SDK Usage & Event Enrichment
BugWatch - SDK Usage & Event Enrichmentlink
The other BugWatch folders document the wire API - the exact JSON each ingest endpoint accepts. This folder documents how your application produces that JSON through the BugWatch SDKs: identifying the user, adding tags/context/release, correlating traces, sampling, redaction, and grouping.
Examples use @newinstance/bugwatch (JS/TS - the reference SDK) and newinstance/bugwatch (PHP). The same concepts apply to the mobile SDKs (iOS, Android, React Native, Flutter), which expose a global setUser.
The scope model - global, per-request, per-capturelink
Enrichment lives on a scope, and there are three places to set it:
| Scope | Lifetime | Use it for |
|---|---|---|
| Global | Process-wide, until changed | Single-identity processes - workers, CLIs, a signed-in mobile/desktop user |
| Per-request | One HTTP request; auto-cleared at the end | Concurrent servers - bind the authenticated user to this request only |
| Per-capture | One event | A one-off override on a single captureException / captureLog |
What reaches the wire: the SDK merges these before sending - the per-capture hint wins over the per-request scope, which wins over the global scope. Each event carries exactly one merged
userobject (id,username,ip). There is no separate "per-request user" field on the ingest endpoint; per-request isolation is purely an SDK-side concern.
User identity (global)link
Attach a user to all subsequent events. Pass null to clear (e.g. on logout):
// @newinstance/bugwatch
BugWatch.setUser({ id: "u_123", email: "alice@acme.com", username: "alice" });
// ip is auto-populated by the ingest server if omitted
BugWatch.setUser(null); // on logout// newinstance/bugwatch
BugWatch::setUser(['id' => 'u_123', 'email' => 'alice@acme.com', 'username' => 'alice', 'ip' => $request->ip()]);
BugWatch::setUser(null); // on logoutOnly id, email, username, ip are kept - any other key is dropped before the event leaves your process.
Concurrency warning:
setUsersets a single process-wide user. On a server handling concurrent requests it can attribute one request's events to another user. Use the per-request scope below instead.
Per-request user (concurrency-safe)link
A Node server handles many users on one event loop; a long-running PHP worker services many requests in sequence or as coroutines. A shared "current user" set per request races - whichever request wrote last wins.
The SDK solves this with a per-request scope that is isolated from every other in-flight request and auto-clears when the request ends. Set the user from your auth middleware; any captureException / captureLog during that request is tagged with it.
JS / Node - setRequestUser (plus setRequestTag, setRequestContext) from @newinstance/bugwatch/node, backed by AsyncLocalStorage. Works with the Express, Koa, Fastify, Hapi, Nest and Next.js adapters (Hapi opens its request scope in an onRequest extension). enterContext(ctx) is the non-callback variant for runtimes where wrapping in runWithContext is awkward. Every adapter also accepts a getUser option resolved lazily at capture time, which usually replaces this middleware pattern; an explicit setRequestUser still wins. setRequestUser returns false when called outside a request scope, and it never writes the global scope. Inside one request the last write wins; async work started in a handler keeps that request identity until its async tree resolves. Non-framework code (queues, workers) gets the same isolation from installAsyncScope + runWithContext:
import { createClient } from "@newinstance/bugwatch";
import { bugWatchExpressRequestHandler, bugWatchExpressErrorHandler } from "@newinstance/bugwatch/express";
import { setRequestUser, setRequestTag } from "@newinstance/bugwatch/node";
const client = createClient({ projectKey: process.env.BUGWATCH_KEY });
app.use(bugWatchExpressRequestHandler(client)); // 1. open a per-request scope (first)
app.use((req, _res, next) => { // 2. auth middleware - writes THIS request only
if (req.user) setRequestUser({ id: req.user.id, email: req.user.email });
next();
});
app.use(bugWatchExpressErrorHandler(client)); // 3. error handler (last) - user + tags attached
// Concurrent request B has its own scope - its identity is fully isolated.PHP - register BugWatchContextMiddleware (Laravel). It reads $request->user(), scopes it to the current request, and calls flush() + resetScope() on termination:
// app/Http/Kernel.php (Laravel 10/11) or bootstrap/app.php (Laravel 11 middleware() style)
\NewInstance\BugWatch\Laravel\BugWatchContextMiddleware::class,Isolation is automatic per runtime: PHP-FPM (one process per request) needs nothing; Octane/RoadRunner reset between requests via the middleware; Swoole/OpenSwoole store scope in per-coroutine context; queue workers and Artisan commands reset on job/command boundaries.
The request scope wins over the global scope on any overlapping field and clears automatically at request end, so the next request starts clean. On the Node runtime,
withBugWatchRouteHandleropens a per-request scope, sosetRequestUserworks inside Next.js route handlers too; only true edge runtimes (Cloudflare Workers, Vercel Edge) lackAsyncLocalStorageand need the per-capture hint below.
Per-capture and withScopelink
Attach identity/tags to one event without touching shared scope - works on any adapter or runtime:
BugWatch.captureException(err, { user: userId ? { id: userId } : undefined, tags: { invoiceId, route: "/api/invoices" } });BugWatch::captureException($e, ['user' => ['id' => $userId], 'tags' => ['route' => $routeName, 'tenant' => $tenantId]]);withScope opens a temporary cloned scope; mutations inside are discarded when it returns. It captures nothing by itself: call a capture method inside the callback:
BugWatch.withScope((scope) => {
scope.setTag("orderId", order.id);
scope.setUser({ id: user.id });
BugWatch.captureException(err); // sees the scoped tag + user
});Tags, context and releaselink
BugWatch.setTag("region", "eu-west-1"); // indexed, searchable
BugWatch.setContext("payment", { provider: "paystack", currency: "NGN" }); // not indexed; on detail view
BugWatch.setRelease("checkout@2.4.1"); // or the `release` init optionBugWatch::setTag('region', 'eu-west-1');
BugWatch::setTags(['tenant' => 't_42', 'version' => '2.4.1']);
BugWatch::setContext('payment', ['provider' => 'paystack', 'amount_ngn' => 5000]);
BugWatch::setRelease('checkout@2.4.1');- Tags - scalar values only, max 50 per event, indexed for filtering. Wire:
tags{}. - Context - arbitrary structured data grouped by name, not indexed; shown on the event detail. Wire:
contexts{}(passthrough, redacted like everything else). - Breadcrumbs -
addBreadcrumb({ category, type, level, message, data })keeps a bounded trail (last 50; per-request inside an adapter scope) rendered as a timeline on the occurrence. Wire:breadcrumbs[]. - Release - build/version label (version string or git SHA). Wire:
release. Environment is bound to the API key, not sent by the SDK. - Cause chains - automatic in both SDKs: JS
new Error(msg, { cause })/AggregateErrorand PHPgetPrevious()chains ship a structuredcauses[]array (each cause with its own type, message and, in JS, stacktrace; depth 3).
Event ID and deduplicationlink
captureException returns an event ID. The server deduplicates events that repeat the same eventId within a 10-minute window - a duplicate increments deduped instead of ingested:
const eventId = BugWatch.captureException(err);Wire: eventId (≤200 chars).
Trace correlation and distributed tracinglink
Both SDKs join W3C traces automatically: every JS server adapter and the PHP Laravel middleware read the inbound traceparent header, and a request without one gets a synthesized trace id (JS), so errors and logs always share the request's trace. Outbound, wrapFetch (JS) injects traceparent for you, and BugWatch.traceHeaders() (both SDKs) returns the header for any other client. Both SDKs can also create spans (withSpan/startSpan) that appear on the trace waterfall; see the JS and PHP pages under Install an SDK for the span API.
If your app already uses OpenTelemetry, the JS ./otel subpath injects the active OTel span's traceId / spanId into every event instead:
import { otelTraceContextProvider } from "@newinstance/bugwatch/otel";
BugWatch.init({ projectKey: process.env.BUGWATCH_KEY, traceContextProvider: otelTraceContextProvider() });Or set it manually - per event or globally:
BugWatch.captureException(err, { traceId: "abc123", spanId: "def456" });
BugWatch.setTraceContext(currentTraceId, currentSpanId); // setTraceContext(null, null) to clearWire: traceId (hex, ≤32 chars) and spanId (hex, ≤16 chars) - non-hex characters are stripped server-side.
Samplinglink
Send only a fraction of events (useful for high-volume info/debug logs):
BugWatch.init({ projectKey: process.env.BUGWATCH_KEY, sampleRate: 0.25 }); // 25%; default 1 (100%)Sampling is SDK-side and runs before beforeSend; sampled-out events never reach the wire. For level-based sampling (keep all errors, sample info) use beforeSend.
Redaction and beforeSendlink
The SDK redacts sensitive values before they leave your process (the server redacts again as defence-in-depth). Default keys include password, passwd, pwd, token, accesstoken, refreshtoken, idtoken, auth, authorization, cookie, setcookie, secret, clientsecret, privatekey, apikey, sessionid, creditcard, cardnumber, cvv, pin, ssn, bvn, nin (case-insensitive). Matched values become [REDACTED]; redaction runs on a deep clone and never mutates your objects. Add your own and/or drop/mutate events:
BugWatch.init({
projectKey: process.env.BUGWATCH_KEY,
sensitiveFields: ["accountNumber", "iban"], // merged with the defaults
beforeSend(event) {
if (event.tags?.url?.includes("/health")) return null; // drop the event
if (event.user) event.user = { id: event.user.id }; // strip email/username
return event;
},
});Both are SDK-side - they shape or suppress the payload before it is sent.
Fingerprinting (grouping)link
Override how events are grouped into issues. PHP exposes setFingerprint (the JS SDK has no fingerprint control today; its per-capture hint accepts level, tags, user, traceId, spanId only):
BugWatch::setFingerprint('payment-gateway-timeout'); // one issue for all
BugWatch::setFingerprint(['checkout', 'GATEWAY_TIMEOUT']); // group by component + codeWire: fingerprint (passthrough grouping hint).
Browser apps - never ship the secretlink
Browser code is public. Never put your project key/secret in client-side code. Your backend mints a short-lived session token; the browser SDK uses that instead:
// Backend (Express) - expose a mint endpoint:
import { bugWatchBrowserSessionHandler } from "@newinstance/bugwatch/express";
app.get("/bugwatch/session", bugWatchBrowserSessionHandler({ projectKey: process.env.BUGWATCH_KEY }));
// Browser - one call, no projectKey (v0.2.0+):
import { initBrowser } from "@newinstance/bugwatch";
const client = initBrowser({ sessionUrl: "/bugwatch/session" });initBrowser handles the SSR guard, the singleton and handler installation, resolves a relative sessionUrl against the page origin, and never throws: any failure degrades to a working no-op client plus one console warning, so the reporter can never crash the page.
The mint endpoint calls POST /api/v1/bugwatch/browser-session (see Server Ingest API); browser events post to /api/v1/bugwatch/ingest/browser with x-bugwatch-session (see Browser Ingest).
What lands on the wirelink
| SDK feature | On the ingest event? | Event field |
|---|---|---|
setUser / setRequestUser / per-capture user | Yes - one merged object | user{id,email,username,ip} |
setTag / per-capture tags | Yes | tags{} (≤50, scalar) |
setContext | Yes | contexts{} (passthrough) |
addBreadcrumb | Yes | breadcrumbs[] (last 50) |
| cause chains (automatic) | Yes | causes[] (depth 3) |
setRelease / release | Yes | release (≤200) |
| returned event id | Yes | eventId (≤200, dedup) |
OTel / setTraceContext | Yes | traceId (≤32 hex), spanId (≤16 hex) |
setFingerprint | Yes | fingerprint (passthrough) |
| Sampling | No - sampled out before send | - |
Redaction / beforeSend | Shapes/suppresses the payload | - |
| Per-request vs global | No - resolved to one user before send | - |
For the full per-adapter examples (Koa, Fastify, Hono, Nest, Next.js) and every option, see the SDK READMEs: @newinstance/bugwatch (JS/TS) and newinstance/bugwatch (PHP).