Instrumentation
@vercube/telemetry/instrument is the toolkit the framework's own packages use to instrument themselves, and it is public for the same reason they need it. @vercube/storage traces every operation, @vercube/cache counts hits and misses, @vercube/queue carries trace context across a process boundary, and none of them depends on OpenTelemetry directly.
Nothing on this subpath touches the HTTP layer, the plugin or the DI container. A library that wants to be traceable can import it without pulling the framework in.
createInstrument
import { createInstrument, SpanKind, ValueType } from '@vercube/telemetry/instrument';
const instrument = createInstrument('acme-billing');
The argument is the instrumentation scope, conventionally your package name. It shows up on every span and instrument you produce, which is how a backend tells your spans apart from the framework's.
Call it once at module level. Creating the toolkit creates nothing: the tracer is resolved on first span and the instruments on first use. That matters for metrics, because the OpenTelemetry metrics API has no proxy meter and an instrument created before a MeterProvider is registered stays a no-op for the life of the process.
Spans
export function charge(amount: number): Promise<Receipt> {
return instrument.span('billing.charge', { kind: SpanKind.CLIENT, attributes: { 'billing.amount': amount } }, () =>
gateway.charge(amount),
);
}
span returns whatever your function returned, unchanged. A synchronous function stays synchronous, and the span ends when the work settles however it settles: a plain return, a synchronous throw, or a rejection. A failure records the exception, sets error.type and marks the span as failed.
Server spans follow a different rule, and you do not want it here. A 4xx on a server span describes the caller, so the framework's request span is not marked failed by one. An error out of a storage driver carrying status: 404 is a genuine failure of that operation, so instrument.span marks it.
The callback receives the span, for anything you only learn while the work runs:
instrument.span('billing.charge', {}, (span) => {
const receipt = gateway.charge(amount);
span.setAttribute('billing.receipt', receipt.id);
return receipt;
});
Crossing a process boundary
A span parents itself on whatever is active. When the work continues in another process, the parent has to travel with the message.
export function publish(job: Job): Promise<void> {
return instrument.span('acme.publish', { kind: SpanKind.PRODUCER }, () => {
const headers: Record<string, string> = {};
instrument.inject(headers);
return transport.send(job, headers);
});
}
export function consume(job: Job, headers: Record<string, string>): Promise<void> {
return instrument.spanFrom('acme.process', { kind: SpanKind.CONSUMER }, instrument.extract(headers), () => run(job));
}
inject writes W3C trace context into a plain header record, extract reads it back into a context usable as a parent, and spanFrom takes that context instead of the active one. Without this the consumer starts a trace of its own and the two halves of the same operation can never be put back together.
Call inject from inside the producer span, not before it. It writes whatever is active at that moment, so injecting first names the caller of publish as the parent and the producer span drops out of the chain. @vercube/queue builds its headers inside the publish span for this reason.
Both use the globally registered propagator, so they do nothing until an application installs one.
Metrics
instrument
.counter('acme.billing.charges', {
description: 'Charges handed to the gateway.',
unit: '{charge}',
valueType: ValueType.INT,
})
.add(1, { 'billing.currency': currency });
counter, upDownCounter and histogram memoize by name, so calling them on every operation is what you are meant to do. The options are read the first time and ignored afterwards.
Keep the attribute values bounded. One time series per customer id is how a metrics backend gets destroyed; put the unbounded value on the span instead, where it costs one attribute rather than one series.
The active span
instrument.activeSpan()?.addEvent('acme.decision', { 'acme.outcome': outcome });
Use this when the thing you are recording is part of an operation somebody else already opened a span for. @vercube/auth records its decision this way: the check is part of serving the request, and a span of its own for something that takes microseconds buys nothing.
Cost when nobody is collecting
Every method is a no-op until the application registers a provider, so a library can instrument itself unconditionally. The OpenTelemetry API creates non-recording spans in that state, which is not free but is close to it, and with telemetry: false the framework's own instrumentation points are a single null check.