Distributed trace propagation for Node.js. W3C TraceContext-compatible identifiers, automatic context propagation through AsyncLocalStorage, framework adapters for Express, Koa, Hono, and Fastify, and a span emitter that any collector or observability layer can consume.
npm install @prsm/trace
A tracer creates trace IDs at the edge of a request and propagates them through every async boundary without requiring you to thread context manually.
import { createTracer } from '@prsm/trace'
const tracer = createTracer({ service: 'order-api' })
app.use(tracer.express())
app.post('/order', async (req, res) => {
// tracer.current() returns { traceId, spanId, parentSpanId, sampled }
// available everywhere in this async chain
await queue.push(job)
await cache.fetch(key, loader)
await workflow.start('process-order', input)
res.json({ ok: true })
})
tracer.onSpan((span) => {
// ship to your collector, log it, store it, broadcast it
})When a downstream service receives a request from this server with a traceparent header, its own tracer can pick up where this one left off — the trace continues across process and network boundaries.
Tracing is the difference between "an order failed somewhere" and "the order failed because the cache stampede on user:42 blocked the workflow's lookup step for 11 seconds." It is the cross-cutting concern that turns a distributed system from a black box into something you can reason about.
The hard part is propagation. You can sprinkle traceId parameters through every function signature in your codebase, or you can use AsyncLocalStorage and have it just work — including across setTimeout, promise chains, and any package that opts into the convention. This package gives you the latter, with a tiny surface area and zero ceremony.
const tracer = createTracer({
service: 'order-api',
sampler: true, // true | false | 0..1 | ({ name, attributes }) => boolean
})The most common way to instrument code. Wraps a function in a span that automatically captures duration, errors, and context.
const value = await tracer.span('compute-total', { orderId }, async () => {
return await computeTotal()
})Inside the callback, tracer.current() returns the active context. Errors thrown by fn are recorded on the span and rethrown.
Lower-level: returns a span handle with setAttribute, setError, and end. Useful when the lifetime doesn't fit a single await.
const handle = tracer.startSpan('long-running-thing')
await tracer.run(handle.context, async () => {
// ... work that can read tracer.current() ...
})
handle.end()Returns the active trace context or null. The context shape is { traceId, spanId, parentSpanId, sampled }.
Runs fn with ctx as the active context. Useful when you have a context from another source (e.g. a persisted record) and want to restore it before doing work.
Read and write the traceparent header on outbound and inbound requests.
const headers = {}
tracer.inject(headers)
await fetch(url, { headers })
// On the other end:
const parent = tracer.extract(req.headers)Parses a traceparent header string into { traceId, parentSpanId, sampled } for use as a span parent.
app.use(tracer.express()) // Express
app.use(tracer.koa()) // Koa
app.use(tracer.hono()) // Hono
fastify.register(tracer.fastify()) // FastifyEach adapter:
- Reads
traceparentfrom the incoming request to continue an upstream trace. - Starts a
serverspan around the handler. - Captures
http.method,http.path,http.statusas attributes. - Marks 5xx responses as errors.
- Puts the context in
AsyncLocalStorageso everything inside the handler sees it.
The adapters can also be imported by subpath if you prefer:
import { expressMiddleware } from '@prsm/trace/express'Registers a span-end listener. The callback receives a fully-formed span:
{
traceId, spanId, parentSpanId,
service, name, kind, // 'server' | 'client' | 'internal' | 'producer' | 'consumer'
startedAt, endedAt, durationMs,
attributes,
status, // 'ok' | 'error'
error, // { name, message, stack } | null
}Hook this into your collector, log it, push it to @prsm/devtools, or ship it to OpenTelemetry.
createTracer({ sampler: true }) // sample everything (default)
createTracer({ sampler: false }) // sample nothing
createTracer({ sampler: 0.1 }) // sample 10%
createTracer({ sampler: ({ name }) => name.startsWith('http.') })The sampling decision is made when a root span starts (no parent). Child spans inherit their parent's decision, so a sampled trace stays fully sampled all the way down.
Trace and span IDs use the W3C TraceContext format:
traceId: 16 random bytes, encoded as 32 hex characters.spanId: 8 random bytes, encoded as 16 hex characters.traceparent:00-{traceId}-{spanId}-{flags}where flags01means sampled,00means not.
You can talk to any service that speaks the same format — OpenTelemetry collectors, other languages' OTel SDKs, browser-based instrumentation.
npm install
npm test
No infrastructure required.