Error monitoring · by SmartBear Software
Build your own BugSnag.
Catch errors from your apps, group them by stack trace, and get a Slack message when something new breaks. BugSnag bills you per event for that.
What you're paying now
$1,524 per year
BugSnag Preferred at 300k events a month for a small team: $127 × 12 months on annual billing. $1,800 if you pay monthly.
Build it in any tool
Paste into Claude Code, ChatGPT, Codex, Replit, Lovable, or Cursor. The agent fetches everything it needs.
Paste anywhere
Build my own BugSnag. Fetch https://buildyourown.software/like/bugsnag/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals.
Claude Code
claude "Build my own BugSnag. Fetch https://buildyourown.software/like/bugsnag/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
Codex CLI
codex "Build my own BugSnag. Fetch https://buildyourown.software/like/bugsnag/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
01
What it does
BugSnag is an error monitoring service. You drop an SDK into your web, mobile, or backend app. When something throws, the SDK sends the error class, message, stack trace, the user, and a trail of breadcrumbs to BugSnag. BugSnag groups matching events into one issue and shows you how often it happens and how many users it hit.
On top of that it tracks releases and gives each one a stability score (the share of sessions that didn't crash), symbolicates minified stacks with source maps, and alerts Slack, email, or PagerDuty when a new issue shows up or a fixed one comes back. SmartBear bought it in 2021 and now also sells performance monitoring on the same dashboard.
For a small team with a web app and a Node backend, the part you use is: receive a JSON payload, hash the stack to group it, show a list, and post to Slack. That's a weekend build with one tricky piece (source maps).
02
What it costs
List prices from the vendor's own pricing page. The fine print is where the money goes.
Free
$0
1 user
- –7.5K events and 1M spans per month
- –7 days data retention
- –50+ platforms, 30+ integrations
Select
$20
per month billed annually, at 50k events ($23 monthly)
- –Unlimited users
- –60 days data retention
- –End-to-end diagnostics
- –Basic notifications
Price steps up with the event pack: 150k is $32, 300k is $65, 1M is $200, 3M is $534 a month on annual billing.
Preferred
$33
per month billed annually, at 100k events ($39 monthly)
- –Automatic error prioritization
- –Stability benchmarks and targets
- –Advanced segmentation and custom notifications
- –SAML single sign-on
300k events is $127, 500k is $219, 1M is $399, 3M is $1,069 a month on annual billing.
Enterprise
Custom
custom event volume and retention
- –Automatic error assignment
- –Premium support and a named CSM
- –On-premises or SaaS
- –Sensitive data management
Contact sales.
The fine print
- The Free plan is one user. The moment a second person needs to log in, you're paying for Select.
- You buy events in fixed packs. Exceed your daily share more than 3 times in a rolling 30 days and BugSnag auto-upgrades you to the next pack. Turn that off and it rate-limits your events instead.
- Minidump crashes (Electron, Breakpad, Crashpad) count as 5 events each.
- Custom notifications, stability targets, and advanced segmentation are Preferred features. Select only gets basic notifications.
- Performance monitoring is a second slider (spans) with its own packs. The starting price includes only the smallest one.
- Paid plans keep 60 days of data. Free keeps 7. Older events are gone unless you're on Enterprise.
Prices checked September 10, 2026 at www.bugsnag.com. They change. Check before you quote them.
03
What you actually use
The headline features, and an honest call on whether a typical team needs each one.
8 of 13 headline features are worth building for a typical user. The rest is what you're paying for.
- Build it
Error ingest from SDKs
A browser and a Node SDK catch unhandled errors and post a JSON payload to your server.
Why: This is the product. Each SDK is about 100 lines once you skip mobile.
- Build it
Grouping into issues
Hash the normalized stack trace so the same bug from a thousand users is one row.
Why: Without grouping you have a log file. The fingerprint function is the core of the build.
- Build it
Issue inbox
List of issues with event count, users affected, first and last seen, sorted and filtered.
Why: The screen you open every morning.
- Build it
Stack trace, breadcrumbs, and metadata
Per-event view of frames, the clicks and requests before the error, the user, and custom data.
Why: This is how you reproduce the bug. Breadcrumbs are a ring buffer in the SDK.
- Build it
Source map symbolication
Upload source maps per release and turn main-8f3a2c.js:1:48213 into checkout.ts:42.
Why: Minified browser stacks are useless without it. One library call per frame.
- Build it
Release tracking and stability score
Every event carries a release version. Stability is the share of sessions with no unhandled error.
Why: Tells you whether the deploy you just shipped made things worse. It's two counters per release.
- Build it
Slack and email alerts
A message when a new issue appears or a resolved one regresses.
Why: Slack incoming webhooks and one transactional email. An afternoon.
- Build it
Resolve, ignore, snooze
Issue states, plus 'resolved in release X' so old builds don't reopen it.
Why: Without states the inbox fills up with noise you already know about.
- Maybe
Custom filters and segmentation
Pivot issues by any metadata key you send (plan tier, region, browser).
Why: A jsonb column and a WHERE clause. Add it when you have a question the list can't answer.
- Maybe
Spike detection
Alert when the project error rate jumps well above its baseline.
Why: Compare this hour to the last 24. Cheap, but new-issue and regression alerts cover most of it.
- Skip
Performance monitoring and tracing
Spans, Web Vitals, app start time, distributed traces.
Why: Different product with its own data model. Use OpenTelemetry if you want it.
- Skip
Mobile and native SDKs
iOS, Android, React Native, Unity, dSYM and ProGuard symbolication.
Why: Native crash handling is real work. If you ship mobile apps, that's the one reason to keep paying.
- Skip
Two-way issue tracker sync, SSO, on-prem
Push to Jira and sync status back, SAML login, self-hosted BugSnag.
Why: You already self-host. A 'create GitHub issue' link is enough.
04
How it works under the hood
The data model, the flows that matter, and the integrations you need. This is what the build prompt is based on.
Data model
Project
id · name · slug · platform (browser | node) · api_key · stability_target · retention_days · daily_event_limit · alert_environments
api_key is 32 random bytes as hex. The SDK sends it in a header.
Issue
id · project_id · fingerprint · error_class · message_template · culprit · status (open | resolved | ignored | snoozed) · resolved_in_release · snoozed_until · snooze_event_target · first_seen · last_seen · event_count · user_count · first_release · last_release
Unique on (project_id, fingerprint). Counts only go up; retention never decrements them.
Event
id · project_id · issue_id · occurred_at · received_at · exceptions (json) · exceptions_resolved (json) · release · environment · session_id · user_key · user (json) · breadcrumbs (json) · metadata (json) · request (json) · handled · severity · pinned
The raw thing the SDK sent, plus the source-mapped copy. pinned rows survive retention.
Release
id · project_id · version · environment · git_sha · first_seen · sessions · crashed_sessions · events · new_issues
stability = 100 × (1 - crashed_sessions / sessions). Created automatically the first time a version shows up.
SourceMap
id · project_id · release · minified_file · map (text) · uploaded_at
Match on file basename, ignoring origin and query string. Keep parsed maps in an in-memory LRU.
AlertChannel
id · project_id · kind (slack | email) · config (json: webhook_url or emails[]) · on_new_issue · on_regression · enabled
Plus a notifications_log table so you never send the same new-issue alert twice.
Key flows
Ingest an event
- 1.SDK POSTs JSON to /api/ingest with the project API key in a header.
- 2.Validate with zod, truncate long fields, scrub keys like password and token, reject over 256 KB.
- 3.Resolve minified frames through any source map stored for that release.
- 4.Normalize the top in-app frames and hash them with the error class to get the fingerprint.
- 5.Upsert the issue on (project_id, fingerprint), insert the event, bump daily counters and the distinct-user set, all in one transaction.
- 6.After commit, decide whether this is a new issue or a regression and send alerts.
Fingerprint a stack
- 1.Take up to 5 in-app frames from the top of the first exception (all frames if none are in-app).
- 2.Strip the origin, query string, and content hashes from file paths: main-8f3a2c.js becomes main-*.js.
- 3.Drop line and column for minified files that couldn't be source-mapped; keep file, function, and line otherwise.
- 4.If there are no frames at all, use the error class plus a message with numbers, ids, and quoted strings replaced by placeholders.
- 5.sha256 the joined string. A client-supplied groupingKey overrides all of this.
Release stability
- 1.The SDK sends a session start on page load or process start; Node's Express middleware counts one per request in 60-second batches.
- 2.Each event carries the session id. The first unhandled event for a session inserts into crashed_sessions and increments the release counter.
- 3.Stability = 100 × (1 - crashed / sessions). Releases under the project target show red on the releases page.
Alerts
- 1.The issue upsert returns whether it inserted or updated. Insert means new issue: post to Slack and email once, logged in notifications_log.
- 2.An event on a resolved issue reopens it and fires a regression alert, unless the event's release is older than resolved_in_release.
- 3.Regression alerts are capped at one per issue per hour. Ignored issues never alert.
Retention rollup
- 1.A nightly cron at 03:00 UTC runs per project.
- 2.Delete events older than retention_days in batches of 5,000, skipping the pinned first and last event of each issue.
- 3.Charts keep working because issue_daily_stats and release counters were written at ingest and are kept for 400 days.
- 4.Delete crashed_sessions rows and source maps for releases with no events in 90 days.
Integrations
- Postgres (Supabase, Neon, or Railway)requiredIssues, events, releases, counters, source maps.
- Auth (Supabase Auth, Clerk, or NextAuth)requiredMagic-link sign-in for the dashboard. The SDKs authenticate with API keys.
- Slack incoming webhooksrequiredNew issue and regression messages.
- Resend (or Postmark)requiredAlert emails and the daily digest.
- Cron (Vercel Cron or a scheduled job)requiredNightly retention rollup and the daily digest.
- @jridgewell/trace-mappingrequiredSource map lookups for minified browser frames.
- OpenRouteroptionalAn 'explain this error' button on the issue page.
- GitHuboptionalLink resolved frames to source lines and create an issue from an error.
05
The build prompt
The full spec the one-line command points at. Paste it directly if your tool can't fetch URLs, or edit the data model and non-goals first to fit your team.
# Build an error tracking service (replacing BugSnag for your own apps)
You are building a self-hosted error tracking service for a small team that ships a web app and a Node backend. It replaces BugSnag for teams that use error monitoring, issue grouping, source maps, release stability, and Slack alerts, and nothing else. Build it end to end: the server, the dashboard, a browser SDK, and a Node SDK. Correctness of grouping matters more than features. If two events are the same bug they must land in the same issue, and if they aren't they must not.
## Stack
- Next.js (App Router) with TypeScript and Tailwind
- Postgres via Supabase or Neon, with Drizzle (or Prisma)
- Auth for the dashboard: magic-link email (Supabase Auth, Clerk, or NextAuth). One organization. Every signed-in user sees every project.
- `zod` for payload validation
- `@jridgewell/trace-mapping` for source maps
- Resend for email, Slack incoming webhooks for chat alerts
- Vercel Cron (or any scheduler) hitting `/api/cron/retention` nightly and `/api/cron/digest` daily
- Monorepo layout: `apps/web` (the service), `packages/sdk-browser`, `packages/sdk-node`, `examples/` (a test page and a test Express app)
If the repo already uses a different stack, use that and keep the behavior identical.
## Data model
All tables have `id` (uuid), `created_at`, `updated_at`. All timestamps are `timestamptz` stored in UTC.
- `users`: `email` (unique), `name`.
- `projects`: `name`, `slug` (unique), `platform` (`browser` | `node`), `api_key` (32 random bytes as hex, unique), `stability_target` (numeric, default 99.5), `retention_days` (int, default 30, min 7, max 90), `daily_event_limit` (int, default 100000), `alert_environments` (text[], default `{production}`).
- `issues`: `project_id`, `fingerprint` (text), `error_class`, `message_template`, `message_sample` (the first raw message), `culprit` (top in-app frame as `file in function`), `status` (`open` | `resolved` | `ignored` | `snoozed`), `resolved_at`, `resolved_in_release` (nullable text), `snoozed_until` (nullable), `snooze_event_target` (nullable int), `regressed_at`, `first_seen`, `last_seen`, `event_count` (bigint), `user_count` (int), `first_release`, `last_release`, `assignee_id` (nullable). Unique index on (`project_id`, `fingerprint`). Index on (`project_id`, `status`, `last_seen desc`).
- `events`: `project_id`, `issue_id`, `occurred_at`, `received_at`, `exceptions` (jsonb, as sent), `exceptions_resolved` (jsonb, after source maps, nullable), `release` (text, nullable), `environment` (text, default `production`), `session_id` (nullable), `user_key` (nullable text), `user` (jsonb), `breadcrumbs` (jsonb), `metadata` (jsonb), `request` (jsonb), `sdk` (jsonb), `handled` (bool), `severity` (`error` | `warning` | `info`), `pinned` (bool, default false). Index on (`issue_id`, `occurred_at desc`).
- `issue_users`: `issue_id`, `user_key`, `first_seen`, `last_seen`. Unique on (`issue_id`, `user_key`).
- `issue_daily_stats`: `issue_id`, `day` (date, UTC), `event_count`, `user_count`. Unique on (`issue_id`, `day`).
- `releases`: `project_id`, `version`, `environment`, `git_sha` (nullable), `first_seen`, `sessions` (bigint), `crashed_sessions` (bigint), `events` (bigint), `new_issues` (int). Unique on (`project_id`, `version`, `environment`).
- `release_daily_stats`: `release_id`, `day`, `sessions`, `crashed_sessions`, `events`. Unique on (`release_id`, `day`).
- `crashed_sessions`: `project_id`, `release_id`, `session_id`, `day`. Unique on (`project_id`, `session_id`).
- `project_daily_stats`: `project_id`, `day`, `events`, `dropped`. Unique on (`project_id`, `day`).
- `source_maps`: `project_id`, `release`, `minified_file` (basename, e.g. `main-8f3a2c.js`), `map` (text), `uploaded_at`. Unique on (`project_id`, `release`, `minified_file`).
- `alert_channels`: `project_id`, `kind` (`slack` | `email`), `config` (jsonb: `{ webhookUrl }` or `{ emails: string[] }`), `on_new_issue` (bool), `on_regression` (bool), `enabled` (bool).
- `notifications_log`: `issue_id`, `channel_id`, `kind` (`new_issue` | `regression`), `sent_at`, `error` (nullable).
## The ingest payload
`POST /api/ingest` with header `X-Api-Key: <project api_key>` (also accept `apiKey` in the body for `sendBeacon`). Accept `Content-Type: application/json` and `text/plain`. Answer `OPTIONS` with CORS headers and set `Access-Control-Allow-Origin: *` on every response.
```json
{
"sdk": { "name": "errorwatch-browser", "version": "0.1.0" },
"release": "1.4.2",
"environment": "production",
"sessionId": "8c1f0c2e-7d9a-4a2e-9a6f-3c1b2f9b7e10",
"occurredAt": "2026-09-10T14:03:22.120Z",
"handled": false,
"severity": "error",
"groupingKey": null,
"exceptions": [
{
"class": "TypeError",
"message": "Cannot read properties of undefined (reading 'id')",
"frames": [
{ "file": "https://app.example.com/assets/main-8f3a2c.js", "function": "loadUser", "line": 1, "column": 48213, "inApp": true },
{ "file": "https://app.example.com/assets/vendor-11ab2c.js", "function": "u", "line": 1, "column": 9021, "inApp": false }
]
}
],
"user": { "id": "u_123", "email": "jane@example.com", "name": "Jane" },
"breadcrumbs": [
{ "at": "2026-09-10T14:03:19.002Z", "type": "navigation", "message": "/orders -> /orders/551", "data": {} },
{ "at": "2026-09-10T14:03:21.900Z", "type": "click", "message": "button 'Reorder'", "data": {} },
{ "at": "2026-09-10T14:03:22.010Z", "type": "request", "message": "GET /api/orders/551 -> 500", "data": { "durationMs": 88 } }
],
"metadata": { "cart": { "items": 3, "total": 4999 } },
"request": { "url": "https://app.example.com/orders/551", "method": "GET", "userAgent": "Mozilla/5.0 ..." }
}
```
`exceptions[0]` is the outermost error; later entries are `cause` chains. Frames are top of stack first. `occurredAt` is optional and defaults to `received_at`; reject it if it's more than 5 minutes in the future or 30 days in the past and use `received_at` instead.
Ingest rules:
1. `401` on a missing or unknown API key. `400` with a list of zod errors on a malformed body. `413` over 256 KB. `429` with `Retry-After: 3600` once the project has hit `daily_event_limit` for the UTC day; increment `project_daily_stats.dropped`.
2. Truncate: message to 1,000 chars, breadcrumb messages to 200, max 25 breadcrumbs (keep the newest), max 50 frames per exception, max 5 exceptions, `metadata` to 16 KB (drop whole top-level keys from the end until it fits).
3. Scrub: at any depth in `user`, `metadata`, `request`, and `breadcrumbs[].data`, any key matching `/password|passwd|secret|token|authorization|cookie|session|card|ssn/i` gets the value `"[REDACTED]"`. Never store `request.headers.cookie` or `request.headers.authorization`.
4. `user_key` = `user.id`, falling back to `user.email`, else null.
5. Resolve source maps (below), then fingerprint (below), then in one transaction: upsert the issue, insert the event, upsert `issue_users`, bump `issue_daily_stats`, `project_daily_stats`, and the release counters. Return `202 { "eventId", "issueId" }`.
6. After the transaction commits, run the alert decision. Never send alerts inside the transaction.
Upsert the issue with a single statement so concurrent first events can't create two issues:
```sql
insert into issues (...) values (...)
on conflict (project_id, fingerprint) do update
set last_seen = excluded.last_seen, event_count = issues.event_count + 1, last_release = excluded.last_release
returning id, status, resolved_in_release, snooze_event_target, event_count, (xmax = 0) as inserted;
```
`inserted = true` means this event created the issue. Exactly one concurrent request will see it.
## Fingerprinting (`lib/fingerprint.ts`, pure, unit-tested)
Input: the resolved exceptions (or raw if no map matched), the project id, and an optional `groupingKey`.
1. If `groupingKey` is a non-empty string, return `sha256(projectId + "|" + groupingKey)`.
2. Take `exceptions[0]`. Pick the first 5 frames with `inApp = true`. If there are none, take the first 5 frames.
3. Normalize each frame's `file`:
- Strip the origin (`https://app.example.com`) and any query string or fragment.
- Replace content hashes: `/[-.][0-9a-f]{6,}(?=\.m?js$)/i` becomes `-*`, so `main-8f3a2c.js` and `main.8F3A2C.js` both become `main-*.js`.
- Strip `webpack:///`, `webpack://`, leading `./` and `../` segments, and everything before `/src/`, `/app/`, `/dist/`, or `/node_modules/` on absolute Node paths.
4. Normalize `function`: empty, `<anonymous>`, `eval`, `Object.<anonymous>` all become `?`.
5. Decide whether the frame is minified: the file is unresolved (no source map hit) and either matches the hash pattern, ends in `.min.js`, or has `column > 500`. Minified frames contribute `file:function` only. Everything else contributes `file:function:line` (never column).
6. If there are no frames at all, use `message_template`: the message with numbers replaced by `#`, UUIDs and hex strings of 8+ chars by `<id>`, and anything in single or double quotes by `<str>`. Fingerprint is `sha256(projectId + "|" + class + "|" + message_template)`.
7. Otherwise fingerprint is `sha256(projectId + "|" + class + "|" + frames.join("\n"))`.
Always compute `message_template` and store it on the issue; the issues list shows it as the title. `culprit` is the first in-app frame after normalization as `src/checkout.ts in applyDiscount`.
## Source maps
- `POST /api/source-maps` with `X-Api-Key`, multipart form: `release`, `minifiedFile` (basename), `map` (the file). Max 20 MB. Upsert on (project, release, minifiedFile).
- `scripts/upload-source-maps.ts <dir> --release <version>`: walks the directory, uploads every `.map` next to a `.js`, and prints what it sent. Read `--release` or fall back to `package.json` `version`.
- At ingest, for each frame whose `file` basename matches a stored map for the event's `release`: parse with `TraceMap` (cache parsed maps in an in-memory LRU of 50), call `originalPositionFor({ line, column })`, and produce a resolved frame with `file = source`, `line`, `column`, `function = name || original function`, `inApp = true` unless the source path contains `node_modules`. Attach `context` (5 lines before and after) from `sourcesContent` when present.
- Store the resolved copy in `events.exceptions_resolved`. Fingerprint from the resolved copy. Frames with no map keep their raw values and are treated as minified for fingerprinting if they look minified.
- Events that arrive before a map is uploaded are not reprocessed. Say so in the README.
## Sessions and stability
- `POST /api/sessions` with `X-Api-Key`: `{ "release", "environment", "sessionsStarted": 1, "at": "..." }`. The browser SDK sends one on init. The Node SDK sends one on init and, with the Express `requestHandler`, batches one per request into a single POST every 60 seconds. Upsert `releases` (creating it if new) and add to `sessions` on both the release and its `release_daily_stats` row.
- On an unhandled event (`handled = false`, severity `error`) with a `sessionId`: insert into `crashed_sessions` on conflict do nothing. If the insert happened, increment `crashed_sessions` on the release and the day row. Two crashes from one session count once.
- Stability for a release = `100 × (1 - crashed_sessions / sessions)`, shown with one decimal. If `sessions < 100`, show it in gray with the label "low sample". Below `stability_target` shows red.
- `releases.new_issues` = count of issues whose `first_release` equals this version. Update it when an issue is inserted.
## Alerts
Run after the ingest transaction commits, and only when the event's `environment` is in the project's `alert_environments`.
- New issue: `inserted = true`. Send to every enabled channel with `on_new_issue`. Write one `notifications_log` row per channel. Never send `new_issue` twice for the same issue, even if a channel is added later.
- Regression: the issue's `status` is `resolved` and either `resolved_in_release` is null, or the event's `release` is greater than or equal to it (compare with `semver` when both parse, otherwise plain string compare). Set `status = open`, `regressed_at = now`, clear `resolved_in_release`, and send to channels with `on_regression`. If the event's release is older than `resolved_in_release`, store the event, keep the issue resolved, send nothing.
- Snoozed: if `snoozed_until` has passed or `event_count >= snooze_event_target`, set `status = open` and treat as a regression.
- Ignored issues store events and never alert.
- Cap regression alerts at one per issue per 60 minutes (check `notifications_log`).
- Slack message: title linking to the issue page, then `class: message` (truncated to 200 chars), culprit, release, environment, events and users affected. Use Block Kit with a header block and a section with fields.
- Email: same content, one email per address in the channel, subject `[project] New issue: TypeError in src/checkout.ts` or `[project] Regression: ...`.
- Retry a failed send once after 5 seconds, then store the error on the log row. A failing channel must never fail the ingest request.
## HTTP API summary
| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| POST | `/api/ingest` | `X-Api-Key` | Receive one event. Returns `202 { eventId, issueId }`. |
| POST | `/api/sessions` | `X-Api-Key` | Count session starts for a release. Returns `202`. |
| POST | `/api/source-maps` | `X-Api-Key` | Upload one map for a release. Returns `201`. |
| GET | `/api/projects/[slug]/issues` | dashboard session | JSON behind the issues table, same filters as the URL. |
| POST | `/api/projects/[slug]/issues/[id]/status` | dashboard session | Resolve, ignore, snooze, reopen. |
| POST | `/api/projects/[slug]/alert-channels/[id]/test` | dashboard session | Send a test message to one channel. |
| GET | `/api/cron/retention` | `CRON_SECRET` header | Nightly rollup and delete. |
| GET | `/api/cron/digest` | `CRON_SECRET` header | Daily digest email. |
Every SDK-facing endpoint answers `OPTIONS` with CORS headers and finishes in under 200 ms at p95 with 1,000 issues in the project. Do the source map lookup and the fingerprint before opening the transaction so the transaction holds only the writes.
## Edge cases to handle
- Deduplicate on an optional `eventId` (uuid) in the payload: if the same project has stored that `eventId` in the last 24 hours, return `202` with the existing ids and store nothing. The browser SDK sets it so a `keepalive` retry can't double count.
- An empty or missing `release` stores `null` and the event doesn't touch any release counters. An empty `environment` becomes `production`. Lowercase and trim both.
- Clock skew: if `occurredAt` is ahead of `received_at` by more than 5 minutes, or behind by more than 30 days, use `received_at` and set `metadata._clockSkew = true`.
- `exceptions[0].frames` empty but a later cause has frames: fingerprint from `exceptions[0]` anyway (class plus message template). Don't silently switch to the cause.
- A frame with `line` but no `column` is not minified by the column rule.
- Windows paths in Node stacks (`C:\app\src\x.ts`) normalize to forward slashes before the path rules run.
- Non-ASCII messages are truncated by code point, not by byte, so you never split a character.
- Rotating an API key keeps the old key valid for 24 hours so deployed clients don't drop events during the switch. Store `api_key_previous` and `api_key_rotated_at` on the project.
- An issue that's `ignored` and then manually reopened starts alerting again on the next regression, and its `new_issue` alert is never re-sent.
- Deleting a project deletes everything under it in one transaction and is behind a confirmation that requires typing the slug.
## Screens
Times display in the viewer's browser timezone with the UTC value in a `title` tooltip. Relative times ("4 min ago") in lists. Daily buckets in charts are UTC days and say so in the axis label.
1. **Sign in** (magic link). First user creates the organization.
2. **Projects (`/`)**: cards with name, platform, events in the last 24 hours, open issues, and the stability of the most recent production release. "New project" button asks for name and platform and shows the API key and an install snippet.
3. **Issues (`/p/[slug]/issues`)**: table, 50 per page. Filters in the URL: `status` (default `open`), `environment`, `release`, `q` (matches error class, message template, culprit). Sort by `last_seen` (default), `event_count`, `user_count`, `first_seen`. Columns: class and message template, culprit, events, users, first seen, last seen, a 14-day sparkline from `issue_daily_stats`. Checkbox bulk actions: resolve, ignore, snooze. Keyboard: `j`/`k` to move, `enter` to open, `r` to resolve.
4. **Issue (`/p/[slug]/issues/[id]`)**: header with class, message sample, status, and buttons: Resolve, Resolve in release (input prefilled with `last_release`), Ignore, Snooze (1 day, 7 days, or "until 100 more events"), Reopen. Stats: events, users, first seen (with `first_release`), last seen (with `last_release`). A 30-day bar chart. Affected users: top 10 by event count with their last seen. Event navigator: latest event by default with prev/next and "oldest". Event panel: the stack with a raw/resolved toggle, each frame expandable to show its `context` lines with the error line highlighted, in-app frames bold, library frames collapsed by default. Cause chain below. Breadcrumbs as a timeline with a type icon, message, and seconds before the error. Tabs: User, Metadata, Request, SDK. Optional "Explain this error" button (below).
5. **Releases (`/p/[slug]/releases`)**: table: version, environment, first seen, sessions, crashed sessions, stability (colored against the target), new issues, events, source maps uploaded (count). Row click opens `/p/[slug]/releases/[version]` with the same stats, a daily stability chart, and the list of issues introduced in that release.
6. **Project settings (`/p/[slug]/settings`)**: name, API key with copy and rotate, install snippets for both SDKs, stability target, retention days, daily event limit, alert environments, alert channels (add Slack webhook with a "Send test" button; add email list), and the last 50 rows of `notifications_log`.
7. **Daily digest email** (`/api/cron/digest`, 08:00 UTC): per project, to every user: new issues in the last 24 hours, top 5 issues by events, and the stability of the latest release. Skip projects with no events.
Navigation: left sidebar with the project switcher, then Issues, Releases, Settings.
Dashboard behaviors:
- Every list is a server component. Initial render does no client-side fetching. Paginate with `?page=` in the URL.
- The 14-day sparkline and the 30-day chart read only `issue_daily_stats`, never `events`.
- Empty states say what to do next: a project with no events shows the install snippet; an issue list with filters and no rows shows "No issues match" with a clear-filters link.
- Status changes write who did it and when to an `issue_activity` table (`issue_id`, `user_id`, `action`, `detail`, `created_at`) shown at the bottom of the issue page.
- The issue page URL accepts `?event=<id>` so a Slack link can open a specific event.
## Browser SDK (`packages/sdk-browser/src/index.ts`, under 150 lines, no dependencies)
Exports `init`, `notify`, `setUser`, `leaveBreadcrumb`, `addMetadata`. Also build a single IIFE file usable as `<script src="/errorwatch.js">` that exposes `window.errorwatch`.
- `init({ apiKey, endpoint, release, environment, user })`. On init: generate a `sessionId` (`crypto.randomUUID()`), POST a session start, and install handlers.
- `window.onerror` and `window.onunhandledrejection` produce `handled: false` events. Non-Error rejection reasons become `class: "UnhandledRejection"` with the stringified reason as the message and no frames.
- Parse `error.stack` for both V8 (`at fn (url:line:col)`, `at url:line:col`) and Firefox/Safari (`fn@url:line:col`) formats. `inApp` = the frame's origin equals `location.origin` and the path doesn't contain `node_modules`.
- Breadcrumbs, ring buffer of 25: `click` (tag name plus up to 40 chars of text or `aria-label`), `navigation` (wrap `history.pushState` and listen to `popstate`, message `from -> to`), `request` (wrap `fetch`: method, URL path, status, duration), `console` (`console.error` and `console.warn`, first 200 chars), `custom` via `leaveBreadcrumb(message, data)`.
- `notify(error, { severity, metadata, groupingKey })` sends a `handled: true` event.
- Send with `fetch(endpoint, { method: "POST", keepalive: true, headers: { "Content-Type": "text/plain", "X-Api-Key": apiKey } })` so it survives page unload. Drop events beyond 10 per minute. Never throw from the SDK; wrap everything.
- Include `request.url` and `request.userAgent`.
## Node SDK (`packages/sdk-node/src/index.ts`, under 150 lines, no dependencies)
Exports `init`, `notify`, `leaveBreadcrumb`, `addMetadata`, `requestHandler`, `errorHandler`.
- `init({ apiKey, endpoint, release, environment, autoExit })`. `release` defaults to `process.env.RELEASE` then `package.json` `version`; `environment` defaults to `NODE_ENV`. Sends a session start on init.
- `process.on("uncaughtException")` and `process.on("unhandledRejection")` send `handled: false`, then await the send with a 2-second timeout, then `process.exit(1)` when `autoExit !== false`.
- Parse V8 stacks. `inApp` = path doesn't contain `node_modules` and isn't `node:internal`. Strip the working directory from paths.
- `requestHandler()` (Express): assigns a `sessionId` per request, counts it for the 60-second session batch, and stores `req` context in `AsyncLocalStorage` so `notify` inside a route picks up the request.
- `errorHandler()` (Express): captures the error with `request` (`url`, `method`, `headers` minus cookie and authorization, `ip`), `user` from `req.user?.id` if present, then calls `next(err)`.
- Use the global `fetch`. Never throw from the SDK.
## Retention (`GET /api/cron/retention`, nightly at 03:00 UTC, protected by `CRON_SECRET` header)
Per project:
1. Mark the oldest and newest event of each issue `pinned = true` (a single update with window functions).
2. Delete events where `received_at < now() - retention_days` and `pinned = false`, in batches of 5,000 until none remain.
3. Delete `crashed_sessions` rows older than `retention_days`.
4. Delete `issue_daily_stats`, `release_daily_stats`, and `project_daily_stats` rows older than 400 days.
5. Delete `source_maps` for releases with no events in 90 days.
6. Log rows deleted per table. Return them as JSON.
Issue `event_count`, `user_count`, `first_seen`, `last_seen`, and release counters never change during retention. Charts read only from the daily stats tables so they stay correct after events are gone.
## Optional: "Explain this error" (OpenRouter)
Button on the issue page. Send the error class and message, the resolved top 10 frames with their `context` lines, the last 10 breadcrumbs, and the metadata to any model through OpenRouter with the prompt "Explain the likely cause in three sentences and suggest one fix." Cache the answer on the issue in an `explanation` column. Skip entirely if `OPENROUTER_API_KEY` is unset.
## Non-goals
Do not build: mobile or native SDKs, performance monitoring or spans, session replay, distributed tracing, SSO, two-way Jira sync, on-prem packaging, spike detection, or billing. Leave a TODO where one would go.
## Acceptance criteria
1. Two `POST /api/ingest` calls with the same `TypeError`, the same function names, and the same hashed file with different hashes (`main-8f3a2c.js` and `main-11ab2c.js`) and different columns produce one issue with `event_count = 2`.
2. The same frames with class `RangeError` instead of `TypeError` produce a second issue. Different messages with the same class and frames (`"order 12"` vs `"order 99"`) stay in one issue with `message_template = "order #"`.
3. After uploading a source map for release `1.0.0`, an event from that release with a minified frame stores an `exceptions_resolved` frame equal to `src/checkout.ts:42` in `applyDiscount`, and `culprit` equals `src/checkout.ts in applyDiscount`.
4. Five events from two distinct `user.id` values give `user_count = 2` and two `issue_users` rows. Events with no user leave `user_count` unchanged.
5. A metadata key `password` is stored as `[REDACTED]`; a 300 KB body returns 413; a wrong API key returns 401; a body with no `exceptions` returns 400 listing the missing field.
6. The first event for a fingerprint sends exactly one Slack post and one email per configured address. The second event sends nothing. An event with `environment = staging` sends nothing when `alert_environments = {production}`.
7. Resolving an issue and posting again sets `status = open`, stamps `regressed_at`, and sends a regression alert. Resolving in release `1.2.0`, then posting from `1.1.9`, keeps it resolved with no alert; posting from `1.2.1` regresses it.
8. 200 session starts and 3 unhandled events with distinct session ids on release `1.3.0` show stability `98.5`. Two unhandled events with the same session id count as one crashed session.
9. Twenty concurrent first events for one fingerprint produce one issue, `event_count = 20`, and one `new_issue` log row per channel.
10. With `retention_days = 7` and events backdated 10 days, the retention cron deletes them except the pinned first and last event of each issue; `event_count` and the 30-day chart are unchanged.
11. The example page with the browser SDK: clicking "Crash" sends an event whose breadcrumbs include the click and the prior navigation, and a rejected promise sends an `UnhandledRejection` event.
12. The example Express app: a route that throws produces an event with `request.url` and `request.method`, no `cookie` header, and `user.id` from `req.user`. An `uncaughtException` is received by the server before the process exits.
13. The issues list filters by `status`, `environment`, and `q`, sorts by each column, and pages at 50. Times render in the browser's timezone with UTC in the tooltip.
14. Snoozing "until 100 more events" reopens the issue on the 100th event and sends a regression alert.
## Deliverables
- The service with migrations and a seed script: two projects (one browser, one node), 12 issues across 3 releases with realistic stacks and breadcrumbs, 400 events, one uploaded source map, and one Slack channel pointing at a placeholder webhook.
- `lib/fingerprint.ts` and `lib/sourcemaps.ts` with table-driven tests.
- Both SDKs built to `dist/` with a size check that fails the build over 150 source lines.
- `examples/browser-page` (a static page with Crash, Reject, and Handled buttons, built minified with two different hashes) and `examples/express-app`.
- README covering environment variables, the Slack webhook and Resend setup, cron configuration, how to upload source maps in CI, and the note about events that arrive before their map.
Build `lib/fingerprint.ts` and its tests first, then the ingest endpoint, then the browser SDK against the example page, then the screens, then alerts and retention. Run the example page against the running server after each step.06
Where to build it
Any of these will take the prompt. Use the one you already pay for.
- Claude CodeAgentic coding in your terminal
Best fit. The fingerprint function and the source map path need tests written and run as you go, and both SDKs are small enough to build in the same repo.
- ReplitBuild and host in the browser
Works. Replit gives you Postgres, hosting, and a public URL the SDKs can post to. Set the cron up as a Replit scheduled deployment.
- ChatGPT / CodexPlan, then build with Codex
Ask ChatGPT to trim the payload and breadcrumb types to what your app actually has, then hand the spec to Codex.
- LovablePrompt-to-app with Supabase built in
Fine for the issues list and issue page. Put the ingest endpoint and fingerprinting in a Supabase edge function, because the UI builder won't do that part well.
- OpenRouterBring your own model
Optional. Send the resolved stack, breadcrumbs, and the 5 lines of source around the top frame to any model and show its explanation on the issue page.
07
Test it
Walk the checklist by hand once, then let the agent write the automated tests.
Manual checklist
- Install the browser SDK on a test page, throw an error from a button click, and confirm an issue appears within 5 seconds.
- Click the same button 10 times and confirm there is still one issue and its event_count is 10.
- Change the message text but keep the stack (throw new Error('order ' + Date.now())) and confirm it still groups into one issue.
- Throw a different error class from the same line and confirm it creates a second issue.
- Build the test page minified with two different content hashes, upload both source maps, and confirm both builds land in the same issue with readable file names and line numbers.
- Set a user id in the SDK, throw from two browsers with different ids, and confirm users affected shows 2.
- Resolve an issue, throw again, and confirm it reopens and a regression message reaches Slack.
- Resolve an issue in release 1.2.0, send an event tagged 1.1.9, and confirm it stays resolved with no alert. Send 1.2.1 and confirm it regresses.
- Send a payload with a metadata key named password and confirm the stored value is [REDACTED].
- Load the test page 50 times and crash 2 of them, then confirm the release shows 96% stability.
- Kill a Node process with an uncaught exception and confirm the event arrives before the process exits.
- Set retention to 7 days, backdate some events to 10 days ago, run the cron, and confirm they're gone but the issue counts and 30-day chart are unchanged.
- Open the issue page in a browser set to a different timezone and confirm first seen and last seen shift while the daily chart buckets stay the same.
Generate automated tests
Paste this into the same tool that built the app. It writes tests against the acceptance criteria from the build prompt.
Write automated tests for the error tracking service in this repo. Treat the acceptance criteria below as the spec. Use Vitest for the fingerprint and source map functions and for the API routes against a real test Postgres, and Playwright for the example browser page and the dashboard. Mock Slack (the webhook `fetch`) and Resend at the module boundary and assert on call counts and payloads. Record one real Slack Block Kit payload and one Resend request as fixtures.
## Acceptance criteria to cover
1. Two ingests with the same `TypeError`, same function names, and hashed files `main-8f3a2c.js` and `main-11ab2c.js` with different columns produce one issue with `event_count = 2`.
2. Same frames with class `RangeError` produce a second issue. Messages `"order 12"` and `"order 99"` with the same class and frames stay in one issue with `message_template = "order #"`. An event with no frames groups by class plus template.
3. After uploading a source map for release `1.0.0`, a minified frame resolves to `src/checkout.ts:42` in `applyDiscount`, `culprit` equals `src/checkout.ts in applyDiscount`, and the fingerprint matches an event from a second build of the same source with a different hash and its own map.
4. Five events from two distinct `user.id` values give `user_count = 2` and two `issue_users` rows. Events with no user leave `user_count` unchanged.
5. A metadata key `password` is stored as `[REDACTED]` at any depth; a 300 KB body returns 413; a wrong API key returns 401; a body with no `exceptions` returns 400 naming the field; the 100,001st event of a UTC day returns 429 and increments `dropped`.
6. The first event for a fingerprint sends one Slack post and one email per address. The second event sends nothing. A `staging` event sends nothing when `alert_environments = {production}`. A channel that throws does not change the ingest response.
7. Resolve, then ingest again: `status = open`, `regressed_at` set, one regression alert. Resolve in `1.2.0`, ingest from `1.1.9`: still resolved, no alert. Ingest from `1.2.1`: regressed with one alert. A second regression inside 60 minutes sends nothing.
8. 200 session starts and 3 unhandled events with distinct `sessionId` values on release `1.3.0` give stability `98.5`. Two unhandled events with one `sessionId` count once. A handled event never counts as a crash.
9. Twenty concurrent first events for one fingerprint (`Promise.all`) produce one issue, `event_count = 20`, and exactly one `new_issue` log row per channel.
10. With `retention_days = 7` and events backdated 10 days, the retention cron deletes them except the pinned first and last event per issue. `event_count`, `user_count`, and the 30-day chart data are unchanged. Running the cron twice deletes nothing the second time.
11. Playwright on the example page: clicking "Crash" sends a payload whose breadcrumbs include the click and the earlier navigation, in order. Clicking "Reject" sends an `UnhandledRejection` event. Clicking "Handled" sends `handled: true`.
12. The example Express app: a throwing route produces an event with `request.url`, `request.method`, no `cookie` header, and `user.id` from `req.user`. Spawn the app as a child process, trigger an `uncaughtException`, and assert the server received the event before the process exited with code 1.
13. Playwright on the dashboard: the issues list filters by `status`, `environment`, and `q`, sorts by each column, and pages at 50. With the browser timezone set to `Asia/Kolkata`, "last seen" shows the local time and the tooltip shows UTC.
14. Snooze "until 100 more events": the 99th event keeps `status = snoozed`; the 100th sets `open` and sends one regression alert.
15. An `eventId` posted twice within 24 hours stores one event and returns the same ids both times.
16. `occurredAt` 10 minutes in the future is replaced with `received_at` and `metadata._clockSkew = true`.
## What to mock and what to keep real
- Keep Postgres real. Grouping, the `xmax = 0` insert detection, and the retention batches only mean something against the real database.
- Mock the Slack webhook by stubbing global `fetch` for URLs that start with `https://hooks.slack.com/`. Assert on the Block Kit body, not just the call count.
- Mock the Resend client module. Assert on `to`, `subject`, and that the body contains the issue link.
- Mock OpenRouter entirely; skip the "explain" tests when `OPENROUTER_API_KEY` is unset.
- Do not mock `@jridgewell/trace-mapping`. Use a real map from the fixture build.
- Use a throwaway `X-Api-Key` per test project so parallel test files don't share issues.
## Layout
- `tests/unit/fingerprint.test.ts`: table-driven cases `{ name, exceptions, groupingKey, expectSameAs | expectDifferentFrom }` for 1, 2, and the path normalization rules (origin, hash, `webpack:///`, Windows paths, `node_modules`).
- `tests/unit/sourcemaps.test.ts`: criterion 3 with a real map generated from a tiny fixture project in `fixtures/sourcemap-build/`.
- `tests/integration/`: API routes against a test Postgres (`DATABASE_URL_TEST` or Testcontainers) for 4 through 10 and 14 through 16. Freeze time with `vi.useFakeTimers()` or an injected clock for 7, 10, and 16.
- `tests/e2e/`: Playwright for 11, 12, and 13. Point the example page and Express app at the test server with a seeded project.
- `tests/sdk-size.test.ts`: fails if either SDK source file exceeds 150 lines.
## Rules
- Name every test after its criterion: `test("AC9: twenty concurrent first events produce one issue and one alert")`.
- Each integration test creates its own project and truncates nothing shared. Tests must pass when run in parallel and in random order.
- Never sleep for real time. Use the fake clock for snooze expiry, the regression cap, and the retention cutoff.
- Build fixtures in `fixtures/`: a V8 stack, a Firefox stack, a Safari stack, a Node stack with `node_modules` frames, a Windows path stack, and one payload for each SDK. Use them in the unit tests so both SDK parsers are covered.
- Tests must run in CI without a browser UI. Add `pnpm test` and a GitHub Actions workflow with a Postgres service container.
- Run the suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass/fail and what you changed.BugSnag is a trademark of SmartBear Software. This page is independent research and is not affiliated with or endorsed by SmartBear Software. Last updated 2026-09-10.