Weaving Observability Traces and Logs in Node.js
Olivia Novak
Dev Intern · Leapcell

Introduction
In modern distributed systems, understanding application behavior and quickly diagnosing issues is paramount. As applications scale and become more complex, relying solely on traditional logging or standalone tracing tools falls short. We need a holistic view where application logs are intrinsically linked to the underlying operational context – the execution path, component interactions, and timing information provided by traces. This is where connecting your logging framework with your tracing system becomes incredibly valuable.
In the Node.js ecosystem, Pino stands out as a high-performance logger, while OpenTelemetry has emerged as the industry standard for collecting telemetry data, including traces. Bridging the gap between Pino and OpenTelemetry allows us to enrich our logs with trace context, making them significantly more powerful for debugging, performance analysis, and overall system comprehension. This article will guide you through the process of establishing this crucial connection, ultimately enhancing your application's observability.
The Pillars of Observability
Before diving into the implementation details, let's briefly define the core concepts we'll be working with:
- Observability: The ability to infer the internal state of a system by examining its external outputs. It's about asking arbitrary questions about your system and getting answers from the data it provides (logs, metrics, traces).
- Logs: Discrete, timestamped records of events that occur within an application. They provide detailed textual information about what happened, when it happened, and sometimes why. Pino is an excellent choice for structured, high-performance logging in Node.js.
- Traces: Represent the end-to-end journey of a request or transaction through a distributed system. A trace is composed of one or more spans, where each span represents a logical unit of work (e.g., a function call, a database query, an API request). Traces provide context, causality, and latency information across service boundaries. OpenTelemetry offers a vendor-agnostic standard for generating and collecting traces.
- Trace Context: Information that identifies a trace and its current span. This context is propagated across process and service boundaries, allowing individual spans to be linked together to form a complete trace. Key components include
traceId
andspanId
.
The goal is to infuse our Pino logs with this trace context, making it effortless to navigate from a problematic log entry directly to the relevant trace that shows its entire execution path.
Connecting Pino Logs to OpenTelemetry Traces
The principle behind integrating Pino with OpenTelemetry is straightforward: wherever a log is emitted, we want to include the current OpenTelemetry traceId
and spanId
in the log record. This establishes a direct correlation.
Step 1: Set up OpenTelemetry
First, ensure your application is instrumented with OpenTelemetry. This involves installing the necessary SDKs and auto-instrumentation packages.
npm install @opentelemetry/sdk-node @opentelemetry/api @opentelemetry/auto-instrumentations-node npm install @opentelemetry/exporter-collector --save-dev # Or another exporter like OTLP gRPC
Then, initialize OpenTelemetry at the entry point of your application (e.g., src/instrumentation.js
or src/tracing.js
).
// src/tracing.js import { NodeSDK } from '@opentelemetry/sdk-node'; import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base'; import { Resource } from '@opentelemetry/resources'; import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; import { PeriodicExportingSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'; // Example OTLP exporter const sdk = new NodeSDK({ resource: new Resource({ [SemanticResourceAttributes.SERVICE_NAME]: 'my-nodejs-app', [SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0', }), spanProcessor: new PeriodicExportingSpanProcessor(new OTLPTraceExporter()), // Or new ConsoleSpanExporter() for development instrumentations: [ new HttpInstrumentation(), new ExpressInstrumentation(), // Add other relevant instrumentations like @opentelemetry/instrumentation-pg for PostgreSQL ], }); sdk.start(); console.log('OpenTelemetry tracing initialized.'); // To ensure traces are flushed on shutdown process.on('SIGTERM', () => { sdk.shutdown() .then(() => console.log('Tracing terminated')) .catch((error) => console.log('Error terminating tracing', error)) .finally(() => process.exit(0)); });
Make sure to run your application with this instrumentation enabled:
node -r ./src/tracing.js src/index.js
Step 2: Integrate Trace Context into Pino
Pino provides a powerful feature called "bindings" or "child loggers" that allows you to add contextual properties to log records. We can leverage the trace.getSpanContext()
method from @opentelemetry/api
to retrieve the current trace and span IDs.
First, install Pino:
npm install pino
Now, create your Pino logger instance. The key is to use a pino-caller
or a custom function to inject the trace context. A convenient way is to create a child logger for each request or operation where context is available.
Let's look at an example using Express.js:
// src/index.js (after OpenTelemetry instrumentation) import express from 'express'; import pino from 'pino'; import { trace } from '@opentelemetry/api'; const app = express(); const port = 3000; // Create a base Pino logger const logger = pino({ level: process.env.LOG_LEVEL || 'info', formatters: { // This formatter ensures correlation IDs are at the top level log: (obj) => { if (obj.traceId && obj.spanId) { return { traceId: obj.traceId, spanId: obj.spanId, ...obj }; } return obj; }, }, }); // Middleware to inject OpenTelemetry trace context into the logger app.use((req, res, next) => { const currentSpan = trace.getSpan(trace.activeContext()); if (currentSpan) { const spanContext = currentSpan.spanContext(); if (spanContext.traceId && spanContext.spanId) { // Create a child logger with trace and span IDs req.log = logger.child({ traceId: spanContext.traceId, spanId: spanContext.spanId, }); req.log.debug('Request started'); // Example log } else { req.log = logger; // No trace context, use base logger } } else { req.log = logger; // No active span, use base logger } next(); }); app.get('/', (req, res) => { req.log.info('Handling root request'); // Simulate some work that might generate another span const myService = { doSomething: (log) => { log.debug('Doing something important in service'); // Here you could potentially create a new span if this was a separate module/function // const newSpan = tracer.startSpan('myService.doSomething'); // newSpan.end(); } }; myService.doSomething(req.log); res.send('Hello from Node.js with correlated logs!'); }); app.get('/error', (req, res) => { req.log.error('An error occurred during request processing!'); res.status(500).send('Something went wrong!'); }); app.listen(port, () => { logger.info(`App listening at http://localhost:${port}`); });
In this example:
- We create a base
pino
logger. - A middleware intercepts incoming requests.
- Inside the middleware,
trace.getSpan(trace.activeContext())
is used to get the currently active OpenTelemetry span. - If a span exists, its
traceId
andspanId
are extracted. - A
child
logger is created usinglogger.child({ traceId, spanId })
. This child logger automatically includes these properties in every log it produces. req.log
is then assigned this child logger, making it accessible throughout the request handling chain.
Now, when you make a request to /
, your console (or log destination) will show logs similar to this (formatted for readability):
{ "traceId": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "spanId": "f7e8d9c0b1a2e3f4", "level": 30, "time": 1678886400000, "pid": 12345, "hostname": "my-host", "msg": "Request started" } { "traceId": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "spanId": "f7e8d9c0b1a2e3f4", "level": 30, "time": 1678886400000, "pid": 12345, "hostname": "my-host", "msg": "Handling root request" } { "traceId": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "spanId": "f7e8d9c0b1a2e3f4", "level": 20, "time": 1678886400000, "pid": 12345, "hostname": "my-host", "msg": "Doing something important in service" }
Notice how traceId
and spanId
are consistently present in all logs related to the same request. This strong correlation is what enables powerful observability.
Application and Benefits
With this setup, you gain several benefits:
- Faster Root Cause Analysis: When an error log appears, you can immediately use its
traceId
to retrieve the full trace, seeing all operations, services, and timing involved in that request. This significantly reduces the time spent sifting through unrelated logs. - Context-Rich Logs: Every log entry has the context of the operation it belongs to, even if that operation spans multiple services.
- Improved Debugging: You can filter logs by
traceId
in your log management system to see only the logs relevant to a specific user interaction or process, providing a sequential narrative of events. - Performance Bottleneck Identification: By correlating logs with trace timings, you can pinpoint exactly which part of your code or which external call is taking too long, and then zoom into its detailed logs if necessary.
- Unified Observability Experience: Log management systems (like Elastic Stack, Splunk, Sumo Logic, Datadog) and APM tools (like Jaeger, Zipkin, Datadog APM, New Relic) can leverage these correlation IDs to display logs alongside traces, offering a truly unified view of your application's health and performance.
Conclusion
Integrating Pino logs with OpenTelemetry traces is a fundamental step towards achieving comprehensive observability in your Node.js applications. By embedding trace and span IDs directly into your log records, you transform disparate telemetry signals into a powerful, interconnected narrative of your system's behavior. This correlation streamlines debugging, accelerates root cause analysis, and provides a much clearer picture of your application's operational state. Embracing this practice moves your observability strategy from fragmented insights to a truly coherent understanding of your distributed systems.