@opentelemetry/api
@opentelemetry/api is the interface half of OpenTelemetry for JavaScript: the types, enums, and global accessors for traces, metrics, context, and baggage, with no implementation behind them. Every method is a no-op until an SDK registers itself on a global, so a library can call trace.getTracer() and start spans without forcing any telemetry cost or vendor choice on the applications that install it. When an application does register an SDK, those same calls start producing real spans and metrics that flow to whatever exporter is configured. It has zero dependencies, ships its own TypeScript types, and gzips to under 5 KB, which is the whole point: instrument once against this, and the application decides where the data goes.
The right dependency for library authors and for hand-written spans inside an app that already runs an SDK, and it costs almost nothing to add. Just be clear that this package alone produces no telemetry, and that assembling the SDK side of OpenTelemetry in Node is still meaningfully more work than installing a vendor agent.
Use it if
- You maintain a library or framework and want optional instrumentation: your users who run an SDK get spans, and your users who do not pay nothing but a few no-op function calls
- You want manual spans inside an application that is already running an OpenTelemetry SDK, so your business logic shows up between the auto-instrumented HTTP and database spans
- You want to stay vendor-neutral: the same instrumentation code exports to Jaeger, Tempo, Honeycomb, Datadog, or an OTLP collector by changing SDK configuration rather than application code
- You need to propagate trace context across a service boundary yourself, for example over a message queue or a protocol the auto-instrumentations do not cover
- You installed only this package and expected telemetry: nothing is recorded. You also need an SDK, exporters, and instrumentation packages, and @opentelemetry/sdk-node is still on a 0.x version (0.221.0), so the assembly you depend on is not itself declared stable
- You want observability working this afternoon: dd-trace or @sentry/node give you distributed traces from one import and a token, whereas an OpenTelemetry setup in Node means picking a context manager, a propagator, a resource, a sampler, an exporter, and an instrumentation list, and loading it all before your app's first require
- You need logs: the logs API lives in a separate @opentelemetry/api-logs package that is still 0.x, so logging is not covered by the 1.x stability promise the tracing API gives you
- Your dependency tree has drifted: because the API finds the SDK through a global object, a package pinned to an incompatible API major silently receives no-op tracers and its spans just never appear, which is a genuinely painful thing to debug
- You are instrumenting a browser app and expect automatic context: async context propagation in the browser needs a zone-based context manager from the contrib repo, and without it spans started inside promises lose their parent
Setup reality
npm install @opentelemetry/api is trivial: zero dependencies, types included, works in CommonJS and ESM. Everything after that is the work. Applications additionally install an SDK plus exporters, and the SDK must be initialized before any instrumented module is required, which in practice means node --require ./tracing.js or, for ESM, node --import with the register loader. Library authors should declare the API as a peer dependency with a caret range and never as a bundled dependency, because two copies in one node_modules can leave one of them holding a no-op. Attribute names are not provided here either: the semantic conventions live in @opentelemetry/semantic-conventions, which versions on its own schedule. Expect to read release notes: the API is 1.9.1 and stable, while the SDK and instrumentation packages around it move fast and still carry 0.x version numbers.
Patterns
Wrap work in an active spanstart-active-span
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('checkout-service', '1.4.0');
async function charge(orderId) {
return tracer.startActiveSpan('charge', async (span) => {
try {
const result = await gateway.charge(orderId);
return result;
} finally {
span.end();
}
});
}startActiveSpan does not end the span for you, and a span that is never ended is never exported. The finally block matters: an early return or a thrown error otherwise leaks the span.
Mark a span as failed and attach the exceptionrecord-error-status
import { SpanStatusCode } from '@opentelemetry/api';
try {
await gateway.charge(orderId);
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
throw err;
} finally {
span.end();
}recordException adds an event with the stack trace but does not change the span status, so you need both calls. Leave successful spans at the default UNSET rather than setting OK; backends treat an explicit OK as a signal that no further judgement should be applied.
Add attributes and events to a spanspan-attributes
span.setAttribute('order.id', orderId);
span.setAttributes({
'payment.provider': 'stripe',
'payment.amount_cents': 4999,
'payment.retry': false,
});
span.addEvent('gateway.retry', { attempt: 2 });Attribute values must be a string, number, boolean, or a homogeneous array of those. Passing a nested object drops the attribute and logs a diag warning that you will not see unless you set up a diag logger.
Add data to whatever span is currently activeenrich-active-span
import { trace } from '@opentelemetry/api';
export function tagTenant(tenantId) {
trace.getActiveSpan()?.setAttribute('tenant.id', tenantId);
}
// pull the trace id for a log line
const traceId = trace.getActiveSpan()?.spanContext().traceId;getActiveSpan returns undefined when no SDK is registered or when the context manager lost the async chain, so always use optional chaining here. Putting traceId into your logs is the cheapest way to join logs to traces.
Run code inside a specific span's contextmanual-context
import { context, trace } from '@opentelemetry/api';
const span = tracer.startSpan('background-job');
const ctx = trace.setSpan(context.active(), span);
context.with(ctx, () => {
doWork(); // spans started here become children of span
});
span.end();Use this when startActiveSpan does not fit, such as a span whose lifetime crosses callbacks. In Node the async chain only survives if the SDK registered AsyncLocalStorageContextManager, which sdk-node does by default.
Inject and extract trace context over your own transportpropagate-across-services
import { context, propagation, trace } from '@opentelemetry/api';
// producer
const headers = {};
propagation.inject(context.active(), headers);
await queue.publish({ body, headers });
// consumer
const parentCtx = propagation.extract(context.active(), message.headers);
context.with(parentCtx, () => {
tracer.startActiveSpan('handle-message', (span) => { /* ... */ span.end(); });
});inject writes the W3C traceparent header by default. It is a no-op if no propagator is configured on the SDK side, so an empty carrier object usually means the SDK, not your code.
Set span kind and link to related tracesspan-kind-and-links
import { SpanKind, trace } from '@opentelemetry/api';
const span = tracer.startSpan('consume batch', {
kind: SpanKind.CONSUMER,
links: messages.map((m) => ({
context: trace.getSpanContext(propagation.extract(context.active(), m.headers)),
})).filter((l) => l.context),
});Links are how you model a batch consumer that serves many upstream traces, since a span can only have one parent. Kind drives service-map and latency views in most backends, so SERVER, CLIENT, PRODUCER, and CONSUMER are worth setting correctly.
Record a countercounter-metric
import { metrics } from '@opentelemetry/api';
const meter = metrics.getMeter('checkout-service', '1.4.0');
const charges = meter.createCounter('checkout.charges', {
description: 'Completed charge attempts',
unit: '{charge}',
});
charges.add(1, { provider: 'stripe', outcome: 'success' });Create instruments once at module scope, not per request: creating a counter on every call churns the SDK's instrument registry. Keep attribute values low-cardinality, because each distinct combination becomes its own time series.
Record a latency histogramhistogram-metric
const latency = meter.createHistogram('checkout.duration', {
unit: 'ms',
description: 'Time to complete a checkout',
});
const started = performance.now();
try {
await checkout();
} finally {
latency.record(performance.now() - started, { route: '/checkout' });
}Bucket boundaries are an SDK view concern, not an API one, so the defaults apply until someone configures a view. Never put a user id or order id in the attributes here.
Report a value that is read on collectionobservable-gauge
const gauge = meter.createObservableGauge('queue.depth');
gauge.addCallback((result) => {
result.observe(queue.size(), { queue: 'emails' });
});The callback runs on the SDK's collection interval, so keep it synchronous and cheap. Anything slow here delays the whole metric export cycle.
Carry a value across services with baggagebaggage
import { context, propagation } from '@opentelemetry/api';
const bag = propagation.createBaggage({ 'tenant.id': { value: tenantId } });
context.with(propagation.setBaggage(context.active(), bag), () => handle());
// downstream, after propagation.extract
const tenant = propagation.getBaggage(context.active())?.getEntry('tenant.id')?.value;Baggage travels in a plain HTTP header to every downstream service, including third parties you call. Never put anything sensitive in it, and it does not become a span attribute unless the SDK is configured with a baggage span processor.
Find out why no spans are appearingdebug-noop-api
import { diag, DiagConsoleLogger, DiagLogLevel, trace } from '@opentelemetry/api';
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
const tracer = trace.getTracer('debug');
console.log(tracer.constructor.name); // NoopTracer means no SDK registeredA NoopTracer means either no SDK was registered, the SDK was registered after your module was imported, or a duplicate incompatible @opentelemetry/api copy is in node_modules. Check with npm ls @opentelemetry/api before blaming your code.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @opentelemetry/sdk-node | npm | You are an application rather than a library, and need the piece that actually records and exports what the API describes. |
| dd-trace | npm | You are a Datadog shop and want auto-instrumentation, profiling, and runtime metrics from one package without assembling an SDK yourself. |
| @sentry/node | npm | Errors matter more to you than traces, and you want performance data as a side effect of the error tooling you were installing anyway. |