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.
@prsm/trace/otlp ships an OTLP/HTTP+JSON exporter so spans can be shipped to any OpenTelemetry-compatible backend - Jaeger, Tempo, Honeycomb, Datadog, New Relic, Grafana Cloud, or a self-hosted OpenTelemetry Collector.
import { createTracer } from '@prsm/trace'
import { otlpExporter } from '@prsm/trace/otlp'
const tracer = createTracer({ service: 'order-api' })
const exporter = otlpExporter({
url: 'http://otel-collector:4318/v1/traces',
resource: {
'deployment.environment': 'production',
'host.name': process.env.HOSTNAME,
},
batch: { size: 100, timeout: '5s' },
retry: { maxAttempts: 3, initialBackoff: '500ms' },
headers: {
// for SaaS backends that require auth:
// 'x-honeycomb-team': process.env.HONEYCOMB_API_KEY,
},
onError: (err) => console.error('otlp export failed', err),
})
tracer.onSpan(exporter)
// graceful shutdown - flush any buffered spans
process.on('SIGTERM', async () => {
await exporter.shutdown()
})Spans are batched (default 100 spans or 5 seconds, whichever first), grouped by service, and POSTed as OTLP/HTTP JSON. Failed requests retry with exponential backoff except on 4xx (other than 429, which is treated as retryable). Network errors and final failures surface to the optional onError callback; nothing throws to your application code.
@prsm/devtools and your OTLP backend are complementary: devtools gives you live, in-the-moment debugging (last hour of traces, click around, see what just broke), while the OTLP backend gives you long-term retention, aggregation queries, and alerting. Wire both up - same tracer.onSpan(...) listener mechanism.
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.