Introduction
Most logging decisions are made in isolation. An engineer initializes a new Node.js service, reaches for Pino or Winston based on familiarity or a quick benchmark, and moves on. This works fine until the organization decides to invest seriously in observability - and suddenly that isolated logging choice has consequences at the infrastructure layer. Log lines have no trace context. The metrics pipeline and the logging pipeline are strangers. Correlating a slow request from a distributed trace back to the application log that explains why it was slow requires manual cross-referencing that no amount of dashboard tuning can fully eliminate.
OpenTelemetry (OTel) was designed precisely to dissolve this boundary. Its telemetry model - the three pillars of traces, metrics, and logs unified under a single SDK, a single propagation protocol, and a single wire format - promises something genuinely valuable: a log record that carries the trace context of the span that emitted it, automatically, without any developer intervention at the call site. When you are already using OTel for distributed tracing and metrics, the question of what to do about logs becomes unavoidable.
This article addresses that decision directly. It examines three viable strategies for Node.js teams with an existing or planned OTel investment: using the OTel Logs SDK natively as your logging API; continuing with Pino as your application logger while enriching it with trace context via OTel instrumentation; and bridging Pino's output into the OTel pipeline so you get Pino's ergonomics and OTel's transport guarantees simultaneously. These are not equally right for every team, and the answer depends on where you are in your OTel adoption journey, what your performance requirements are, and how much complexity you are prepared to carry in your logging infrastructure.
The Problem: Logs Live Outside the Telemetry Graph
To understand why this decision matters, it helps to think about what makes distributed tracing genuinely useful. When a request enters your system, OTel's SDK generates a trace ID and span IDs, propagates them through HTTP headers and message queue metadata, and records timing, attributes, and events at each hop. The result is a complete causal graph of the request's journey. This is the artifact you reach for during an incident - not logs.
But traces tell you what happened in broad structural terms. Logs tell you why. A span might show that a database query took 4.2 seconds, but the log line from the repository layer - "connection pool exhausted, waited 3.8s for available slot" - tells you what to fix. The problem is that without correlation, finding that log line requires guessing time windows, filtering by service name, and hoping the timestamp alignment is accurate enough. With correlation, you navigate directly from the slow span to the log records emitted during its execution.
The OTel Logs specification solves this by defining traceId and spanId as first-class fields on a LogRecord, and by having the SDK automatically populate them from the active span context at the moment of emission. This is not something you configure per call site - it happens at the SDK layer, transparently. For teams that have already instrumented their services with OTel traces, this automatic correlation is the primary argument for routing logs through OTel rather than a separate pipeline. The secondary argument is operational simplicity: one exporter, one collector pipeline, one backend configuration, rather than separate pipelines for each telemetry signal.
Understanding the OTel Logs Signal
How OTel Models Log Records
The OpenTelemetry Logs specification defines a LogRecord as a structured data type with a set of standard fields: Timestamp, ObservedTimestamp, TraceId, SpanId, TraceFlags, SeverityNumber, SeverityText, Body, Resource, and Attributes. The SeverityNumber is a numeric scale from 1 (TRACE) to 24 (FATAL4), mapped to familiar named levels. Body is the human-readable message. Attributes is a key-value map for structured context. Resource describes the emitting entity - service name, version, deployment environment - and is shared across all signals from a given SDK instance.
Crucially, TraceId and SpanId are populated by the SDK from the active OTel context at the moment emit() is called. If a span is active on the current execution context - because an HTTP instrumentation library opened one for the incoming request - the log record automatically carries that span's identity. The developer writing the log call does not need to thread context manually. This is the architectural payoff of instrumenting your traces with OTel first: the logging correlation comes for free.
The Logs SDK in Node.js
The OTel JavaScript SDK splits the Logs signal across two packages that mirror the pattern established by the Traces and Metrics signals. The @opentelemetry/api-logs package defines the stable public API: LoggerProvider, Logger, and the emit() method. Application code depends only on this package. The @opentelemetry/sdk-logs package provides the implementation: LoggerProvider initialization, LogRecordProcessor pipeline, and LogRecordExporter integration. This separation means that library authors can emit logs against the API without coupling their packages to a specific SDK version, and application operators control the pipeline configuration at startup.
Exporters ship log records over OTLP - the OpenTelemetry Protocol - to a collector or backend. The @opentelemetry/exporter-logs-otlp-http and @opentelemetry/exporter-logs-otlp-grpc packages handle OTLP/HTTP and OTLP/gRPC respectively. A BatchLogRecordProcessor buffers records and exports them in batches, decoupling the application thread from the network I/O of the export operation in the same way that Pino's worker-thread transport decouples serialization from the application thread.
// src/instrumentation/logs.ts
// Initialize the OTel Logs SDK - call this once at process startup,
// before any application code runs.
import { LoggerProvider, BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { logs } from '@opentelemetry/api-logs';
export function initLogging(): LoggerProvider {
const resource = new Resource({
[SEMRESATTRS_SERVICE_NAME]: process.env.SERVICE_NAME ?? 'api',
[SEMRESATTRS_SERVICE_VERSION]: process.env.APP_VERSION ?? 'unknown',
'deployment.environment': process.env.NODE_ENV ?? 'development',
});
const exporter = new OTLPLogExporter({
url: process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ?? 'http://localhost:4318/v1/logs',
headers: {},
});
const provider = new LoggerProvider({ resource });
provider.addLogRecordProcessor(
new BatchLogRecordProcessor(exporter, {
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
exportTimeoutMillis: 30_000,
maxQueueSize: 2048,
})
);
// Register as the global LoggerProvider - api-logs consumers will use this
logs.setGlobalLoggerProvider(provider);
return provider;
}
Emitting Logs via the OTel API
With the provider initialized, application code acquires a named logger from the global logs API and calls emit(). The SeverityNumber and SeverityText fields must be set explicitly - there is no convenience method for logger.error() shorthand in the bare OTel API. This verbosity is intentional: the OTel API is a foundation, not a developer ergonomics layer.
// src/lib/otelLogger.ts
// A thin wrapper around @opentelemetry/api-logs that provides
// a familiar logger.info() / logger.error() interface.
import { logs, SeverityNumber, Logger as OtelLogger } from '@opentelemetry/api-logs';
const SEVERITY_MAP: Record<string, { number: SeverityNumber; text: string }> = {
trace: { number: SeverityNumber.TRACE, text: 'TRACE' },
debug: { number: SeverityNumber.DEBUG, text: 'DEBUG' },
info: { number: SeverityNumber.INFO, text: 'INFO' },
warn: { number: SeverityNumber.WARN, text: 'WARN' },
error: { number: SeverityNumber.ERROR, text: 'ERROR' },
fatal: { number: SeverityNumber.FATAL, text: 'FATAL' },
};
type LogLevel = keyof typeof SEVERITY_MAP;
type Attributes = Record<string, string | number | boolean>;
export class Logger {
private readonly otelLogger: OtelLogger;
constructor(name: string, private readonly defaultAttributes: Attributes = {}) {
this.otelLogger = logs.getLogger(name, process.env.APP_VERSION);
}
private emit(level: LogLevel, message: string, attributes: Attributes = {}): void {
const severity = SEVERITY_MAP[level];
this.otelLogger.emit({
severityNumber: severity.number,
severityText: severity.text,
body: message,
// TraceId and SpanId are injected automatically by the SDK
// from the currently active span context - no manual work required
attributes: { ...this.defaultAttributes, ...attributes },
});
}
trace(message: string, attributes?: Attributes): void { this.emit('trace', message, attributes); }
debug(message: string, attributes?: Attributes): void { this.emit('debug', message, attributes); }
info(message: string, attributes?: Attributes): void { this.emit('info', message, attributes); }
warn(message: string, attributes?: Attributes): void { this.emit('warn', message, attributes); }
error(message: string, attributes?: Attributes): void { this.emit('error', message, attributes); }
fatal(message: string, attributes?: Attributes): void { this.emit('fatal', message, attributes); }
child(additionalAttributes: Attributes): Logger {
return new Logger(
this.otelLogger.toString(),
{ ...this.defaultAttributes, ...additionalAttributes }
);
}
}
// Usage:
// const logger = new Logger('order-service');
// logger.info('Order created', { orderId: '123', tenantId: 'acme' });
// -> Emits OTel LogRecord with traceId/spanId from active span - automatically
Pino Standalone with OTel Context Injection
Why You Might Stay with Pino
For teams already running Pino in production, migrating to the OTel Logs API introduces risk and operational disruption with uncertain upside. Pino's worker-thread transport architecture, its ecosystem of community transports, and its developer ergonomics are proven and stable. The OTel Logs SDK in JavaScript, while stable per the specification, has a shorter track record in production environments and a smaller community ecosystem than Pino. For services where logging volume is high and latency is a hard requirement, Pino's benchmarked throughput is a concrete advantage.
The pragmatic answer for many teams is: keep Pino as the application logging API, but inject OTel trace context into Pino log records automatically. This way, every log line carries the traceId and spanId of the active span without routing the logs through the OTel Logs SDK pipeline. You retain Pino's ergonomics, performance, and transport flexibility, and you gain the ability to correlate logs with traces in your backend - provided your log aggregation platform understands the field names and can link them to your trace data.
Automatic Context Injection via Instrumentation
The @opentelemetry/instrumentation-pino package, maintained in the opentelemetry-js-contrib repository, patches Pino at the instrumentation layer to automatically add trace_id, span_id, and trace_flags to every log record emitted while a span is active. No changes to call sites are required. The instrumentation hooks into Pino's logger creation and mixin system to inject the active span context at emit time.
// src/instrumentation/index.ts
// OTel SDK initialization - this file must be required first,
// before any other imports, typically via --require in the node command.
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express';
import { Resource } from '@opentelemetry/resources';
import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: process.env.SERVICE_NAME ?? 'api',
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? 'http://localhost:4318/v1/traces',
}),
instrumentations: [
new HttpInstrumentation(),
new ExpressInstrumentation(),
// PinoInstrumentation patches Pino to inject trace context into log records
new PinoInstrumentation({
// Optional: customize the field names used for trace context
// Default field names follow W3C trace context naming conventions
logKeys: {
traceId: 'trace_id',
spanId: 'span_id',
traceFlags: 'trace_flags',
},
}),
],
});
sdk.start();
// Graceful shutdown: flush pending spans and exports on process exit
process.on('SIGTERM', async () => {
await sdk.shutdown();
process.exit(0);
});
// src/lib/logger.ts - plain Pino setup; no OTel-specific code here
// The PinoInstrumentation handles context injection transparently.
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL ?? (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
base: {
service: process.env.SERVICE_NAME ?? 'api',
version: process.env.APP_VERSION ?? 'unknown',
},
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'],
censor: '[REDACTED]',
},
});
// In production, a log record emitted inside an active OTel span looks like:
// {
// "level": 30,
// "time": 1718012345678,
// "service": "api",
// "msg": "Order created",
// "orderId": "abc-123",
// "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", <- injected by PinoInstrumentation
// "span_id": "00f067aa0ba902b7", <- injected by PinoInstrumentation
// "trace_flags": "01" <- injected by PinoInstrumentation
// }
The important caveat with this approach is that the logs remain in Pino's pipeline. They go wherever your Pino transports send them - stdout, a file, a log aggregation agent - not through the OTel Logs SDK. The trace context fields are present in the log records, and your backend (Grafana, Datadog, Elasticsearch) can use them to cross-reference spans and logs, but the delivery mechanism is separate from your OTel trace and metrics pipeline.
The Bridge Pattern: Pino API, OTel Transport
Routing Pino Output Through the OTel Logs SDK
The most architecturally complete option - and the most complex to set up - is using Pino as your application logging API while routing its output through the OTel Logs SDK pipeline. The pino-opentelemetry-transport package implements this pattern as a Pino transport that runs in a worker thread, deserializes Pino's JSON output, maps it to OTel LogRecord fields, and emits the records through an OTLPLogExporter. The result is that your application code uses the familiar logger.info() / logger.error() Pino API, but the actual records delivered to your backend are OTel-native LogRecord structures - complete with automatic trace context correlation, OTel-standard severity numbers, and unified resource attributes.
This pattern gives you the best of both worlds from an operational standpoint: Pino handles serialization and worker-thread buffering with its proven performance characteristics, and the OTel pipeline handles export, batching, and backend compatibility. The log records arrive at your OTel backend with the same TraceId and SpanId as your trace spans, enabling first-class trace-to-log navigation without any field-name mapping configuration.
// src/lib/logger.ts - Pino with pino-opentelemetry-transport
import pino from 'pino';
const isProduction = process.env.NODE_ENV === 'production';
export const logger = pino(
{
level: process.env.LOG_LEVEL ?? (isProduction ? 'info' : 'debug'),
base: {
service: process.env.SERVICE_NAME ?? 'api',
version: process.env.APP_VERSION ?? 'unknown',
},
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', '*.password'],
censor: '[REDACTED]',
},
},
isProduction
? pino.transport({
targets: [
// Primary: ship logs through OTel pipeline in production
{
target: 'pino-opentelemetry-transport',
level: 'info',
options: {
// Connects to the OTel Collector running as a sidecar or DaemonSet
resourceAttributes: {
'service.name': process.env.SERVICE_NAME ?? 'api',
'service.version': process.env.APP_VERSION ?? 'unknown',
'deployment.environment': process.env.NODE_ENV ?? 'production',
},
},
},
// Secondary: also write errors to local file as a fallback
{
target: 'pino/file',
level: 'error',
options: { destination: './logs/errors.log', mkdir: true },
},
],
})
: pino.transport({
target: 'pino-pretty',
options: { colorize: true, translateTime: 'SYS:HH:MM:ss' },
})
);
Correlating Spans and Logs in the Bridge Pattern
When pino-opentelemetry-transport routes a log record through the OTel Logs SDK, it reads the active span context from the OTel context propagation mechanism - provided the OTel SDK is initialized - and populates traceId and spanId on the LogRecord. This means you need both the OTel SDK (for trace instrumentation and context propagation) and Pino (for the logging API) initialized at startup, and the transport running in its worker thread.
The startup sequence matters: the OTel SDK must initialize before any instrumented code runs, and the Pino transport must be configured to hand off to the OTel Logs SDK. The recommended pattern is to initialize the SDK in a dedicated instrumentation file loaded via the Node.js --require flag or the newer --import flag for ESM, ensuring it runs before the application entry point.
// src/instrumentation/index.ts - unified SDK init (traces + logs)
import { NodeSDK } from '@opentelemetry/sdk-node';
import { LoggerProvider, BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express';
import { Resource } from '@opentelemetry/resources';
import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { logs } from '@opentelemetry/api-logs';
const resource = new Resource({
[SEMRESATTRS_SERVICE_NAME]: process.env.SERVICE_NAME ?? 'api',
[SEMRESATTRS_SERVICE_VERSION]: process.env.APP_VERSION ?? 'unknown',
'deployment.environment': process.env.NODE_ENV ?? 'development',
});
// Configure the Logs SDK pipeline used by pino-opentelemetry-transport
const loggerProvider = new LoggerProvider({ resource });
loggerProvider.addLogRecordProcessor(
new BatchLogRecordProcessor(
new OTLPLogExporter({
url: process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ?? 'http://localhost:4318/v1/logs',
})
)
);
logs.setGlobalLoggerProvider(loggerProvider);
// Configure the Traces SDK pipeline for HTTP and Express instrumentation
const sdk = new NodeSDK({
resource,
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? 'http://localhost:4318/v1/traces',
}),
instrumentations: [
new HttpInstrumentation(),
new ExpressInstrumentation(),
],
});
sdk.start();
process.on('SIGTERM', async () => {
try {
await sdk.shutdown();
await loggerProvider.shutdown();
} finally {
process.exit(0);
}
});
// package.json (relevant excerpt)
// The --require flag ensures instrumentation runs first,
// before any application module is loaded.
{
"scripts": {
"start": "node --require ./dist/instrumentation/index.js ./dist/server.js",
"start:esm": "node --import ./dist/instrumentation/index.js ./dist/server.js"
}
}
Comparing the Three Approaches
The three strategies represent genuinely different positions on the spectrum between coupling and flexibility. Understanding their trade-offs requires separating concerns: developer ergonomics, operational complexity, performance, trace-log correlation quality, and migration risk.
Pure OTel Logs maximizes pipeline consistency. A single OTLP exporter handles traces, metrics, and logs; the OTel Collector does all routing, filtering, sampling, and backend fan-out; and the log records are semantically identical to what the OTel specification defines. The cost is developer ergonomics: the bare @opentelemetry/api-logs API is verbose and not designed as a direct application logging interface. Teams typically build a thin wrapper (as shown earlier), but this wrapper carries maintenance overhead and lacks the rich ecosystem of transports, formatters, and community plugins that Pino or Winston provide. The OTel Logs SDK is also newer to production at scale than Pino, and edge cases in high-volume scenarios may surface issues without established community workarounds.
Pino with context injection is the lowest-risk option for teams with existing Pino deployments. The PinoInstrumentation is transparent - no changes to application code, no new pipeline components, no migration risk. Trace context appears in log records automatically. The limitation is that the correlation exists at the field level (trace_id/span_id values in JSON), not at the pipeline level. Your traces and your logs are still in separate pipelines; the backend is responsible for linking them. If you later want to route logs through the OTel Collector for filtering or sampling, you need to change the transport configuration - but that change is scoped to the logger initialization and does not touch application code.
Pino with OTel bridge transport is the architecturally completest option but carries real complexity. You now have three moving parts: the OTel SDK (for trace instrumentation), the Pino library (for the logging API), and the bridge transport (for routing Pino output through the OTel Logs SDK pipeline). Startup sequencing matters. Worker-thread lifecycle management matters. A misconfigured exporter endpoint silently drops logs. In exchange, you get a single unified pipeline with the same guarantee the OTel Collector gives for all telemetry: backend-agnostic routing, centralized sampling, scrubbing, and fan-out. For organizations committed to OTel as their long-term telemetry standard, this is the right end state.
| Pure OTel Logs | Pino + Context Injection | Pino + OTel Bridge | |
|---|---|---|---|
| Developer ergonomics | Low (verbose API) | High (native Pino) | High (native Pino) |
| Trace-log correlation | Automatic (pipeline-native) | Field-level (trace_id in JSON) | Automatic (pipeline-native) |
| Operational complexity | Medium | Low | High |
| Pipeline unification | Full | None | Full |
| Performance | Medium | Highest | High |
| Migration risk | High (API change) | Low | Medium |
| Ecosystem maturity | Newer | Proven | Newer |
Trade-offs and Honest Caveats
The Maturity Gap Is Real
At the time of writing, the OTel Logs signal in JavaScript is stable per the specification, and the core SDK packages (@opentelemetry/sdk-logs, @opentelemetry/exporter-logs-otlp-http) are production-ready. However, the ecosystem around OTel Logs is demonstrably less mature than the ecosystem around Pino. There are fewer community packages for OTel-native logging utilities, fewer documented operational patterns, and fewer engineers who have run OTel Logs at the throughput levels that Pino handles routinely. This is not a permanent condition - the ecosystem is evolving rapidly - but it is a real consideration for teams making infrastructure decisions today. If your service emits a million log lines per second and you hit a performance issue with the OTel Logs SDK, the community surface area for finding help and workarounds is smaller than it would be for Pino.
This maturity gap affects the bridge pattern too. pino-opentelemetry-transport and related community packages are younger than Pino's core and its established transport ecosystem. Evaluating their production stability, issue tracker activity, and maintenance posture before committing to them in a critical service is prudent engineering practice. Checking the repository's recent commit history and open issues before adoption is not optional due diligence - it is mandatory.
Silent Export Failures
The async nature of OTel's BatchLogRecordProcessor - which is the correct choice for production - means that if the exporter fails to deliver records (network partition, collector overload, misconfigured endpoint), the application does not receive an error. Records are buffered up to maxQueueSize and then dropped silently if the queue fills. This is intentional behavior: logging must not impact application availability. But it means you need a separate monitoring mechanism for your OTel Collector's ingestion health - typically a metric exported by the Collector itself - to detect and alert on log delivery failures. Teams accustomed to synchronous file-based logging, where a full disk is immediately observable, are sometimes surprised by this behavior the first time they encounter a misconfigured OTLP endpoint in staging.
The OTel Collector as a New Dependency
Routing logs through OTLP means adding an OTel Collector (or a compatible OTLP-capable agent) to your deployment topology. In a Kubernetes environment, this is typically a sidecar container or a DaemonSet, and the operational overhead is modest compared to the benefits of centralized telemetry routing. In simpler environments - a single VM, a small Docker Compose deployment, or a serverless function - the Collector adds non-trivial complexity. For teams not already running a Collector for traces, adopting OTel Logs means adopting the Collector as well, which is a larger architectural commitment than switching logging libraries.
Vendor Lock-in Considerations
One of OTel's stated design goals is vendor neutrality. A service emitting OTLP can route its telemetry to any compatible backend - Grafana Tempo, Jaeger, Zipkin, Datadog, Dynatrace, Honeycomb - by changing Collector configuration rather than application code. In practice, backends vary in their OTLP support quality, and some OTel semantic convention fields are interpreted differently across platforms. Moving from one backend to another is significantly easier with OTel than with a vendor-specific SDK, but it is not as friction-free as the marketing suggests. Factor this into your evaluation honestly rather than treating vendor neutrality as an absolute guarantee.
Migration Strategy: A Pragmatic Path Forward
If You Are Starting a New Service Today
For greenfield services where OTel adoption is the organizational direction, the cleanest architecture is to initialize the OTel SDK fully at startup and adopt the bridge pattern: Pino for the logging API, routing through the OTel Logs SDK via pino-opentelemetry-transport. This gives you Pino's ergonomics now and full OTel pipeline unification at launch, without a future migration. The added complexity is manageable in a new codebase where there is no existing logging infrastructure to preserve. Configure the OTel SDK through environment variables (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES) to keep the instrumentation code generic and environment-specific configuration in your deployment manifests.
If pino-opentelemetry-transport is not yet stable enough for your risk tolerance, start with Pino standalone plus PinoInstrumentation for context injection. You will have trace context in your log records immediately, and when you are ready to unify the pipeline, the change is isolated to the logger initialization file and a new Pino transport configuration - no application code changes required.
If You Are Migrating an Existing Service
For existing services already running Pino, the migration path with the lowest disruption is additive: add @opentelemetry/instrumentation-pino to your OTel SDK initialization. This gives you trace context injection with zero changes to application logging code. It is the right first step and may be sufficient for your observability goals if your backend can correlate logs and traces via the injected field values.
The full migration to the bridge pattern can be deferred until you have operational experience with the OTel Collector in your environment and confidence in the transport package's stability for your throughput level. Treat it as a second phase, triggered by a concrete operational need - such as wanting to apply log sampling or routing rules in the Collector without changing application code.
// Phase 1 migration: add PinoInstrumentation to existing OTel SDK init
// No changes to application logger.ts required.
// ─────────────────────────────────────────────────────────────────────
// Before:
const sdk = new NodeSDK({
resource,
traceExporter,
instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()],
});
// After (Phase 1):
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
const sdk = new NodeSDK({
resource,
traceExporter,
instrumentations: [
new HttpInstrumentation(),
new ExpressInstrumentation(),
new PinoInstrumentation(), // <- single line addition; no other changes needed
],
});
Best Practices Across All Approaches
Standardize on OTel Semantic Conventions for Field Names
Whether you are using pure OTel Logs, Pino with context injection, or the bridge pattern, aligning your log record field names with the OpenTelemetry Semantic Conventions eliminates translation friction at every subsequent layer. The OTel Semantic Conventions define standard attribute names for HTTP requests (http.method, http.url, http.status_code), database calls (db.system, db.name, db.statement), exceptions (exception.type, exception.message, exception.stacktrace), and dozens of other domains. Using these names means your logs, traces, and metrics share a common vocabulary, and your OTel Collector processors, your backend query language, and your alert rule expressions can refer to the same field names across all three signals without a mapping layer.
Use the OTel Collector as the Routing and Filtering Layer
One of the most significant architectural benefits of routing logs through OTLP is that the OTel Collector becomes your centralized policy enforcement point. Sampling rules, field scrubbing (PII removal), log level filtering, and backend fan-out all become Collector configuration rather than application code. This means you can change the retention policy for health check logs, add a new log destination for security events, or redact a newly discovered sensitive field without a code deploy. For organizations with multiple teams emitting logs, centralizing this logic in the Collector rather than in each application's logger initialization reduces consistency risk significantly.
# otel-collector-config.yaml (relevant excerpt)
# Route logs: errors to PagerDuty webhook + Loki, info to Loki only.
# Filter out /health endpoint logs at the Collector layer - no app code change.
processors:
filter/health_checks:
logs:
exclude:
match_type: regexp
record_attributes:
- key: http.target
value: "^/health(z)?$"
redaction/pii:
allow_all_keys: true
blocked_values:
- "[0-9]{4}[-][0-9]{4}[-][0-9]{4}[-][0-9]{4}" # credit card pattern
exporters:
loki:
endpoint: "http://loki:3100/loki/api/v1/push"
otlp/error_alerts:
endpoint: "https://your-alerting-backend/v1/logs"
service:
pipelines:
logs:
receivers: [otlp]
processors: [filter/health_checks, redaction/pii]
exporters: [loki]
logs/errors:
receivers: [otlp]
processors: [filter/health_checks]
exporters: [otlp/error_alerts]
Initialize OTel Before Everything Else
The most common failure mode in OTel setups is SDK initialization racing with instrumented library imports. If express, pg, mongodb, or pino is imported before the OTel SDK patches them, the instrumentation hooks are never installed. The fix is deterministic startup ordering via --require (CommonJS) or --import (ESM) Node.js flags, pointing to a dedicated instrumentation file that performs all SDK initialization before the application entry point runs. This is not optional in production OTel deployments - it is the foundational pattern the ecosystem assumes.
Handle Graceful Shutdown Explicitly
The BatchLogRecordProcessor and BatchSpanProcessor both maintain in-memory queues that need to be flushed before the process exits. Without explicit shutdown handling, SIGTERM will kill the process before buffered telemetry is exported. This is particularly significant for serverless functions and short-lived container workloads, where the entire lifecycle of the process might correspond to a single request. Register process.on('SIGTERM', ...) and process.on('SIGINT', ...) handlers that call sdk.shutdown() and loggerProvider.shutdown(), awaiting their resolution before calling process.exit(). Most Node.js frameworks (Fastify, NestJS) expose lifecycle hooks that can accommodate this pattern cleanly.
Key Takeaways
Five concrete steps you can apply to your Node.js service today:
-
Add
PinoInstrumentationto your existing OTel SDK init. This single-line addition injectstrace_idandspan_idinto every Pino log record emitted inside an active span. No application code changes, immediate benefit. It is the correct first step regardless of which long-term strategy you choose. -
Standardize your log field names on OTel Semantic Conventions now. Rename
requestIdtocorrelation_id,httpMethodtohttp.method, and align your exception fields toexception.message/exception.stacktrace. This is a naming discipline change, not a library change, and it reduces future friction at every backend and pipeline integration point. -
Route OTel SDK initialization through a dedicated
--requirefile. Do not initialize the SDK in your application entry point. A dedicated instrumentation file loaded via the--requireflag guarantees initialization order and prevents instrumentation gaps from module import racing. -
Configure explicit graceful shutdown. Add
SIGTERMandSIGINThandlers that flush both the trace and log SDK pipelines. Buffered telemetry on process exit is a silent data loss source that is easy to miss in testing and consequential during incidents. -
Plan your OTel Collector configuration as application infrastructure, not afterthought. Log sampling rules, PII scrubbing, and backend routing belong in the Collector, not in application code. Define these policies in Collector configuration early, and treat them with the same review discipline as application code.
Analogies and Mental Models
Think of the three strategies as three ways to connect your home to the power grid. Pure OTel Logs is like installing a modern all-in-one smart home energy system from the start: everything - solar, battery, mains - runs through a single unified controller. The integration is seamless and the monitoring is comprehensive, but the initial installation is more complex than simply plugging things in.
Pino with context injection is like running standard wiring but installing smart plugs on your most important circuits. You get visibility into what matters, the installation is non-invasive and reversible, and you retain the familiar interface. The limitation is that the plugs are downstream of the panel - you can observe usage, but you cannot route or filter at the source.
Pino with OTel bridge is like running all your circuits through a smart breaker panel that can reroute power, log consumption, and apply rules - while keeping the same outlet interface in every room. The panel is a new dependency with its own maintenance requirements, but the flexibility it provides at the infrastructure layer is worth it once you have enough circuits to manage.
The mental model that governs all three is the separation between the logging API (what developers call) and the logging pipeline (what infrastructure controls). Pino is a logging API. The OTel Logs SDK, with its exporter and Collector, is a logging pipeline. You can mix and match them - and often should.
80/20 Insight
If you are adopting OTel incrementally - traces first, then metrics, then logs - the single highest-leverage action at each stage is aligning the pipeline. The 20% of effort that produces 80% of the observability value is not the library you choose for your logger; it is ensuring that your log records carry the same traceId and spanId as the trace spans they were emitted within. Once that correlation exists - whether through PinoInstrumentation, the bridge transport, or native OTel Logs - the ability to navigate from a slow span to the application log that explains it transforms incident response from a multi-tool archaeological exercise into a single click in your observability backend.
Everything else - transport performance, pipeline unification, OTel Collector routing rules - is important, but secondary to that one correlation. Get the trace context into your logs first. Build the rest of the architecture around it at whatever pace your operational complexity can absorb.
Conclusion
The choice between pure OTel Logs, Pino with context injection, and the bridge pattern is not primarily a technical choice - it is an organizational posture question about how aggressively your team is adopting OTel and how much pipeline complexity you are prepared to maintain. None of the three options is clearly dominant across all dimensions.
For most teams today, the pragmatic answer is to start with Pino and PinoInstrumentation, add trace context immediately with minimal disruption, and treat the bridge pattern as a planned second phase once the OTel Collector is operational in your environment and the transport ecosystem has more production mileage behind it. For new services where OTel is already part of the foundation, committing to the bridge pattern from day one is architecturally sound and avoids a future migration. Pure OTel Logs, without Pino, makes the most sense if your organization is moving toward OTel as a universal telemetry standard and is prepared to invest in the ergonomic wrapper layer and accept the current ecosystem maturity constraints.
What matters more than library choice is the discipline with which you apply whatever strategy you select: consistent field naming, explicit graceful shutdown, centralized scrubbing and routing in the Collector, and a clear operational model for what happens when the export pipeline is degraded. OTel's promise - unified telemetry, vendor neutrality, trace-log correlation - is genuine. Realizing it requires not just installing packages, but thinking carefully about the pipeline as infrastructure.
References
- OpenTelemetry Logs Specification - https://opentelemetry.io/docs/specs/otel/logs/
- OpenTelemetry JavaScript SDK - Logs (
@opentelemetry/sdk-logs) - https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/sdk-logs - OpenTelemetry API Logs (
@opentelemetry/api-logs) - https://github.com/open-telemetry/opentelemetry-js/tree/main/api-logs - OTLP Log Exporter HTTP (
@opentelemetry/exporter-logs-otlp-http) - https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/exporter-logs-otlp-http - opentelemetry-js-contrib: Pino Instrumentation (
@opentelemetry/instrumentation-pino) - https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-pino - opentelemetry-js-contrib: Winston Instrumentation (
@opentelemetry/instrumentation-winston) - https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-winston - pino-opentelemetry-transport - https://github.com/pinojs/pino-opentelemetry-transport
- Pino Documentation: Transports - https://getpino.io/#/docs/transports
- OpenTelemetry Semantic Conventions - Logs - https://opentelemetry.io/docs/specs/semconv/general/logs/
- OpenTelemetry Collector Documentation - https://opentelemetry.io/docs/collector/
- OTel Collector Filter Processor - https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/filterprocessor
- OTel Collector Redaction Processor - https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/redactionprocessor
- @opentelemetry/sdk-node: NodeSDK - https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-sdk-trace-node
- Node.js
--requireand--importflags documentation - https://nodejs.org/api/cli.html#-r---require-module - OpenTelemetry Specification: Data Model for Logs - https://opentelemetry.io/docs/specs/otel/logs/data-model/
- W3C Trace Context Specification - https://www.w3.org/TR/trace-context/