DEV Community

Libme
Libme

Posted on

Instrument a Node.js App with OpenTelemetry in an Afternoon

You can get real distributed traces out of a Node.js service in an afternoon, and you should start with the zero-code auto-instrumentation before you write a single span by hand. The fast path is: install @opentelemetry/sdk-node plus the auto-instrumentations package, point an OTLP exporter at a collector or a backend, and launch your app with one extra --require flag. Custom spans come later, only where the automatic ones leave gaps.

That order matters. Most teams I've watched adopt OpenTelemetry (OTel) burn their first day hand-writing spans around functions that the auto-instrumentation would have covered for free. Below is the sequence that actually fits in an afternoon, with the tradeoffs I ran into.

What does OpenTelemetry actually give you?

OpenTelemetry is a vendor-neutral standard for generating telemetry — traces, metrics, and logs — plus SDKs that produce it and a wire format (OTLP) for shipping it. The value is that your instrumentation isn't tied to one observability vendor. You emit OTLP, and whatever speaks OTLP on the other end (an open-source collector, Jaeger, Grafana Tempo, or a commercial backend) can ingest it.

For a first pass, focus on traces. A trace is a tree of spans showing one request as it moves through your code: the incoming HTTP handler, the database query it triggered, the outbound API call it made. That's the signal that answers "why was this request slow?" — the question that usually drives adoption in the first place.

Metrics and logs are also part of OTel, but they're separate signals with their own exporters and maturity levels. Don't try to do all three in one afternoon.

Takeaway: OpenTelemetry decouples your instrumentation from your backend, and traces are the signal worth wiring up first.

How do you get traces without writing any code?

The auto-instrumentation library patches popular libraries — http, Express, pg, mysql2, Redis clients, gRPC, and dozens more — to emit spans automatically. You install two packages and set a couple of environment variables.

npm install @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/api
Enter fullscreen mode Exit fullscreen mode

Then run your app with the register hook, which loads and starts the SDK before your code:

export OTEL_SERVICE_NAME=checkout-api
export OTEL_TRACES_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
node --require @opentelemetry/auto-instrumentations-node/register app.js
Enter fullscreen mode Exit fullscreen mode

That's it for the zero-code path. If your app talks HTTP and hits a database, you'll start seeing spans for both once traffic flows. The OTEL_EXPORTER_OTLP_ENDPOINT here points at the OTLP/HTTP port (4318); OTLP/gRPC uses 4317. OTEL_SERVICE_NAME is the single most important attribute to set — without it your service shows up under an "unknown_service" label that makes traces useless to filter.

The catch: auto-instrumentation loads everything. In my testing, that added measurable startup time and pulled in instrumentation for libraries I wasn't using. Once you know which integrations you need, you can disable the rest — either by only registering specific instrumentation packages, or by passing a config to getNodeAutoInstrumentations() (next section).

Takeaway: One --require flag and two env vars get you database and HTTP spans with no code changes.

When should you write a setup file instead of the flag?

The register-flag approach is great for a demo, but for a real service you'll want an explicit setup file so you control the exporter, resource attributes, and which instrumentations load. Create instrumentation.js:

const { NodeSDK } = require('@opentelemetry/sdk-node');
const {
  getNodeAutoInstrumentations,
} = require('@opentelemetry/auto-instrumentations-node');
const {
  OTLPTraceExporter,
} = require('@opentelemetry/exporter-trace-otlp-proto');

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: 'http://localhost:4318/v1/traces',
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      // fs spans are noisy; turn them off
      '@opentelemetry/instrumentation-fs': { enabled: false },
    }),
  ],
});

sdk.start();

process.on('SIGTERM', () => {
  sdk.shutdown().finally(() => process.exit(0));
});
Enter fullscreen mode Exit fullscreen mode

Load it before anything else, either with node --require ./instrumentation.js app.js or by putting the require('./instrumentation') as the very first line of your entry file. The ordering is not optional — the SDK patches modules at require time, so if your app imports Express before the SDK starts, those spans silently never appear. This is the single most common "why do I see nothing?" problem, and it's easy to hit with ES modules where import hoisting fights you.

The sdk.shutdown() on SIGTERM matters in containers: exporters batch spans and flush on an interval, so an abrupt exit drops the last batch. Flushing on shutdown is what keeps you from losing the traces around a crash or a deploy.

Takeaway: An explicit setup file loaded first — with a shutdown flush — is the difference between "works in a demo" and "works in production."

How do you add spans for your own business logic?

Auto-instrumentation covers I/O boundaries, but it can't see inside your functions. When you want to know how long "apply discount rules" took inside a request, add a manual span with the API package you already installed:

const { trace, SpanStatusCode } = require('@opentelemetry/api');

const tracer = trace.getTracer('checkout-api');

async function applyDiscounts(cart) {
  return tracer.startActiveSpan('applyDiscounts', async (span) => {
    try {
      span.setAttribute('cart.item_count', cart.items.length);
      const result = await runRules(cart);
      span.setAttribute('discount.applied', result.total);
      return result;
    } catch (err) {
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw err;
    } finally {
      span.end();
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

startActiveSpan makes this span the parent of anything that runs inside the callback, so a database query fired by runRules nests under applyDiscounts automatically. Two habits worth forming early: always span.end() in a finally, and record exceptions plus set an error status so failed spans are visible in your backend. A span you forget to end leaks and never exports.

Resist the urge to wrap every function. Spans have overhead and cost money in most backends. Instrument the handful of operations you'd actually want to see broken out — the ones with real latency or real failure modes.

Takeaway: Add manual spans only around meaningful business operations, and always end them and mark errors.

Do you need the Collector, or can you export directly?

You can export straight from your app to a backend that accepts OTLP, and for a first afternoon that's fine. But the OpenTelemetry Collector — a standalone process that receives, processes, and forwards telemetry — earns its place quickly. It lets you change backends, add batching and retry, drop or scrub attributes (say, a token that leaked into a URL), and sample, all without redeploying your app.

Approach Setup effort Best for
Direct OTLP from app Lowest — one env var First traces, local dev, single service
App → Collector → backend One extra container Multiple services, attribute scrubbing, switching vendors
Collector as agent + gateway Highest Larger fleets, central sampling and routing

A minimal collector config that receives OTLP and logs it to the console for debugging looks like this:

receivers:
  otlp:
    protocols:
      http:
      grpc:
exporters:
  debug:
    verbosity: detailed
service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [debug]
Enter fullscreen mode Exit fullscreen mode

Point your app's OTEL_EXPORTER_OTLP_ENDPOINT at the collector, run the collector, and you'll see spans printed as they arrive — a fast way to confirm your app is actually emitting before you wire up a real backend.

Takeaway: Skip the Collector for your very first trace, but adopt it the moment you have more than one service or need to scrub or sample.

Bottom line

If you have a Node.js service and an afternoon, start with the zero-code path: install the SDK and auto-instrumentations, set OTEL_SERVICE_NAME and an OTLP endpoint, and launch with the register flag. Once you see HTTP and database spans, graduate to an explicit instrumentation.js so you control the exporter and flush on shutdown. Add manual spans only around the business logic you genuinely need to see. Bring in the Collector when you have more than one service or need to sample and scrub. The trap to avoid is hand-writing spans on day one — the automatic ones cover most of what you need, and load order, not span coverage, is what usually goes wrong first.

Related reading

Top comments (0)