-
Notifications
You must be signed in to change notification settings - Fork 2
Tracer Concepts
How a trace is built, and why spans nest without being passed around.
- Spans and traces
- The span lifecycle
- Why nesting is automatic
- startSpan vs startActiveSpan
- Span kind
- Status and exceptions
- Nothing on a span throws
A span is one unit of work: a name, a start and end time, and a parent. A
trace is the tree of spans that share a traceId.
checkout ──────────────────────────────── 210ms trace 4bf92f…, span aaa…
auth.verify ──── 40ms trace 4bf92f…, span bbb…, parent aaa…
db.query ──────── 120ms trace 4bf92f…, span ccc…, parent aaa…
Every span carries a SpanContext: the traceId it belongs to, its
own spanId, and the sampling flag. That triple is the only thing that has to
travel between processes — see Tracer-Propagation.
The distinction that matters when reading a trace: a child span measures a duration, whereas an event marks an instant. Use a child span for "this took this long", an event for "this happened".
import { SpanStatusCode, Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const span = tracer.startSpan('db.query'); // 1. created, clock starts
span.setAttribute('db.system', 'postgres'); // 2. described
span.addEvent('cache.miss'); // …and annotated
span.setStatus(SpanStatusCode.ERROR, 'timeout');
span.end(); // 3. clock stops, handed to the exporterend() is what queues the span for export, and it is idempotent — a second
call is ignored, so finally { span.end() } is always safe. After it, the span
is inert: further writes are silently dropped rather than throwing or
retroactively changing an exported span.
Exporters never receive the live Span. They receive an immutable
SpanData snapshot, so a slow or asynchronous exporter cannot observe
a span mid-write or mutate trace state.
The usual way to build a span tree is to thread the parent through every call:
import { type Span, Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
async function checkout(span: Span) {
await charge(span); // must pass it
}
async function charge(parentSpan: Span) {
const s = tracer.startSpan('charge', { parent: parentSpan.context });
// …
}Tracer keeps the active span in an
ambient async context instead. startSpan reads it
and parents itself automatically:
import { Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
await tracer.startActiveSpan('checkout', async () => {
await charge(); // no span parameter
});
async function charge() {
const s = tracer.startSpan('charge'); // parents to `checkout` anyway
s.end();
}This works across await, at any call depth, and keeps concurrent requests
isolated — request A and request B each see their own active span even though
they interleave on one event loop. That property is AsyncLocalStorage's, and
it is the entire reason ambient is a dependency.
Tracer keeps the active span in its own context store, separate from
ambient's shared RequestContext. Span lifecycle is tracer's concern; the
request bag is the application's.
That store is built on first use, not at import, so importing
@tundralibs/tracer succeeds everywhere — including a browser bundle, where
node:async_hooks does not exist. What changes there is only what genuinely
depends on a scope:
-
tracer.active()returnsundefined, andtracer.logContext()/tracer.propagation()return{}— exactly what they return outside any span, so callers need no branch. -
startActiveSpan(and thewrap/wrapClientwitnesses built on it) throws aTypeError. Establishing a scope is the one thing that cannot degrade quietly: silently running the callback with no active span would turn a nested trace into orphaned roots. -
startSpan,span.end(), exporters andinject/extractneed no context at all, so manual span lifecycles and propagation keep working.
On every supported runtime — Deno, Bun, Node >= 22 and Cloudflare Workers
under nodejs_compat — the store is a single process-wide instance shared by
every caller, so nesting behaves identically to a store built at import time.
startSpan |
startActiveSpan |
|
|---|---|---|
| Makes the span active | no | yes, for the callback's duration |
| Ends the span | you must call end()
|
automatic, including on throw |
| Spans created inside | do not parent to it | parent to it |
Reach for startActiveSpan by default. startSpan is for the case where a
span's lifetime genuinely does not match a function call — a span you open in
one callback and close in another (see the Express recipe in
RECIPES.md, where the response event ends the span).
startActiveSpan handles async callbacks correctly: it keeps the span open
until the returned promise settles, so the duration covers the real work rather
than just the synchronous head. If the callback throws or rejects, the exception
is recorded on the span and re-thrown unchanged.
SpanKind tells a backend how to draw the span, and its numeric values
are OTLP's:
| Kind | Use for |
|---|---|
INTERNAL |
work with no remote counterpart (the default) |
SERVER |
handling an inbound request |
CLIENT |
making an outbound request |
PRODUCER / CONSUMER
|
asynchronous message publish / handle |
SERVER and CLIENT are what let a backend pair the two halves of a remote
call and show network time between them, so setting them is worth the keystrokes
at request boundaries.
UNSET is the default and means "no explicit judgement" — it is not a
failure. Set ERROR only for genuine failures, so error-rate panels stay
meaningful.
recordException() adds an exception event using the OpenTelemetry attribute
names (exception.type, exception.message, exception.stacktrace) so
backends render it as an error. It deliberately does not set the span status:
a caught and handled exception is not necessarily a failed operation. Set the
status yourself when it is:
import { SpanStatusCode, Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const span = tracer.startSpan('risky');
const risky = () => Promise.resolve();
try {
await risky();
} catch (err) {
span.recordException(err);
span.setStatus(SpanStatusCode.ERROR, 'risky() failed');
throw err;
}Tracing is observability. It must never be able to break the code it observes, so every span operation is total:
- writes after
end()are ignored - writes to a span that sampling dropped are ignored
- export failures are swallowed (the exporter reports them out of band)
- a malformed inbound
traceparentjust means "start a new trace"
The one place tracer does throw is construction — an invalid
serviceName, sampler, exporter, or idGenerator raises
TracerConfigError immediately. A misconfiguration is cheap to
surface at startup and expensive to discover as missing traces later.