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:

ScopeLifetimeUse it for
GlobalProcess-wide, until changedSingle-identity processes - workers, CLIs, a signed-in mobile/desktop user
Per-requestOne HTTP request; auto-cleared at the endConcurrent servers - bind the authenticated user to this request only
Per-captureOne eventA 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 user object (id, email, 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 logout

Only id, email, username, ip are kept - any other key is dropped before the event leaves your process.

Concurrency warning: setUser sets 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, withBugWatchRouteHandler opens a per-request scope, so setRequestUser works inside Next.js route handlers too; only true edge runtimes (Cloudflare Workers, Vercel Edge) lack AsyncLocalStorage and 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 option
BugWatch::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 }) / AggregateError and PHP getPrevious() chains ship a structured causes[] 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 clear

Wire: 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 + code

Wire: 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 featureOn the ingest event?Event field
setUser / setRequestUser / per-capture userYes - one merged objectuser{id,email,username,ip}
setTag / per-capture tagsYestags{} (≤50, scalar)
setContextYescontexts{} (passthrough)
addBreadcrumbYesbreadcrumbs[] (last 50)
cause chains (automatic)Yescauses[] (depth 3)
setRelease / releaseYesrelease (≤200)
returned event idYeseventId (≤200, dedup)
OTel / setTraceContextYestraceId (≤32 hex), spanId (≤16 hex)
setFingerprintYesfingerprint (passthrough)
SamplingNo - sampled out before send-
Redaction / beforeSendShapes/suppresses the payload-
Per-request vs globalNo - 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).

SDK Usage & Event Enrichment — NewInstance Documentation