Bug Tracker
Developer reference for Certivant's built-in bug tracker — how exceptions from production, staging, development machines and browsers are captured, sanitised, deduplicated by fingerprint and mirrored to one GitHub issue per distinct bug, plus error codes, CertivantError, environment variables, privacy rules and the GitHub App setup.
Every unexpected exception — from the API, the worker, the signaling server,
scripts and the browser, in every environment — is sent to one central
collector. The collector groups identical errors into one signature
(a BugSignature row, keyed by a fingerprint) and mirrors each signature to
one GitHub issue in certivant/certivant-app. The same bug never creates
a second issue: later sightings update the existing one.
Super admins see and manage signatures in Admin → Bug tracker
(/app/admin/bug-tracker).
How a report travels
process (api / web / worker / signaling / script) browser page
captureError(err, ctx) window.onerror
└─ reportBug → buildBugReport (sanitised) unhandledrejection
└─ enqueueBugReport ──► BullMQ "bug-report" error boundaries
(Redis down → spool file, drained daily) └─ POST /api/bug-reports/client
(same origin, rate limited)
worker: processBugReportBatch ◄────────────────┘
├─ collector instance → ingestBugReports
└─ sender instance → POST {collector}/api/bug-reports/ingest
(Bearer ingest key)
collector: ingestBugReports
sanitise again → fingerprint → upsert BugSignature (+ last 50 BugOccurrence)
└─ BullMQ "bug-github-sync" → syncSignatureToGitHub (create / update issue)
- Server code —
registerServerBugReporter({ app })(from@repo/bug-tracker) is called once at process start. It wirescaptureErrorfrom@repo/logsto the local queue and also catchesuncaughtException/unhandledRejection. OnlycaptureErrorreports —captureMessage(info / warn) never does. - Browser —
BrowserBugReporter(mounted once inapps/web/app/layout.tsx, so it covers the marketing site, the app and the public forms) hookswindow.onerrorandunhandledrejection.global-error.tsxand the public-formerror.tsxboundary callreportBrowserError(error)directly (errors carrying adigestcame from the server and are reported there). Reports are batched (10 per request, every 5 s, and on page hide viasendBeacon), one per distinct error per page session, 20 per page load. - Collector vs sender — the instance whose
BUG_TRACKER_COLLECTOR_URLis empty is the collector (the tunnel today,app.certivant.comlater). Every other environment forwards its queue to the collector over HTTPS.
Error codes
Codes look like CVT-<AREA>-<NNN> and are listed in
packages/logs/lib/error-codes.ts (browser-safe). Areas:
| Area | Meaning |
|---|---|
API | Hono API (packages/api) |
ENG | KYC engine (packages/kyc-engine) |
WRK | BullMQ worker (apps/worker) |
SIG | Signaling server (apps/conference-server) |
WEB | Next.js app and browser pages |
DB | Database / Prisma |
STO | File storage |
AI | AI readers and biometric providers |
BIL | Billing and payments |
AUTH | Sign-in, sessions, SSO, 2FA |
INT | Outbound integrations (mail, SMS, webhooks) |
An error without a catalogue code gets CVT-<AREA>-000 (unclassified); the
area is guessed from the first app frame of the stack, then from the app.
Each catalogue entry carries a severity (critical, high, normal, low)
and the title used in the GitHub issue.
Throwing a classified error
Where the failure class is known, throw a CertivantError with a catalogue
code:
import { CertivantError } from "@repo/logs";
try {
await storage.upload(key, buffer);
} catch (err) {
throw new CertivantError("CVT-STO-001", "Document upload failed", {
cause: err,
context: { kycRequestId }, // stays in the process logs, never in the report
});
}
To add a code, append an entry to ERROR_CODES in error-codes.ts — never
renumber or reuse an existing code, since signatures and GitHub issues are
keyed by it. captureError(err, { code: "CVT-…" }) classifies an error you
catch and log instead of rethrowing.
Deduplication
The fingerprint is sha256(code | errorClass | normalised message | top 3 app frames without line / column numbers). The normalised message has ids,
numbers and quoted values replaced by placeholders, so "request clx… not found"
and "request cly… not found" are the same bug. The fingerprint is unique in the
database and a sync job creates the issue only while holding
githubSyncLockedAt and only when githubIssueNumber is still empty — two
workers can never open two issues for one signature.
GitHub issues
- Title:
[CVT-STO-002] <catalogue title or message> — <file:line>. - Labels (created on first use):
bug:auto,area:<area>,app:<app>,sev:<severity>, andenv:<env>for every environment seen. - Body: summary table (first / last seen, occurrences, per-environment counts,
apps, releases, number of affected tenants, route templates), the sanitised
stack and the last five occurrences. A hidden
<!-- cvt-fp:<fingerprint> -->marker links the issue back to its signature. - Updates: the body is refreshed at most every 15 minutes. Comments are added only at 10 / 100 / 1000 occurrences, on the first sighting in a new environment or release, and on a regression — a resolved signature seen again on a release other than the one it was resolved in reopens the issue.
- Muted signatures are counted but never synced. More than 20 new issues in an hour go into one open "Error storm" issue instead.
Admin actions
| Action | Effect |
|---|---|
| Mute | Keeps counting, stops syncing (noise you accept). |
| Unmute | Back to open; the issue is refreshed. |
| Mark resolved | Records the last release it was seen in; the issue is updated. A sighting on any other release is a regression. |
| Re-sync issue | Runs the GitHub sync now (refused for muted signatures). |
Routes (super admin; read needs admin.settings.view, actions need
admin.settings.edit):
GET /api/admin/bug-tracker/signatures?status=&area=&q=&page=&limit=POST /api/admin/bug-tracker/signatures/:id/mute | unmute | resolve | sync
Environment variables
| Variable | Set on | Meaning |
|---|---|---|
BUG_TRACKER_ENABLED | all | Default true; false turns capture into a no-op. |
NEXT_PUBLIC_BUG_TRACKER_ENABLED | web build | false stops the browser hook (build-time). |
BUG_TRACKER_ENV | all | production, staging or dev (default from NODE_ENV). |
BUG_TRACKER_COLLECTOR_URL | senders | Collector base URL, e.g. https://certivant.tunnel.paperssign.com. Empty = this instance is the collector. |
BUG_TRACKER_INGEST_KEY | senders | This environment's key, sent as Authorization: Bearer <key>. |
BUG_TRACKER_INGEST_KEYS | collector | Allowed keys as env=key,env=key. The env in the pair is what gets recorded — the sender's own claim is ignored. |
GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID | collector | GitHub App ids. |
GITHUB_APP_PRIVATE_KEY_PATH / GITHUB_APP_PRIVATE_KEY | collector | App private key (path, default secrets/github-bug-reporter.pem, or the PEM text). |
GITHUB_BUG_REPO | collector | Default certivant/certivant-app. |
GIT_COMMIT_SHA / RELEASE | all | Release tag shown on issues; unknown when unset. |
Typical setup:
- Collector (tunnel today):
BUG_TRACKER_ENV=staging, noBUG_TRACKER_COLLECTOR_URL,BUG_TRACKER_INGEST_KEYS=production=…,dev=…, plus the GitHub App variables. Without the GitHub variables signatures are still recorded; the admin page shows "GitHub sync is not configured". - Production / developer machines:
BUG_TRACKER_ENV=production(ordev),BUG_TRACKER_COLLECTOR_URL=<collector>,BUG_TRACKER_INGEST_KEY=<the key paired with that env on the collector>.
Generate keys with openssl rand -hex 32, one per environment, so a leaked
developer key can be revoked without touching production.
Intake routes
POST /api/bug-reports/ingest— collector only (404 on a sender). Bearer ingest key required (401 otherwise). Body{ reports: BugReport[] }, at most 50 reports and 64 KB; answers202 { accepted }.POST /api/bug-reports/client— browser reports.Origin(orReferer) must be the app's own origin (NEXT_PUBLIC_SITE_URLor one ofCORS_ALLOWED_ORIGINS), 30 requests per minute per IP, at most 10 reports and 32 KB. The server forcesapp: "browser", sanitises message, stack and route again, drops any organization id and stamps its own release.
Both routes read the body as text, so navigator.sendBeacon with a
text/plain body works without a CORS preflight.
Privacy rules
These are non-negotiable — the GitHub repository is outside the data boundary.
- Sent to GitHub: code, area, severity, error class, sanitised message, sanitised app-frame stack, route templates, app, environment, release, counts and the number of affected tenants.
- Never sent: request bodies, query strings, names, emails, phone numbers, document numbers, tokens / API keys, raw organization ids, IP addresses.
sanitizeText(in@repo/logs) replaces emails, phone numbers, long digit runs, JWT / bearer / API keys, UUID / cuid ids and IPs with placeholders and trims to 2000 characters. It runs in the sender and again on the collector.- Routes are templates: the browser replaces ids, tokens, form slugs and the
organization slug (
/app/:org/kyc/:id) before sending; query strings and hashes are dropped. - Organization ids are hashed on the collector before storage; only the count of distinct hashes is shown or published.
CertivantError.contextstays in the process logs and is never part of a report — put identifiers there, not in the message.
GitHub App setup
- In the GitHub organization, Settings → Developer settings → GitHub Apps → New GitHub App. Name it e.g. "Certivant Bug Reporter"; no callback URL, untick Webhook → Active.
- Repository permissions: Issues — Read and write, Metadata — Read. Nothing else.
- Create the app, note the App ID, and generate a private key (a
.pemfile). - Install App on the organization, choosing Only select repositories → certivant-app. The installation id is the number at the end of the installation's settings URL.
- On the collector, save the key as
secrets/github-bug-reporter.pemat the repository root (git-ignored) or put the PEM text inGITHUB_APP_PRIVATE_KEY, then setGITHUB_APP_IDandGITHUB_APP_INSTALLATION_ID.
The collector signs a short-lived App JWT, exchanges it for an installation
token (cached until five minutes before it expires) and calls the REST API
with plain fetch, retrying server errors and secondary rate limits with
backoff.
Maintenance
runBugTrackerMaintenance() runs daily on the collector: it closes
signatures seen only in dev and quiet for 14 days, marks signatures whose
issue was closed on GitHub as resolved, and drains the local spool file
(os.tmpdir()/certivant-bug-spool.ndjson, capped at 5 MB) written while Redis
was unreachable.