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 wires captureError from @repo/logs to the local queue and also catches uncaughtException / unhandledRejection. Only captureError reports — captureMessage (info / warn) never does.
  • Browser — BrowserBugReporter (mounted once in apps/web/app/layout.tsx, so it covers the marketing site, the app and the public forms) hooks window.onerror and unhandledrejection. global-error.tsx and the public-form error.tsx boundary call reportBrowserError(error) directly (errors carrying a digest came from the server and are reported there). Reports are batched (10 per request, every 5 s, and on page hide via sendBeacon), one per distinct error per page session, 20 per page load.
  • Collector vs sender — the instance whose BUG_TRACKER_COLLECTOR_URL is empty is the collector (the tunnel today, app.certivant.com later). 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:

AreaMeaning
APIHono API (packages/api)
ENGKYC engine (packages/kyc-engine)
WRKBullMQ worker (apps/worker)
SIGSignaling server (apps/conference-server)
WEBNext.js app and browser pages
DBDatabase / Prisma
STOFile storage
AIAI readers and biometric providers
BILBilling and payments
AUTHSign-in, sessions, SSO, 2FA
INTOutbound 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>, and env:<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

ActionEffect
MuteKeeps counting, stops syncing (noise you accept).
UnmuteBack to open; the issue is refreshed.
Mark resolvedRecords the last release it was seen in; the issue is updated. A sighting on any other release is a regression.
Re-sync issueRuns 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

VariableSet onMeaning
BUG_TRACKER_ENABLEDallDefault true; false turns capture into a no-op.
NEXT_PUBLIC_BUG_TRACKER_ENABLEDweb buildfalse stops the browser hook (build-time).
BUG_TRACKER_ENVallproduction, staging or dev (default from NODE_ENV).
BUG_TRACKER_COLLECTOR_URLsendersCollector base URL, e.g. https://certivant.tunnel.paperssign.com. Empty = this instance is the collector.
BUG_TRACKER_INGEST_KEYsendersThis environment's key, sent as Authorization: Bearer <key>.
BUG_TRACKER_INGEST_KEYScollectorAllowed 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_IDcollectorGitHub App ids.
GITHUB_APP_PRIVATE_KEY_PATH / GITHUB_APP_PRIVATE_KEYcollectorApp private key (path, default secrets/github-bug-reporter.pem, or the PEM text).
GITHUB_BUG_REPOcollectorDefault certivant/certivant-app.
GIT_COMMIT_SHA / RELEASEallRelease tag shown on issues; unknown when unset.

Typical setup:

  • Collector (tunnel today): BUG_TRACKER_ENV=staging, no BUG_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 (or dev), 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; answers 202 { accepted }.
  • POST /api/bug-reports/client — browser reports. Origin (or Referer) must be the app's own origin (NEXT_PUBLIC_SITE_URL or one of CORS_ALLOWED_ORIGINS), 30 requests per minute per IP, at most 10 reports and 32 KB. The server forces app: "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.context stays in the process logs and is never part of a report — put identifiers there, not in the message.

GitHub App setup

  1. 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.
  2. Repository permissions: Issues — Read and write, Metadata — Read. Nothing else.
  3. Create the app, note the App ID, and generate a private key (a .pem file).
  4. 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.
  5. On the collector, save the key as secrets/github-bug-reporter.pem at the repository root (git-ignored) or put the PEM text in GITHUB_APP_PRIVATE_KEY, then set GITHUB_APP_ID and GITHUB_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.