The observability spine for an application: one function, tracing.span, that runs your work inside an OpenTelemetry span and emits paired structured Pino events — plus a Disposable timeout signal with error-matchable reasons, all threaded through one Options bag.
The premise: OTel spans and structured logs answer the same question ("what happened, where, how long?") through two pipes, and keeping them in sync by hand is how they drift. Here a single call feeds both — every span emits span_start / span_attrs / span_event / span_fail / span_end log lines carrying the live traceId/spanId, so logs and traces correlate without any collector-side magic.
import*astracingfrom"@superbuilders/tracing"asyncfunctionloadCatalog(frontendId: string,opts: tracing.Options){returntracing.span("catalog.load",asyncfunctionload(span,opts){span.set({ frontendId })
using scoped=tracing.timeout(opts.signal,5_000)constrows=awaitfetchCatalog(frontendId,scoped.signal)span.event("catalog_fetched",{rowCount: rows.length})returnrows},opts)}pnpm add @superbuilders/tracing pino
ESM only. @opentelemetry/api is a real dependency (it is the ecosystem's singleton API surface); pino is a peer — you bring your logger.
typeOptions={logger: Logger// pinosignal: AbortSignal}Options is the cross-cutting context: the logger and the cancellation signal travel together, as the last parameter, through every function in a codebase. span() hands the same bag to your callback, so nesting spans is just passing opts down — child spans parent correctly through OTel's active-span context, and every log line stays correlated.
At the top of a process, construct it once:
constopts: tracing.Options={ logger,signal: AbortSignal.timeout(30*60_000)}Runs fn inside an active OTel span. The callback receives a Span handle and the threaded opts:
span.set(attrs)— attach attributes (OTelsetAttributes+ aspan_attrslog line).undefinedvalues are dropped, so optional fields spread in cleanly.span.event(name, attrs?)— a point-in-time event (OTeladdEvent+span_event).span.fail(error, message, attrs?)— record a failure without throwing (exception + ERROR status +span_fail). Iffnlater throws, the failure is not double-recorded.
A rejection from fn is recorded (once), the span ends failed: true, and the error propagates unchanged — span never swallows your errors. Conversely, telemetry never breaks your work: a throwing OTel span implementation is deliberately swallowed inside the recording path.
Attribute keys are compile-time guarded against the reserved trace-correlation names (traceId, span_id, otel.trace_id, …) — the Attrs type maps them to never, so a colliding key is a type error instead of a corrupted log correlation.
A scoped child signal that aborts on whichever comes first — the deadline or the parent — and cleans up with using:
using scoped=tracing.timeout(opts.signal,5_000)constresult=awaiterrors.try(fetch(url,{signal: scoped.signal}))if(result.error){if(errors.is(scoped.signal.reason,tracing.ErrTimeout)){throwerrors.wrap(result.error,"catalog fetch deadline")}throwerrors.wrap(result.error,"catalog fetch")}The reasons are sentinel errors, matchable through any wrap chain with errors.is:
- deadline → a wrap of
ErrTimeoutnaming the duration - parent aborted with an
Errorreason → that exact reason, untouched - parent aborted with a non-Error reason → a wrap of
ErrCanceled
Disposal (end of using scope) clears the timer and detaches from the parent; it is idempotent, and a disposed scope can never fire late.
The current { traceId, spanId } (or {} outside any span) — for stamping trace context onto things that leave the process, like queue messages or response headers.
- No exporter opinions. This wraps
@opentelemetry/apionly; with no SDK registered, spans are no-ops but the structured log events still flow — the library degrades to a disciplined logging convention. - Errors discipline throughout: failures are recorded from real
Errorvalues (built with@superbuilders/errors), and the timeout reasons are designed forerrors.ismatching rather than string comparison.
0BSD © Bjorn Pagen