Tags vs. context
Both ride along on events, but serve different jobs:
- Tags (
setTag(key, value)) are flatkey: stringpairs; numbers and booleans are converted to strings. They're what the dashboard's search matches first for akey:valuefilter, e.g.tenant:acmeon Exceptions/Traces/Logs (see Search). - Context (
setContext(key, object)) attaches a nested object under a namespace —setContext('order', { id, total })becomesorder.id/order.totalon the event. It's for descriptive data shown on the event's detail page; search falls back to it when no tag has the same key.
Global vs. per-request
This is the one thing that works differently from the browser. A server handles many requests at once on one shared client, so there are two levels:
| Set with | Applies to | |
|---|---|---|
| Global | setTag(), setContext(), setUser() | Every event from the whole process, from now on |
| Scope | withScope((scope) => ...) → scope.setTag(), scope.setContext(), scope.setUser() | Only events captured inside that callback — across every await in it |
Use the global functions for things that are the same for the whole process — the service name, the region, the queue a worker consumes:
ts
import { setTag } from '@buglapse/node'
setTag('service', 'billing-api')
setTag('region', process.env.REGION)Don't call setUser() or setTag() with request data inside a handler. It changes the global value, so a concurrent request — and every request after it — would be reported with the wrong user. Per-request data belongs in a scope.
Per-request scope with withScope()
withScope() overlays tags and context for everything captured inside its callback. With the Node SDK the overlay follows the callback's async chain, so it survives await, timers and promise callbacks started inside it — and concurrent calls never see each other's overlay:
ts
import { captureException, withScope } from '@buglapse/node'
await withScope(async (scope) => {
scope.setTag('tenant', tenant.slug)
scope.setContext('order', { id: order.id, total: order.total })
try {
await chargeCard(order)
} catch (error) {
captureException(error) // carries tenant + order.*
}
})The scope overlays the global values; a key set in both takes the scope's value. Anything captured after withScope() returns doesn't see the overlay.
In a web framework
A middleware that wraps the rest of the chain in withScope() gives every request its own scope. Call next() inside the callback, so the route handlers run within it:
ts
import { withScope } from '@buglapse/node'
app.use((request, response, next) => {
withScope((scope) => {
if (request.user) {
scope.setUser({ id: request.user.id, email: request.user.email })
}
scope.setTag('tenant', request.tenant.slug)
next()
})
})scope.setUser() is the per-request equivalent of setUser() — it writes the same user.* keys the dashboard uses to attribute events to a user, but only for events captured inside the scope.
HttpServerIntegration already runs each request inside its own scope (with request.url, request.method and request.referrer set), plus its own breadcrumb trail and trace. Your middleware's withScope() nests inside it and adds to it.
Jobs and scripts
Anything that doesn't start from an HTTP request — a queue job, a cron task — needs the same wrapper to get its own scope: withScope(), or asyncSpan() if you also want it traced. Without one, events share the process-wide root scope.
Identifying users
setUser() is a shortcut for setContext('user', ...). Every field is optional; a repeat call merges into what's already set:
ts
import { setUser } from '@buglapse/node'
setUser({ id: '42', email: 'jane@example.com', username: 'jane' })On a server, that's only right when the whole process acts for one user — a CLI tool, a desktop app's backend. In a multi-user server, call scope.setUser() in a request's scope instead (see above).
The dashboard matches events to a user on its Users page by user.id, then user.email, then IP. Without any user.*, the IP it sees is your server's, so every event lands on one "user" — set the user explicitly to get meaningful user counts.
environment and release
These are set once, in init() — not with setTag()/setContext() — because the dashboard treats them specially: environment powers the environment filter (and can drop whole environments on Settings → Filters), and release is what Source maps match against. See Configuration.
Auto-populated context
- NodeContextIntegration adds
runtime.*,os.*andhost.*— the Node version, operating system and machine name — to every event. - HttpServerIntegration adds
request.url,request.methodandrequest.referrerto every event captured while handling a request. - While a span is active, every event gets
trace.idandspan.id— see Tracing.
Viewing context on the dashboard
An event's detail page groups the flattened keys back by prefix (order.* under "Order", and so on). trace.* and user.* have their own UI (the trace link, the Users page) and aren't repeated in that list. Clicking any row searches for that exact key:value pair:
order.currency:"USD"