Configuration¶
Installation¶
npm install @saidsef/tracing-node --save
The package is ESM only and declares "type": "module". It needs Node 24.0.0 or later.
Initialisation order¶
Instrumentation patches modules as they load, which puts a hard constraint on when setupTracing runs: before the application requires or imports the libraries being traced. Call it after Express or IORedis has loaded and those modules stay unpatched. They produce no spans.
Each module system is patched by a different mechanism. require-in-the-middle handles a CommonJS module as each require runs. An ES module needs a loader hook, which this library registers on first import. That hook reaches only the modules loaded after it. Node loads a whole module graph before evaluating any of it, so an instrumented package imported statically alongside the library has already loaded by the time the hook registers.
Preload¶
// instrument.mjs
import {setupTracing} from '@saidsef/tracing-node';
setupTracing();
node --import ./instrument.mjs ./app.mjs
--import runs the module to completion before the application entry point loads, covering both module systems. This is the recommended form. The end to end harness uses it.
Dynamic import¶
Where a preload is impractical, the entry point imports the library statically and the application dynamically. The application then loads with the hook already in place.
import {setupTracing} from '@saidsef/tracing-node';
setupTracing({serviceName: 'my-service', url: 'http://alloy:4317'});
const {default: app} = await import('./app.mjs');
Static imports in a single file never work for this, whatever order they are written in.
import {setupTracing} from '@saidsef/tracing-node';
import express from 'express'; // loaded before the hook registers, so never patched
ESM loader hook¶
The hook is import-in-the-middle, registered ahead of every instrumentation when the library is imported. The library registers it through module.registerHooks(). Node releases before 24.11.1 cannot run synchronous hooks, and on those the library uses module.register() instead. Set TRACING_NODE_ESM_HOOK to false or 0 to skip registration. That suits an application registering import-in-the-middle itself, through @opentelemetry/auto-instrumentations-node/register for instance, where registering the hook twice risks patching a module twice.
A registration failure becomes a warning on the diagnostic logger. CommonJS patching carries on working.
Options¶
setupTracing({
hostname: 'pod-abc123',
serviceName: 'my-service',
url: 'http://alloy:4317',
concurrencyLimit: 10,
enableFsInstrumentation: false,
enableDnsInstrumentation: false,
enableMetrics: true,
metricsUrl: 'http://alloy:4317',
metricExportIntervalMillis: 60000,
enableLogs: true,
logsUrl: 'http://alloy:4317',
});
| Option | Type | Description | Required | Default |
|---|---|---|---|---|
hostname |
string | Container or pod hostname, recorded as container.name |
No | CONTAINER_NAME, then HOSTNAME |
serviceName |
string | Service name, recorded as service.name |
Yes | SERVICE_NAME |
url |
string | Collector endpoint, <scheme>://<host>:<port> |
Yes | ENDPOINT |
concurrencyLimit |
number | Concurrent exports the exporter allows | No | 10 |
enableFsInstrumentation |
boolean | Enable file system instrumentation | No | false |
enableDnsInstrumentation |
boolean | Enable DNS instrumentation | No | false |
enableMetrics |
boolean | Register a meter provider and export metrics | No | true |
metricsUrl |
string | Metrics endpoint, when it differs from the trace endpoint | No | url |
metricExportIntervalMillis |
number | Interval between metric exports | No | 60000 |
enableLogs |
boolean | Register a logger provider and export Pino log records | No | true |
logsUrl |
string | Logs endpoint, when it differs from the trace endpoint | No | url |
setupTracing throws Error: serviceName is required or Error: url is required when neither the option nor its environment variable supplies a value.
Environment variables¶
| Variable | Maps to | Required |
|---|---|---|
SERVICE_NAME |
serviceName |
Yes, unless the option is passed |
ENDPOINT |
url |
Yes, unless the option is passed |
CONTAINER_NAME |
hostname |
No |
HOSTNAME |
hostname, when CONTAINER_NAME is unset |
No |
TRACING_NODE_ESM_HOOK |
ESM loader hook registration, skipped on false or 0 |
No |
An option passed to setupTracing beats the matching environment variable. The environment resource detector reads OTEL_RESOURCE_ATTRIBUTES, and the explicit service name overrides any service.name it carries.
Metrics¶
The library exports metrics by default, over OTLP gRPC, to the same endpoint as traces. An OpenTelemetry Collector or Grafana Alloy accepts every signal on port 4317, so one endpoint covers both. Point metricsUrl somewhere else where the trace endpoint takes traces alone, a Tempo OTLP receiver for instance. Set enableMetrics to false where metrics are not wanted at all.
Aggregation temporality is cumulative, which Prometheus and Mimir both expect. OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE overrides it.
The export interval is 60 seconds. A shorter interval raises resolution and the volume written to the backend in equal measure.
Sampling never reaches the metrics, so OTEL_TRACES_SAMPLER can be turned down without losing accuracy. Metrics covers why.
Logs¶
The library exports Pino log records by default, over OTLP gRPC, to the same endpoint as traces. Each record carries the trace id and span id of the request that wrote it, and Grafana needs both to link a log line to its trace. The application keeps writing to its own stream, so container logs are unchanged.
Setting enableLogs to false disables log sending at the instrumentation itself. Each record is then never built in the first place, instead of being built and discarded. Logs covers that path.
Point logsUrl somewhere else where the trace endpoint does not accept logs.
Log export covers Pino alone. These options do nothing for an application logging through anything else.
Optional instrumentations¶
enableFsInstrumentation and enableDnsInstrumentation are off by default. Both instrumentations patch on construction, so the library constructs each one only when its option is set. File system tracing produces a large number of spans and earns its keep only while file access is under investigation. Instrumentation covers what each emits.
Using the returned tracer¶
setupTracing returns a tracer for the service. Use it for manual spans over work no instrumentation covers.
const tracer = setupTracing({serviceName: 'my-service', url: 'http://alloy:4317'});
await tracer.startActiveSpan('reconcile', async (span) => {
try {
await reconcile();
} finally {
span.end();
}
});
A span created this way becomes a child of whatever span is active in the current context. A manual span inside a request handler joins that request's trace.
Shutdown¶
import {setupTracing, stopTracing} from '@saidsef/tracing-node';
process.on('SIGTERM', async () => {
await stopTracing();
process.exit(0);
});
stopTracing awaits three shutdowns in turn. The tracer provider flushes the batch span processor, the meter provider flushes a final metric export, and the logger provider flushes queued log records. Each is awaited separately, which leaves the other two signals free to flush when one exporter fails. The providers are then cleared, so a later setupTracing call builds a fresh pipeline.
Called when tracing was never initialised, stopTracing logs a warning and returns. A failed shutdown produces an error on the log rather than a thrown exception.
Spans and log records are batched, and metrics are exported on an interval. A process that exits without this loses whatever is still queued.
Repeated initialisation¶
A second setupTracing call logs Tracing is already initialized. Returning existing tracer. and returns a tracer from the existing provider. It ignores the options passed to it, serviceName excepted, which names the returned tracer.