Developer documentation

Everything SnagSpy does, and how to reach it

SnagSpy takes OpenTelemetry from your application, groups errors into issues, correlates them with deploys, database work, service topology and logs, and tells you why something broke. This page is the complete reference: every endpoint, every setting, every limit — and an honest list of what it does not do.

Everything below was checked against the running code rather than written from memory. If a capability is missing here, assume it is missing from the platform and say so — the section at the end lists the gaps deliberately.

Overview

The platform is a modular monolith in Go with a Nuxt dashboard. Two processes matter to you: ingest, which accepts telemetry, and the API, which serves everything you read. They are separate so a flood of telemetry cannot take the dashboard down with it.

Standard OTLP in

Traces, logs and metrics arrive as OpenTelemetry. Nothing is proprietary, so an application already running an OTel SDK can point it here and skip our SDKs entirely — and can point it somewhere else later.

Correlated, not just grouped

An issue carries the deploy that preceded it, the slow queries around it, the services upstream of it and the logs beside it.

Explained, with the workings

AI investigations select and phrase; the platform computes. Every number in an answer is one you can check, and an answer that cannot be grounded is not given.

Quick start

  1. Create an account and a project. POST /v1/auth/register, then log in — registration deliberately returns no session. Create a project from the dashboard or POST /v1/projects.
  2. Issue a DSN. POST /v1/projects/{projectID}/dsns. A DSN is scheme://publicKey@host/projectID and is safe to ship in a browser bundle — it can write telemetry and read nothing.
  3. Send something. Either SDK below, or plain OTLP to the ingest endpoint.
  4. Watch it arrive. The issue appears under /v1/issues and on the dashboard.

Go

import "github.com/rellinxe/snagspy/sdk/go/snagspy"

client, err := snagspy.New(snagspy.Options{
    DSN:         os.Getenv("SNAGSPY_DSN"),
    Environment: "production",
    Release:     buildSHA,
})
if err != nil {
    log.Fatal(err)
}
defer client.Close(context.Background())

if err := doWork(); err != nil {
    client.CaptureException(context.Background(), err)
}

JavaScript & the browser

import { init, captureException } from "@snagspy/browser"

init({
  dsn: globalThis._importMeta_.env.VITE_SNAGSPY_DSN,
  environment: "production",
  release: __BUILD_SHA__,
})

try {
  risky()
} catch (error) {
  captureException(error)
}

Both SDKs redact secret-shaped values before anything leaves the process. That is not a server-side courtesy — a password sent to us and scrubbed on arrival has still crossed the network.

Concepts

Organisation
The billing and membership boundary. Every piece of data belongs to exactly one, and cross-tenant isolation is a release gate rather than a convention.
Project
One application. Owns its DSNs, its replay consent, and its ingest rate limit.
DSN
A per-project write credential, scheme://publicKey@host/projectID. Public by design: it can write telemetry and read nothing.
API key
A read credential for the dashboard API, scoped to the organisation. Shown once when created.
Event / occurrence
One error as it happened, with its stack, context and correlated signals.
Issue
Occurrences grouped by fingerprint. A minified stack groups on the message plus each frame’s chunk name with the content hash removed, so a redeploy does not split one issue into two.
Trace & span
Standard OTel. The trace list filters on spans and aggregates whole traces, and says which of its numbers are estimates.
Replay unit
The billing unit for session replay: one unit per 64 KiB of uploaded chunk.
Investigation
One AI root-cause analysis. Charged per model call, before the outcome is known — a failed call still cost a call.

SDKs

Both are thin ergonomics layers over OpenTelemetry rather than instrumentation libraries of their own. What they add is the part that is easy to get wrong: DSN handling, exception recording in the shape the platform groups on, and redaction before send.

SDKPackageAdds
Gosdk/go/snagspyDSN parsing, CaptureException, scrubbing, HTTP middleware.
Browser & Nodesdk/jsDSN parsing, error and unhandled-rejection capture, stack normalisation to V8’s shape, session replay, user feedback, scrubbing, a Vite plugin for source maps.

The browser SDK normalises every engine’s stack to V8’s shape before sending, and carries debug ids in a span attribute. A debug id is 32 hexadecimal characters with no separators — the SDK strips the hyphens of a canonical UUID for you.

Sending telemetry

The ingest service is separate from the API and listens on its own port (:8081 by default; publish it as INGEST_PUBLIC_URL). Every route here authenticates with a DSN, not a session.

MethodPathBody
POST/v1/tracesOTLP trace payload. Spans carrying an exception become issues.
POST/v1/logsOTLP logs payload.
POST/v1/metricsOTLP metrics payload.
POST/v1/replayOne replay chunk. See below.
GET/healthzLiveness.

Two buckets apply to every upload: a per-project request limit and a per-organisation event limit. Both are described under Rate limits.

Session replay

Replay is recorded with rrweb, and masking is inverted from rrweb’s defaults: text is masked unless explicitly allowed, rather than exposed unless explicitly masked. The masking happens in the browser before capture, because nothing server-side can undo an unmasked recording that has already arrived.

Uploading a chunk

POST /v1/replay
Content-Type: application/octet-stream
X-Replay-Session:     <session id>
X-Replay-Seq:         <0, 1, 2, …>
X-Replay-Encoding:    json-zstd
X-Replay-Recorded-At: <RFC 3339, optional>

The body is opaque compressed bytes that this platform contractually does not read. An absent or unparseable client clock is not an error — the server substitutes its own, because a recording with a wrong timestamp is still worth keeping.

Three things that will refuse you

  • The project has not consented. Replay capture is per project and off until turned on with PUT /v1/projects/{projectID}/session-replay.
  • Your plan does not sell replay. The free tier does not include it, and the upload is refused with plan_excludes_replay naming your plan. This is not a quota — nothing here counts units against a plan figure.
  • The chunk is too large. The ceiling is applied to bytes actually read, not to what Content-Length claimed.

Watching a recording takes owner scope, re-checks project consent at read time, and is written to the audit trail before the bytes are read.

Source maps

Upload artifacts to POST /v1/projects/{projectID}/artifacts. The row lives in PostgreSQL and the bytes in an object store, addressed by content — so uploading the same map twice costs one copy.

Frames are resolved at read time, not at ingest. A frame that cannot be resolved says why rather than failing the page, so a missing upload degrades to a minified frame with an explanation instead of an error.

Dashboard API

Served by the API process on :8080 by default. Everything is under /v1. Responses are JSON; errors carry a stable code, a human-readable message and a request_id.

Account & projects

POST/v1/auth/registerCreate an account. Returns no session — log in afterwards.
POST/v1/auth/loginExchange credentials for a session cookie.
POST/v1/auth/logoutEnd the current session.
GET/v1/meThe signed-in user and their organisation.
GET/v1/membersMembers of the organisation.
GET/v1/projectsList projects.
POST/v1/projectsCreate a project.
GET/v1/projects/{projectID}One project.
GET/v1/projects/{projectID}/dsnsThe project’s ingest keys.
POST/v1/projects/{projectID}/dsnsIssue a new DSN.
DELETE/v1/projects/{projectID}/dsns/{keyID}Revoke a DSN.
PUT/v1/projects/{projectID}/session-replayTurn replay capture on or off for the project.
GET/v1/api-keysList API keys.
POST/v1/api-keysCreate an API key. The secret is shown once.
DELETE/v1/api-keys/{keyID}Revoke an API key.
GET/v1/settingsOrganisation settings, including retention.
PATCH/v1/settingsChange organisation settings.
GET/v1/audit-logThe audit trail. Not customer-configurable.

Single sign-on

Available only when API_PUBLIC_URL is set. Without it every route here answers sso_unavailable, and the only other sign is a missing “single sign-on enabled” line at boot.

POST/v1/auth/sso/startBegin an SSO login.
GET/v1/auth/sso/callbackIdentity-provider callback.
GET/v1/settings/ssoThe current connection.
PUT/v1/settings/ssoConfigure the connection.
DELETE/v1/settings/ssoRemove the connection.
GET/v1/settings/sso/domainsClaimed email domains.
POST/v1/settings/sso/domainsClaim a domain.
POST/v1/settings/sso/domains/verifyProve a claimed domain.

Issues & events

GET/v1/issuesList issues, filtered and paged.
GET/v1/issues/{issueID}One issue.
GET/v1/issues/{issueID}/eventsOccurrences of an issue.
PATCH/v1/issues/{issueID}Change status (resolve, ignore, reopen).
GET/v1/usersEnd users seen in telemetry.
GET/v1/feedbackUser feedback submissions.
POST/v1/feedbackSubmit user feedback.

Traces, services & performance

GET/v1/tracesTrace list. Filters on spans, aggregates whole traces.
GET/v1/traces/{traceID}One trace with its spans.
GET/v1/servicesService list with health.
GET/v1/services/mapObserved service topology.
GET/v1/services/structureStructural facts about the topology.
GET/v1/services/dependenciesEdges between services.
GET/v1/services/approachingMetrics climbing toward the threshold, with the slope.
GET/v1/trafficTraffic by project rather than by slowest service.
GET/v1/queuesQueue and worker behaviour.
GET/v1/anomaliesDetected anomalies.
GET/v1/endpoints/surgesPer-endpoint abuse signal, no caller identity.
GET/v1/database/slow-queriesSlow database queries.
GET/v1/database/query-patternsQuery patterns.

Logs & metrics (read)

GET/v1/logsSearch log lines.
GET/v1/logs/patternsRecurring log shapes.
GET/v1/logs/surgesLog volume surges.
GET/v1/metrics/namesMetric names seen.
GET/v1/metrics/seriesPoints for a named series.

Session replay

GET/v1/projects/{projectID}/replayRecorded sessions.
GET/v1/projects/{projectID}/replay/{sessionID}One recording’s chunks. Owner scope, re-checks consent, and writes the audit trail before the bytes are read.

Deployments & releases

POST/v1/deploymentsRecord a deployment.
GET/v1/deploymentsList deployments.
GET/v1/deployments/{deploymentID}One deployment.
GET/v1/deployments/{deploymentID}/healthDid this deploy make things worse, with the arithmetic.
GET/v1/releasesReleases, derived from deployments rather than stored.
GET/v1/projects/{projectID}/integrationsConfigured VCS integrations.
PUT/v1/projects/{projectID}/integrations/{provider}Configure github or gitlab.
DELETE/v1/projects/{projectID}/integrations/{provider}Remove one.

Source maps

POST/v1/projects/{projectID}/artifactsUpload a source map or debug artifact.
GET/v1/projects/{projectID}/artifactsList artifacts.
DELETE/v1/projects/{projectID}/artifacts/{artifactID}Delete one.

Alerts, destinations & reports

GET/v1/alertsAlert history.
GET/v1/alert-destinationsWhere alerts are sent.
POST/v1/alert-destinationsAdd a destination.
DELETE/v1/alert-destinations/{destinationID}Remove one.
GET/v1/reports/scheduleThe digest schedule.
PUT/v1/reports/scheduleSet it.
DELETE/v1/reports/scheduleStop it.
GET/v1/reports/previewRender the next digest without sending it.

Cron monitoring & uptime

GET/v1/cron/checksList cron checks.
POST/v1/cron/checksCreate one.
PATCH/v1/cron/checks/{checkID}Change schedule or grace.
DELETE/v1/cron/checks/{checkID}Delete one.
GET/v1/cron/checks/{checkID}/pingsCheck-in history.
POST/v1/cron/checks/{checkID}/rotateRotate the check-in token.
GET/v1/checkins/{token}Check in. Unauthenticated — the token is the credential.
POST/v1/checkins/{token}Check in, with a body.
GET/v1/uptime/monitorsList uptime monitors.
POST/v1/uptime/monitorsCreate one.
PATCH/v1/uptime/monitors/{monitorID}Change one.
DELETE/v1/uptime/monitors/{monitorID}Delete one.
GET/v1/uptime/monitors/{monitorID}/checksProbe results.

Dashboards

GET/v1/dashboardsList dashboards.
POST/v1/dashboardsCreate one.
GET/v1/dashboards/{dashboardID}One dashboard.
PATCH/v1/dashboards/{dashboardID}Rename or re-describe.
PUT/v1/dashboards/{dashboardID}/widgetsReplace the widget set.
DELETE/v1/dashboards/{dashboardID}Delete one.

AI investigations

Every route here needs ANTHROPIC_API_KEY. Without it the feature is off rather than failing per request.

POST/v1/issues/{issueID}/investigateInvestigate an issue.
GET/v1/issues/{issueID}/investigationThe result.
POST/v1/alerts/{alertID}/investigateInvestigate an alert.
GET/v1/alerts/{alertID}/investigationThe result.
POST/v1/projects/{projectID}/investigateInvestigate a project.
POST/v1/projects/{projectID}/explainAsk a question about the topology.
GET/v1/investigationsList investigations.
GET/v1/investigations/{investigationID}One investigation.

Billing

GET /v1/usage is always available. POST /v1/checkout exists only when GENIUSPAY_API_KEY is set — unset, the route is not mounted at all and answers 404 rather than 401, because “there is nothing here” is the honest answer.

GET/v1/usageMetered usage against the plan, with a caveat in every response.
POST/v1/checkoutStart a purchase. Org-admin scope. Returns a hosted checkout URL.

Operational

GET/healthzLiveness. On both the API and the ingest service.
GET/readyzReadiness, including dependencies.

Authentication

CredentialUsed forHow
DSNWriting telemetryIn the SDK config. Public by design — it can write and cannot read.
Session cookieThe dashboardPOST /v1/auth/login. The cookie is slk_session and lasts 720h by default.
API keyScripting the dashboard APIAuthorization header. Created at POST /v1/api-keys, shown once.
Check-in tokenCron check-insIn the URL. The token is the credential, so rotate it with POST /v1/cron/checks/{checkID}/rotate.
SSOThe dashboard, for a claimed domainNeeds API_PUBLIC_URL. Trusts a provider only for the email domain its connection claims, and only for accounts already invited.

Cookie-authenticated requests are Origin-checked. A request without an Origin matching DASHBOARD_ORIGIN is refused with origin_rejected (403). Scripting with curl against a local stack therefore needs -H "Origin: http://localhost:3000".

Rate limits

Rate limits are abuse control, and they are the one thing here that really does refuse a request. They have nothing to do with what your plan includes: going past a plan figure never throttles you and never rejects telemetry. Redis is authoritative so every instance shares a bucket, with a per-process fallback that keeps limiting alive if Redis is unavailable.

LimitDefaultScope
INGEST_REQUESTS_PER_MINUTE600Per project
INGEST_EVENTS_PER_MINUTE30,000Per organisation
AUTH_ATTEMPTS_PER_MINUTE10Per caller
INVESTIGATIONS_PER_HOUR100Per organisation

A refused request answers 429 with Retry-After.

Plans, usage & retention

Usage is metered and reported. It is never acted on at the write path. Going past a plan figure does not throttle you, reject your telemetry or trigger a charge — there is no pay-as-you-go billing here at all. You are told once per signal per billing period, and the message says plainly that nothing has been throttled. The one thing a plan does decide is whether a signal is sold at all: a plan that does not include session replay refuses replay uploads, which is a different act from discarding telemetry somebody paid for.

PlanPriceErrorsLogsMetric pointsReplay unitsInvestigationsRetention
freeFree5k25kNot offeredNot offered107 days
team$25/mo50k250k250k25k25030 days
business$75/mo65k1M1M250k2.5k90 days
enterpriseCustomUnlimitedUnlimitedUnlimitedUnlimitedUnlimited90 days

This table is generated from the same constant the platform bills against, so it cannot advertise a figure the product does not use.

Retention

A plan narrows the platform ceiling and never widens it. The ceilings are 90 days for errors and events and 30 days for trace spans; log lines follow the trace setting. An organisation may narrow its own retention in PATCH /v1/settings. The audit log is not customer-configurable.

A lapsed plan keeps its data for a further 14 days. Limits, seats and features revert the moment a paid period ends, because paying restores them; the retention window does not, because deleted error history does not come back.

Alerts & integrations

Destinations are configured per organisation at /v1/alert-destinations. A destination’s kind chooses the notifier, and a kind with no notifier behind it fails loudly rather than being skipped.

KindNeedsNotes
SlackAn incoming-webhook URLCarries a text field alongside the structured alert.
DiscordA webhook URLCarries a content field.
Generic webhookA URLOutbound requests are checked against a dialler that refuses private and link-local addresses.
EmailSMTP_* configuredA destination is not confirmed until the address proves itself.

Deployments

Record deploys with POST /v1/deployments, or connect GitHub or GitLab at /v1/projects/{projectID}/integrations. Each provider is its own webhook adapter, and a deployment records which provider vouched for it rather than only that a webhook did.

Digests

A scheduled summary at /v1/reports/schedule. Preview the next one without sending it at /v1/reports/preview.

Cron monitoring & uptime

Create a check, then have your job call its check-in URL. The token in the URL is the credential, so the endpoint is unauthenticated by design — a cron job on a box with no secrets can still report in.

# at the end of your job
curl -fsS https://ingest.example.com/v1/checkins/<token>

A check that does not arrive within its grace raises an alert. Uptime monitors are the other direction: the platform probes a URL you name and records the result at /v1/uptime/monitors/{monitorID}/checks.

AI investigations

An investigation reads one issue, alert or project and explains what happened. The division of labour is deliberate and worth knowing before you trust an answer: the model selects and phrases; the platform computes. Every figure in an answer comes from a query, not from the model, and an answer that cannot be grounded in one is not given.

  • Off entirely unless ANTHROPIC_API_KEY is set — not failing per request, off.
  • Charged per model call, before the outcome is known. A call that failed still cost a call, because charging only on success would hide a runaway loop in the one place you would look for it.
  • Declinable per organisation. The data sent is a deliberately minimised subset of a single issue: exception type, message, stack, culprit, counts and the correlated release. Never end-user identifiers, never raw attribute bags, never whole events.

Self-hosting

The stack is Docker Compose: PostgreSQL, Redis, ClickHouse, the API, ingest, a worker and a one-shot migrate service. Everything is configured by environment.

Required

DATABASE_URLPostgreSQL connection string. No default.
REDIS_URLRedis, used for rate limiting. No default.

Core

ENVdevelopment (default) or production. Production refuses sandbox payment deliveries.
LOG_LEVELinfo by default.
DASHBOARD_ORIGINWhere the dashboard is served. Cookie-authenticated requests must send a matching Origin or get origin_rejected.
API_PUBLIC_URLSwitches single sign-on on. Unset, SSO answers sso_unavailable.
INGEST_PUBLIC_URLThe endpoint published in DSNs.
SESSION_COOKIE_NAMEslk_session by default.
SESSION_TTL720h by default.
SECURE_COOKIESSet for HTTPS deployments.
SHUTDOWN_TIMEOUT15s by default.

Limits

INGEST_REQUESTS_PER_MINUTE600 by default, per project.
INGEST_EVENTS_PER_MINUTE30000 by default, per organisation.
AUTH_ATTEMPTS_PER_MINUTE10 by default.
INVESTIGATIONS_PER_HOUR100 by default.

Storage & secrets

CLICKHOUSE_URLEnables the ClickHouse event store. Its presence also switches reads to it.
READ_EVENTS_FROM_CLICKHOUSEDerived from CLICKHOUSE_URL. Run the backfill before flipping it on a database with history.
SECRET_ENCRYPTION_KEYRequired by anything storing an encrypted secret, or it answers secrets_not_durable.
ARTIFACT_DIR / ARTIFACT_S3_*Where source-map artifacts live. A service pointed at the wrong store fails quietly.
BACKUP_ENCRYPTION_KEYRequired by every backup command. A different value makes existing artifacts unreadable.
BACKUP_DIR / BACKUP_S3_* / BACKUP_WAL_SPOOLBackup destination and write-ahead spool.
BACKUP_RETENTION_DAYS / BACKUP_MIN_ARTIFACTSHow much history to keep.

Optional features

ANTHROPIC_API_KEY / ANTHROPIC_MODELAI investigations. Off entirely when unset.
GENIUSPAY_API_KEY / _API_SECRET / _WEBHOOK_SECRETBuying a plan. The key is the switch: unset, neither checkout nor the webhook route is mounted.
GENIUSPAY_BASE_URLDefaults to the merchant API base.
SMTP_HOST / _PORT / _USERNAME / _PASSWORD / _FROMEmail alert delivery.
OPERATOR_WEBHOOK_URLOperator alerts about this deployment. Deliberately unset by default, so it sends nowhere.
ACCOUNT_ALERT_WEBHOOK_URLCommercial alerts about customers. Falls back to OPERATOR_WEBHOOK_URL.
OTLP_ENDPOINTWhere this platform sends its own telemetry.

Commands

cmd/apiThe dashboard API.
cmd/ingestThe telemetry ingest service. A separate process on purpose.
cmd/workerScheduled jobs — retention, purge, digests, alert evaluation.
cmd/alertsEvaluate alerts once, by hand.
cmd/migrateApply migrations. Embedded in the binary, so a new .sql file does nothing until the image is rebuilt.
cmd/seedBuild the local fixture organisation and projects.
cmd/planAssign a plan to an organisation. An operator action, and audited.
cmd/retentionApply retention now.
cmd/purgeDelete telemetry belonging to organisations that no longer exist.
cmd/eraseErase one subject’s data on request.
cmd/refingerprintRe-key issue grouping across history.
cmd/backfillCopy existing events into ClickHouse before switching reads to it.
cmd/backupTake an encrypted backup.

Migrations are embedded in the binary. A new .sql file does nothing until the migrate image is rebuilt, and the container still logs “migrations applied” either way. Check the schema version rather than the log line.

Security & privacy

  • Scrubbing happens twice. The SDKs redact secret-shaped values before send, and the ingestion path scrubs again on arrival — keys, tokens, authorization headers, JWTs, cloud credentials, and key=value assignments in free text.
  • Replay is masked before capture, in the browser, with rrweb’s defaults inverted.
  • Tenants are isolated as a release gate, not as a convention. The cross-tenant suite is part of the check that must pass.
  • Outbound requests are checked. Alert destinations are dialled through a guard that refuses private and link-local addresses, so a webhook cannot be pointed at the inside of the network.
  • Plan changes are audited. Who granted what is the only history that assignment keeps.
  • The marketing site stores nothing and calls nobody — no analytics, no CDN, no fonts fetched at runtime — which is why it carries no cookie banner. A test enforces it.

The sub-processor register and the retention ceilings are published separately and are part of the same commitment: see the security and privacy pages.

What this platform does not do

Listed rather than omitted. A developer who cannot find a feature assumes they missed it, and finds out the expensive way.

  • It does not act on what a plan includes. Going past a plan figure never throttles you, never rejects telemetry, and never triggers a charge — there is no overage billing at all.
  • It does not predict failure. It reports a metric climbing toward a line it already draws, with the slope and the traffic behind it, and says what would break the projection. There is no probability and no time-of-death.
  • It does not suggest dependency upgrades. A stack frame names a package and never a version, so a suggestion would be a confident instruction about software it has never seen.
  • It does not execute remediation. A suggestion is a sentence.
  • There is no annual billing. The catalogue holds one monthly price and checkout sells months at it.
  • There are no invoices yet. Nothing is issued to anyone.
  • Metric-point ingestion is not plan-gated, unlike replay. A plan without a metric-point figure can still send them and have them metered.
  • The dashboard is English only. The marketing, legal and documentation pages are in English and French; the product is not.

Something here disagrees with what the platform actually did? That is a bug in this page and worth reporting — it is meant to be checkable line by line.