Docs / Servers
Go
Standard library only, no dependencies. Every call takes a context, because on a server that is what keeps one user's session apart from another's.
What you need first
A project's DSN. Create a project, then copy it from that project's settings page: it carries the host and the project id, so it is the only thing this SDK needs to be pointed at.
https://<key>@cyclopes.localhost.co.zw/ingest/<project-id>
The key is write-only: it can send events and nothing else, so shipping it inside a mobile app is fine. Read it from your environment rather than committing it.
Install
The module is served by this Cyclopes server, not by a public registry, so the public checksum database cannot vouch for it and has to be told not to try.
$ GOPRIVATE=cyclopes.localhost.co.zw go get cyclopes.localhost.co.zw/go
GOPRIVATE does two things here: it skips the checksum database, and it fetches directly rather than through proxy.golang.org, which cannot reach this host anyway. Discovery then works off the ?go-get=1 probe this server answers at /go.
Set it once with go env -w rather than per command.
Wire it up
import cyclopes "cyclopes.localhost.co.zw/go"
func main() {
cyclopes.Init(cyclopes.Options{
DSN: "https://<key>@cyclopes.localhost.co.zw/ingest/<project-id>",
Environment: "production",
Release: "checkout@1.4.2",
})
defer cyclopes.Flush(2 * time.Second)
if err := charge(order); err != nil {
cyclopes.CaptureException(ctx, err)
}
}
Init is the only call that returns an error, and it returns one only for a malformed DSN. Startup is the only moment you can act on that.
Why every call takes a context
Because on a server it is the difference between per-user journeys and one merged blob.
A server process initialises the SDK once and serves every user. Journeys join exclusively on (project, session_id), so if every request shares the process's one session id, every user's path merges into a single nonsensical session. Python solves this with thread-locals and JavaScript with AsyncLocalStorage; Go already has the right mechanism, and it is the one you are already passing around.
ctx = cyclopes.WithSession(ctx, requestID)
cyclopes.Identify(ctx, map[string]any{"id": user.ID})
cyclopes.Track(ctx, "checkout_started", map[string]any{"cart_total": total})
HTTP middleware
What most servers should do instead of scoping by hand.
mux := http.NewServeMux()
mux.Handle("/checkout", checkoutHandler)
http.ListenAndServe(":8080", cyclopes.Middleware(mux))
Middleware opens a fresh scope per request, derives the session from X-Request-Id, X-Correlation-Id or Traceparent (falling back to a random id), reports panics, and flushes before the process can die. Supply your own rule with MiddlewareWith:
cyclopes.MiddlewareWith(cyclopes.MiddlewareOptions{
SessionID: func(r *http.Request) string { return sessionCookie(r) },
Repanic: true, // let your own recovery middleware see it too
})(mux)
Prefer something stable across a user's requests if you have one, like a signed session cookie. A per-request id makes every request its own session, which is still far better than every user sharing one.
Errors and panics
CaptureException walks the cause chain with errors.Unwrap, five deep:
err := fmt.Errorf("loading cart: %w", pgErr)
cyclopes.CaptureException(ctx, err)
Go errors carry no stack of their own, so the trace is captured at the call site. Report as close to the failure as you can.
Frames are marked in_app using the rule the go tool uses: an import path whose first segment has no dot is standard library, anything under the module cache is a dependency, everything else is yours. In a monorepo where that is too generous, name your own code:
cyclopes.Init(cyclopes.Options{DSN: dsn, InAppPrefix: "example.com/monorepo"})
Panics in a goroutine the middleware does not cover:
go func() {
defer cyclopes.Recover(ctx) // reports, flushes, then re-panics
work()
}()
Logs and journeys
// diagnostic log stream (the Logs page)
cyclopes.Warn(ctx, "upstream slow", map[string]any{"service": "payments", "ms": 812})
// activity stream (the Journeys page)
cyclopes.Identify(ctx, map[string]any{"id": "u-42", "plan": "pro"})
cyclopes.Screen(ctx, "Cart", map[string]any{"items": 3})
cyclopes.Track(ctx, "checkout_started", map[string]any{"total": 99.9})
Items are batched, 30 of them or five seconds, and posted in the background. Step names must be stable and low-cardinality: put the varying part in properties, never in the name. Track(ctx, "order_"+id+"_paid", nil) fragments one step into thousands and makes every journey unmatchable. See naming things.
Screen also records where the user is, and every Track after it says so. Use cyclopes.SetScreen(ctx, "PromoSheet") for an arrival that is not a page of its own. It is written into the scope on ctx when there is one, so a request never stamps its page onto another's; with no scope it falls back to a process-wide value, which is the right answer for a CLI.
Sessions
The process default rotates after 30 minutes of silence. A context-scoped session is never auto-rotated, because the request owns its own lifetime.
cyclopes.Init(cyclopes.Options{DSN: dsn, SessionTimeout: 15 * time.Minute})
cyclopes.NewSession() // rotate explicitly, e.g. on logout
Rotation always flushes first. The batch envelope stamps session_id when the batch is built, so rotating with items still buffered would relabel them onto the new session.
Scrubbing
cyclopes.Init(cyclopes.Options{
DSN: dsn,
BeforeSend: func(e *cyclopes.Event) *cyclopes.Event {
delete(e.User, "email")
return e // or nil to drop it entirely
},
})
A panic inside BeforeSend is contained: the event goes out unmodified rather than disappearing along with the panic.
Guarantees
- Never panics into your application. Every exported entry point recovers.
- Never blocks it. One goroutine drains a bounded queue; when the queue is full or the server unreachable, payloads are dropped and counted (client.Dropped()), never retried forever.
- Never returns an error, except Init, which reports a malformed DSN.
Then
Naming is the part that is expensive to change once data exists under it, and journeys are what the names are for. Both are the same whatever you wrote the app in:
- Journeys, and the one rule about entry steps people get wrong by hand.
- Naming things, before you have a week of data under a name you regret.
- Coming from Sentry: keep the SDK, change the DSN.
Something here wrong or missing? cyclopes@localhost.co.zw.