A lightweight, self-hosted, Sentry-compatible error tracker. One Go binary, one SQLite file, a 15MB Docker image. No Redis, no Postgres, no Node build step, no CDN at runtime. Point your app's official Sentry SDK at it and it just works - built to run comfortably on a small VPS instead of needing a cluster.
Sentry (self-hosted) and even lighter forks like GlitchTip or Bugsink are real, mature products - but they're still a multi-service stack with real memory and operational overhead. Errgotrack is the "I just want to see my app's exceptions, with alerts, on a $5 VPS" version: a single static binary that embeds its own frontend, talks the real Sentry wire protocol, and keeps its entire database in one SQLite file you can back up with one command.
- Sentry-compatible ingestion - the real envelope + legacy store protocol, not a custom API. Any official Sentry SDK works unmodified; verified against the real Python, Node, and Java SDKs, not just hand-crafted test payloads.
- Automatic issue grouping - repeated occurrences of the same bug collapse into one issue instead of flooding your list; resolved issues reopen automatically if they recur.
- A real dashboard - events over time, top exceptions, error distribution by project/environment, new-vs-recurring issues, all backed by live SQL aggregates, not placeholder charts.
- Rich issue detail - full stack traces with surrounding source lines
and captured local variables, tags, breadcrumbs (with their structured
data), user/request context, and Sentry's
contextsobject (browser, OS, device, custom frameworks) all rendered, not just the exception message. - Notifications on Telegram, Slack, Discord, Microsoft Teams, and Google Sheets - configure any combination; each delivers independently through a durable retry queue, so one channel's outage never blocks or duplicates another's alert, and none of them can block event ingestion itself.
- Trace correlation - if an event carries a
trace_id(from an SDK wired for distributed tracing), link straight out to whatever tracing backend you already run (Jaeger, Tempo, Honeycomb, ...). Errgotrack doesn't store spans itself - it stays an error tracker, not an APM. - Multi-project, with per-platform setup snippets - create a project, pick its framework from a dropdown, get the right install command + init code with your real DSN already filled in.
- Search, filters, bulk actions - full-text search over messages and exception types (SQLite FTS5), filter by project/environment/status, bulk resolve/ignore.
- Configurable retention - old events age out in the background without ever touching issue history (counts, first/last seen stay intact).
- Dark/light theme, keyboard-friendly, no CDN - every asset (HTMX, Chart.js, icons) is embedded in the binary.
- Deploys in one command - a 15MB Docker image, non-root, with its own health check (no shell needed), or a single static binary with no dependencies at all.
Any official Sentry SDK speaks the same protocol, so this list is really "what has a ready-made setup snippet in the UI today," not a hard compatibility wall:
| Platform | Status |
|---|---|
| Python - Django | ✅ verified with the real SDK, real request-handling exception |
| Python - Flask | ✅ verified with the real SDK, real request-handling exception |
| Python - FastAPI | ✅ verified with the real SDK, real request-handling exception |
| Node.js | ✅ verified with @sentry/node |
| Java (plain JVM, Spring Boot, Quarkus) | ✅ core SDK verified directly; Spring Boot/Quarkus wrap the same core |
| Next.js | |
| JavaScript (browser) | |
| Other / any Sentry SDK | Should work - it's the same wire protocol everywhere |
Docker:
docker build -t errgotrack:latest .
docker run -d --name errgotrack -p 8080:8080 \
-v errgotrack-data:/data \
-e ERRGOTRACK_SESSION_SECRET=$(openssl rand -hex 32) \
-e ERRGOTRACK_ADMIN_USERNAME=admin \
-e ERRGOTRACK_ADMIN_PASSWORD='change-me' \
-e ERRGOTRACK_PUBLIC_URL=http://localhost:8080 \
errgotrack:latest(If you've pushed this repo to GitHub, CI builds and publishes the image to
ghcr.io/<you>/errgotrack:latest on every push to main - see
.github/workflows/docker.yml - so you can
docker pull that instead of building locally.)
Or run it directly with Go, no Docker at all:
Requires Go 1.26+ (go build auto-downloads the matching toolchain if
your installed Go is older, per the go directive in go.mod).
go build -o bin/errgotrack ./cmd/server
ERRGOTRACK_SESSION_SECRET=$(openssl rand -hex 32) \
ERRGOTRACK_ADMIN_USERNAME=admin \
ERRGOTRACK_ADMIN_PASSWORD='change-me' \
ERRGOTRACK_PUBLIC_URL=http://localhost:8080 \
./bin/errgotrackOr skip the explicit build and just go run ./cmd/server with the same
environment variables, if you're iterating rather than deploying.
Then:
- Open http://localhost:8080 and log in.
- Create a project, picking your framework from the dropdown.
- Copy the DSN and install snippet shown - it already has your real DSN filled in.
- Trigger a real exception in your app. It should show up on the dashboard within a second.
No app handy to test with? Run
tests/integration_sentry_sdk.sh: it
spins up a throwaway server, creates a project through the real HTTP API,
and sends an exception through the official sentry-sdk Python package -
useful for confirming the server itself works before wiring up anything.
All configuration is environment variables (see internal/config/config.go):
| Variable | Default | Purpose |
|---|---|---|
ERRGOTRACK_ADDR |
:8080 |
listen address |
ERRGOTRACK_DB_PATH |
data/errgotrack.db |
SQLite file path |
ERRGOTRACK_PUBLIC_URL |
http://localhost:8080 |
used to build DSNs and alert links; https:// here also makes session/CSRF cookies Secure |
ERRGOTRACK_ADMIN_USERNAME / ERRGOTRACK_ADMIN_PASSWORD |
- | bootstraps the one admin account on first run only |
ERRGOTRACK_SESSION_SECRET |
- | required; any random 32+ byte string |
ERRGOTRACK_RETENTION_SWEEP |
1h |
how often the retention sweeper runs |
Implements enough of the envelope
and legacy store endpoints for
official Sentry SDKs to work unmodified. Verified against real SDK
installs (Python, Node, Java), not just hand-crafted payloads - see
tests/integration_sentry_sdk.sh.
Supported:
- Envelope (
POST /api/<project>/envelope/) and legacy store (POST /api/<project>/store/) endpoints, both gzip or plain. X-Sentry-Authheader and DSN-embedded key authentication.- Error events: exception type/value/module, full stacktrace (file, line, function, module, context line + surrounding source, captured local variables, in-app flag), chained exceptions.
- Timestamps (unix float or ISO8601), level, logger, platform, release, environment, transaction/culprit.
- Tags (object or legacy array form), extra context, breadcrumbs (with
structured data), user context, request context, SDK metadata, and the
free-form
contextsobject (browser, OS, device, runtime, trace, framework-specific contexts). - Client-supplied
fingerprintoverrides automatic grouping. - Bare message events (no exception) with their own grouping.
- Captured local variables of any shape - some SDKs repr() a local down to a plain string, others (e.g. Flask with a dict-valued local) send actual nested JSON; both are handled.
Explicitly not implemented (accepted and silently dropped where the protocol allows it, so a real SDK never errors, but nothing is stored or shown for these):
- Performance tracing / transactions, profiling, session replay, release health "sessions", client reports, metrics. This is an error tracker, not an APM platform - see Trace correlation above for the lightweight alternative.
- Attachments (binary item payloads are parsed per envelope framing but not persisted).
- Source map resolution / server-side symbolication of minified stacks.
X-Sentry-Rate-Limitsbackoff signaling to the SDK (the server rate-limits by IP; it doesn't yet tell a misbehaving client to slow down).
Events are grouped by a fingerprint: a client-supplied fingerprint wins if
present, otherwise it's derived from the exception type plus the
module+function of each in-app stack frame (internal/issues/fingerprint.go).
The exception message is deliberately excluded, since messages often embed
request-specific values ("User 42 not found" vs "User 99 not found")
that would otherwise fragment one bug into many issues.
A resolved issue that sees a new occurrence flips back to unresolved
("reopened"). An ignored issue does not auto-reopen - that's the point of
ignoring it.
Server-rendered Go templates + HTMX for interactivity + a small hand-written
CSS theme (light/dark via prefers-color-scheme and a manual toggle) +
Chart.js for charts. No Tailwind build step: deliberately a plain CSS file
with a small utility-class layer instead, to avoid needing a Node/Tailwind
toolchain in the build. HTMX, Chart.js and all icons (inlined Lucide SVGs)
are vendored under web/static/, so the running app needs no CDN or
internet access to render.
Five channels, any combination:
- Telegram - bot token + chat id.
- Slack, Discord, Microsoft Teams - a single incoming-webhook URL each, simpler than Telegram's bot setup.
- Google Sheets - paste the URL of a Google Apps Script Web App you deploy yourself (snippet provided on the Settings page); it appends one row per alert. No Google credentials are ever stored on the server.
Configure under Settings and use "Send test" to confirm delivery. Alerts
fire for new issues and reopened issues by default (5 minute cooldown per
issue, shared across every enabled channel so one trigger doesn't spam
through all four); per-project/environment rules and occurrence thresholds
live in the notification_rules table.
Delivery goes through a durable SQLite queue, one row per channel per alert, drained by a background worker with exponential backoff. This means: a channel being down or misconfigured never blocks or fails event ingestion, and it never blocks or duplicates delivery to the other enabled channels either.
Not full APM - see "Explicitly not implemented" above - but when an event
carries a trace_id (from any SDK configured with distributed tracing),
the issue page shows it with a copy button, and - if you've configured a
URL template under Settings (e.g. https://tempo.example.com/explore?traceID={trace_id}) -
a direct link into whatever tracing backend you already run.
Each project has a retention_days setting. A background sweeper
(internal/retention) deletes events older than that in bounded batches of
500 rows, so it never holds a long-running transaction that would stall
ingestion. Issue summaries (title, counts, first/last seen) are a
separate table and are never deleted by retention - only the individual
event rows backing them age out.
SQLite via modernc.org/sqlite (pure Go, no CGO - a single static binary,
no libsqlite3 to manage). WAL mode, busy_timeout=5000, foreign keys on,
and a capped connection pool (8), since SQLite has exactly one writer
regardless of pool size. Issue-counter updates run under BEGIN IMMEDIATE
to serialize concurrent ingestion correctly instead of racing a
read-then-write (see internal/issues/store.go and the concurrency test in
internal/issues/store_test.go, which fires 40 concurrent events at the
same fingerprint and checks the final count is exactly 40, with
-race clean).
Event search uses an FTS5 virtual table over message/exception type/value, kept in sync via triggers, so free-text search doesn't scan every row.
PostgreSQL isn't implemented. If you outgrow single-writer SQLite, the
natural path is swapping internal/database for a pgx-backed
implementation behind the same store interfaces - nothing else depends on
SQLite specifics except the FTS5 search query and the BEGIN IMMEDIATE
locking trick, both isolated to small, easy to find call sites.
$ go test -bench=. -benchmem -run '^$' ./internal/ingestion/...
BenchmarkIngestEventNewIssue-8 2340 469734 ns/op 15602 B/op 268 allocs/op
BenchmarkIngestEventSameIssue-8 2824 418826 ns/op 15453 B/op 264 allocs/op
(Apple M2, realistic payload: 4-frame stacktrace, 2 breadcrumbs, user/tags/
request context - see internal/ingestion/bench_test.go.) That's roughly
2,000-2,400 single-threaded events/sec against on-disk SQLite, which is far
beyond what a small app's worker processes will produce.
go test ./... # unit + integration tests against temp SQLite DBs
go test -race ./... # same, with the race detector
./tests/integration_sentry_sdk.sh # real sentry-sdk client, real HTTP serverCovers: envelope/store parsing (including length-prefixed items with
embedded newlines), auth/project resolution, fingerprinting and grouping
edge cases, concurrent ingestion, migrations (idempotent re-open, FK
enforcement), FTS5 search and pagination, notification formatting/retry/
backoff/cooldowns across all four channels and their independence from
each other, retention deletion vs. issue summary preservation, and
HTML-escaping of untrusted event data (a regression test renders an issue
detail page with a <script> payload in the exception type/tags/project
name and asserts it never appears unescaped - stored XSS is the main
injection risk here, since exception messages and tags come straight from
the instrumented app).
Docker:
cp .env.example .env # set ERRGOTRACK_SESSION_SECRET, ERRGOTRACK_ADMIN_USERNAME/PASSWORD
docker compose up -dRuns as a non-root user on a distroless/static base image (15MB), SQLite
data on a named volume, health-checked via the binary's own -healthcheck
flag (the runtime image has no shell/curl for a traditional HEALTHCHECK).
Standalone binary, no Docker:
make build
ERRGOTRACK_SESSION_SECRET=... ERRGOTRACK_ADMIN_USERNAME=... ERRGOTRACK_ADMIN_PASSWORD=... \
ERRGOTRACK_PUBLIC_URL=https://errors.example.com ./bin/errgotrackPut it behind a reverse proxy for TLS - see
deploy/Caddyfile or deploy/nginx.conf.
ERRGOTRACK_PUBLIC_URL must use https:// for session/CSRF cookies to be
marked Secure.
Upgrading: stop the container/process, replace the binary/image, start
it again - migrations in internal/database/migrations/ run automatically
and are idempotent.
Backup: the entire database is one file (plus its -wal/-shm
sidecars while running). Simplest safe backup:
sqlite3 /data/errgotrack.db ".backup /backup/errgotrack-$(date +%F).db"(.backup is safe against a concurrently-running writer; cp-ing the file
directly while the server is live is not.) Restore: stop the server,
replace the file, start it again.
- No performance tracing / APM, profiling, or session replay - this is an error tracker. See Trace correlation above for linking out instead.
- No multi-user/team/RBAC model - one admin account, matching "a private dashboard on a VPS."
- No PostgreSQL backend yet (see Storage above).
- No source map resolution for minified JS stacks.
cmd/server/ entrypoint, wiring, graceful shutdown
internal/sentryprotocol/ envelope + store parsing, auth/DSN parsing
internal/ingestion/ HTTP handlers for the Sentry-compatible endpoints
internal/issues/ fingerprinting, grouping, status transitions
internal/events/ event storage, FTS5 search
internal/dashboard/ aggregate queries behind the dashboard/stat panels
internal/projects/ project CRUD, DSN/key rotation
internal/platforms/ per-framework setup snippets shown on a project's page
internal/notifications/ alert rules, durable per-channel queue
internal/telegram/ + internal/webhooks/ channel-specific formatting/delivery clients
internal/tracing/ trace_id -> external tracing backend URL correlation
internal/auth/ admin login, sessions, CSRF
internal/retention/ bounded-batch event cleanup
internal/database/ SQLite open + embedded migrations
internal/httpserver/ routes, middleware, template rendering
web/ templates + vendored static assets (embedded into the binary)
examples/django/ settings snippet + a view that deliberately throws
tests/ the real-SDK integration script
