Telemetry — Tracing
Overview
The tracing module instruments code with distributed spans compatible with OpenTelemetry. Every span captures its name, timing, status, attributes, and parent relationship. Completed spans are forwarded to all registered TraceExporter instances asynchronously.
Context propagation is automatic: any startSpan call inside the executor of an outer startSpan will inherit the outer span's traceId and record itself as a child span via parentSpanId.
Key Concepts
- Span — a named unit of work with a start time, end time, status, and optional attributes and error.
- Trace — a tree of spans sharing a common
traceId, representing an end-to-end operation. - Context inheritance —
startSpanreads the activeTelemetryContextto find the currenttraceIdandspanId, sets those on the new span, then updates the context for all code running inside the executor. - Automatic finalization — the span's status and
endTimeare set when the executor returns (or rejects), whether it is synchronous or returns a promise.
API Reference
startSpan<T>(name, options, executor): MaybePromise<T>
The primary tracing API. Wraps a block of synchronous or asynchronous code in a span.
| Parameter | Type | Description |
|---|---|---|
name |
string |
Human-readable span name |
options |
SpanOptions | undefined |
Span configuration (see below) |
executor |
(span: TracingSpan) => T | Promise<T> |
The instrumented code block |
Returns the same type (sync or async) as the executor.
import { startSpan } from '@tagna/udiot/telemetry';
// Synchronous
const value = startSpan('compute', undefined, (span) => {
return heavyComputation();
});
// Asynchronous
const result = await startSpan('fetchUser', { kind: 'client' }, async (span) => {
return db.users.findById(span.traceId);
});
SpanOptions
| Field | Type | Default | Description |
|---|---|---|---|
kind |
SpanKind |
'internal' |
Semantic role of this span |
attributes |
TelemetryAttributes |
undefined |
Key/value metadata |
traceId |
string |
inherited or new | Override the trace ID |
spanId |
string |
auto-generated | Override the span ID |
parentSpanId |
string |
inherited | Explicit parent span ID |
context |
Partial<TelemetryContext> |
undefined |
Additional context fields to merge |
SpanKind
type SpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer';
| Value | When to use |
|---|---|
'internal' |
Default. Internal computation with no network boundary. |
'server' |
Receiving an inbound request (HTTP server, RPC handler). |
'client' |
Making an outbound request (HTTP client, database query). |
'producer' |
Publishing a message to a queue or event stream. |
'consumer' |
Processing a message from a queue or event stream. |
SpanStatus
type SpanStatus = 'in_progress' | 'ok' | 'error';
'in_progress' is the initial state. The framework sets 'ok' when the executor returns normally and 'error' when it throws or the returned promise rejects.
TracingSpan
The object passed to the executor and forwarded to exporters after finalization:
| Field | Type | Description |
|---|---|---|
id |
string |
8-byte hex span ID |
traceId |
string |
16-byte hex trace ID |
parentSpanId |
string | undefined |
Parent span ID (if any) |
name |
string |
Span name |
kind |
SpanKind |
Semantic kind |
attributes |
TelemetryAttributes | undefined |
Attached metadata |
status |
SpanStatus |
Finalized status |
startTime |
number |
Unix timestamp in ms |
endTime |
number | undefined |
Unix timestamp in ms (set on finalization) |
error |
Error | undefined |
Error object if status is 'error' |
Exporter management
registerTraceExporter(exporter: TraceExporter): void
Adds a trace exporter. Multiple exporters are supported.
unregisterTraceExporter(exporter: TraceExporter): void
Removes an exporter.
resetTraceExporters(): void
Clears all registered exporters. Useful in tests.
TraceExporter interface
interface TraceExporter {
export(span: TracingSpan): Promise<void> | void;
}
Implement this interface to send spans to any backend. Errors thrown from export() are caught and logged as warnings — they do not propagate to the application.
Basic Usage
Nested spans for an API handler → database call
import { startSpan, getTelemetryContext } from '@tagna/udiot/telemetry';
async function handleCreateOrder(userId: string, items: string[]) {
return startSpan('createOrder', { kind: 'server', attributes: { userId } }, async (parentSpan) => {
// Inside here, traceId and spanId are set in the ambient context.
// Any nested startSpan will automatically become a child of this one.
const order = await startSpan('db.insertOrder', { kind: 'client' }, async () => {
// parentSpanId is automatically set to parentSpan.id
return db.orders.insert({ userId, items });
});
await startSpan('notify.orderCreated', { kind: 'producer' }, async () => {
await eventBus.publish('order.created', { orderId: order.id });
});
return order;
});
}
The resulting trace tree:
createOrder (traceId: abc123, spanId: 0001)
└── db.insertOrder (traceId: abc123, spanId: 0002, parentSpanId: 0001)
└── notify.orderCreated (traceId: abc123, spanId: 0003, parentSpanId: 0001)
Context propagation via HTTP headers
To continue a trace from an upstream service, pass the incoming traceId and spanId via SpanOptions:
import { startSpan } from '@tagna/udiot/telemetry';
// In your HTTP middleware
function traceMiddleware(req: Request, res: Response, next: NextFunction) {
const incomingTraceId = req.headers['x-trace-id'] as string | undefined;
const incomingSpanId = req.headers['x-span-id'] as string | undefined;
startSpan(
`${req.method} ${req.path}`,
{
kind: 'server',
traceId: incomingTraceId, // continue the upstream trace
parentSpanId: incomingSpanId, // upstream span becomes this span's parent
},
async () => {
next();
}
);
}
// When making outbound HTTP calls, propagate the current context:
import { getTelemetryContext } from '@tagna/udiot/telemetry';
async function callDownstream(url: string) {
const ctx = getTelemetryContext();
return fetch(url, {
headers: {
'x-trace-id': ctx?.traceId ?? '',
'x-span-id': ctx?.spanId ?? '',
},
});
}
Custom trace exporter
import { TraceExporter, TracingSpan, registerTraceExporter } from '@tagna/udiot/telemetry';
class MyExporter implements TraceExporter {
async export(span: TracingSpan): Promise<void> {
await sendToMyBackend({
traceId: span.traceId,
spanId: span.id,
parentId: span.parentSpanId,
name: span.name,
durationMs: (span.endTime ?? 0) - span.startTime,
status: span.status,
error: span.error?.message,
});
}
}
registerTraceExporter(new MyExporter());
Common Patterns
Reading the active span inside an executor — the executor receives the TracingSpan object as its first argument. You can attach additional attributes by mutating span.attributes directly.
Fire-and-forget tasks — if you launch background work inside a span's executor without await, the parent span may close before the child. Either await all child work or start a separate root span for the background task using an explicit traceId.
Testing — collect exported spans with a custom TraceExporter implementation that pushes to an array, then assert against the array after the operation.
Pitfalls
- Context propagation is module-level and not
AsyncLocalStorage-based. In concurrent Node.js request handlers running in the same event loop tick withoutawaitseparation, contexts can collide. UsewithTelemetryContext()explicitly to isolate per-request context. - Exporter failures are silently swallowed (logged as warnings). If export reliability is critical, implement retry logic inside your
TraceExporter.export()method. - The
endTimefield onTracingSpanis only set after the executor finishes. Do not read it inside the executor — it will beundefined.