Skip to content

Node.js custom OpenTelemetry instrumentation

The auto-instrumentations capture framework and library activity, but your business logic is invisible until you instrument it. Use the OpenTelemetry API (@opentelemetry/api) to add spans around your own operations, attach attributes for filtering, and record custom metrics. The API is already installed as a dependency of @opentelemetry/sdk-node.

  • A service already initialized with the OpenTelemetry SDK — follow one of the framework guides first.
  • The SDK must be started before this code runs, so spans and metrics have a provider to record against.

Get a tracer once per module, then wrap an operation in startActiveSpan. The active span becomes the parent of any spans created inside the callback — including those from auto-instrumented libraries — so downstream HTTP and database calls nest correctly.

const { trace, context, SpanStatusCode } = require('@opentelemetry/api');
const tracer = trace.getTracer('checkout-api');
function processOrder(order) {
return tracer.startActiveSpan('processOrder', (span) => {
try {
span.setAttribute('order.id', order.id);
span.setAttribute('order.item_count', order.items.length);
span.addEvent('order.validated');
const result = chargeCard(order);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
throw err;
} finally {
span.end();
}
});
}
  • Attributes describe the operation and let you filter traces in Osuite. Prefer namespaced keys such as order.id.
  • Events (addEvent) mark points in time within a span.
  • Status — set SpanStatusCode.ERROR and call recordException on failure so the span is flagged in error tracking.
  • span.end() must always run. The finally block guarantees it even when the operation throws.

To create a child span without making it the active context, use startSpan and attach it manually:

function chargeCard(order) {
const span = tracer.startSpan('chargeCard');
return context.with(trace.setSpan(context.active(), span), () => {
try {
return { charged: order.total };
} finally {
span.end();
}
});
}

Get a meter, then create instruments once at module scope and record to them from your code. Attributes passed at record time become metric dimensions you can group by in Osuite.

const { metrics } = require('@opentelemetry/api');
const meter = metrics.getMeter('checkout-api');
const ordersProcessed = meter.createCounter('orders.processed', {
description: 'Number of orders processed',
unit: '{order}',
});
const orderValue = meter.createHistogram('order.value', {
description: 'Monetary value of each processed order',
unit: 'USD',
});
const ordersInFlight = meter.createUpDownCounter('orders.in_flight', {
description: 'Orders currently being processed',
unit: '{order}',
});
const queueDepth = meter.createObservableGauge('order.queue.depth', {
description: 'Current depth of the order queue',
unit: '{order}',
});
let currentQueueDepth = 0;
queueDepth.addCallback((result) => {
result.observe(currentQueueDepth, { queue: 'orders' });
});
function recordOrder(order) {
ordersInFlight.add(1, { 'order.type': order.type });
ordersProcessed.add(1, { 'order.type': order.type });
orderValue.record(order.total, { 'order.type': order.type });
ordersInFlight.add(-1, { 'order.type': order.type });
}

Choose the instrument by what you are measuring:

  • Counter — a value that only increases (requests, orders, bytes). Use add() with a positive delta.
  • UpDownCounter — a value that rises and falls (queue length, active connections, in-flight requests).
  • Histogram — a distribution you want percentiles over (request duration, payload size, order value).
  • Observable gauge — a point-in-time value read on each collection through a callback (pool size, temperature, current queue depth).

What you should see

Exercise the instrumented code, then open a trace in APM — your manual spans appear as named children with the attributes you set. Open Dashboards → Explore metrics and search for the instrument name (for example orders.processed); the series appears with your attribute keys available as group-by dimensions.

No data in Osuite after a few minutes? Work through these checks.

  • Endpoint — confirm the exporter targets exactly ingest.<region>.osuite.io:443 for your region, over TLS.
  • Token — confirm the ingest header carries a valid <your-ingest-token> and has not been rotated.
  • Pipeline — confirm the signal you expect (traces, logs, or metrics) is wired into an active pipeline with the otlp/osuite exporter attached.
  • Export errors — check the application or Collector logs for OTLP export failures (auth, DNS, TLS, connection refused).
  • Network — confirm the host has outbound access to the ingest endpoint on port 443.
  • Timing — allow up to a minute for the first data to appear before assuming a failure.

Still stuck? Ask the Investigation Agent or contact support.

If custom spans or metrics never appear, confirm the SDK started before this module ran — see Node.js troubleshooting.