JSON logging isn't enough anymore: A deep dive into OpenTelemetry Logs

19 min read Original article ↗

Logs are the oldest telemetry signal, but in distributed systems they're often the least useful. When services span multiple languages and runtimes, each one produces logs in its own format with its own conventions. Correlating a single user request across those services means manually stitching together log lines that were never designed to be connected.

OpenTelemetry logging doesn't try to replace your logging library. Instead, it defines a vendor-neutral data model that existing libraries feed into, so that logs from any service carry the same structure, the same resource context, and the same trace identifiers. The result is that logs become a first-class telemetry signal you can correlate with traces and metrics, rather than isolated text that happens to be nearby.

This article covers the OpenTelemetry logs data model in detail, how application logs enter the OpenTelemetry pipeline through SDKs and log bridges, how the Collector handles logs from sources you don't control, and what changes operationally once your logs are represented this way.

What are OpenTelemetry logs?

At a high level, OpenTelemetry treats logs as first-class telemetry signals, alongside traces and metrics. Each log is a well-defined structured record with:

  • A precise timestamp.
  • A severity level with consistent semantics.
  • Structured attributes for context.
  • Metadata describing where the log came from.
  • Optional trace and span identifiers that link the log to a request's execution path.

This design allows logs from diverse sources to be processed uniformly, even when they originate from different logging libraries or programming environments.

OpenTelemetry logs vs the traditional logging model

Traditional logging approaches focus on local output: writing text to files or stdout, often in ad-hoc formats. This works well for single applications, but breaks down in distributed environments where each service produces logs with diverse formats and conventions.

Adopting structured logging helps, but it doesn't solve the underlying problem. Structure gives you parseable fields, but it doesn't give you a shared model across services, languages, and runtimes, and it doesn't connect logs to the rest of your telemetry.

OpenTelemetry doesn't ask you to replace your logging library. Instead, it defines a common model that your existing logs are mapped into, so that records from different services, languages, and frameworks all share the same structure, the same severity scale, and the same resource context.

When logs are emitted within an active OpenTelemetry trace, relevant identifiers are automatically attached to each log record, making it possible to:

  • Jump from a log entry directly to the trace that produced it.
  • See associated logs in the exact execution context of a request.
  • Correlate errors, latency spikes, and anomalous behavior across signals.

To support these capabilities, OpenTelemetry defines a logs data model that standardizes how log records are represented regardless of the source.

The rest of this guide explores that model in detail, explaining how its fields work together, why they exist, and how OpenTelemetry uses them to turn logs into reliable, high-signal observability data.

Understanding the OpenTelemetry logging specification

The OpenTelemetry logs data model is designed to accommodate logs from a wide range of sources while preserving their original meaning. Existing log formats can be mapped into the model without ambiguity and, in most cases, reconstructed without loss of information.

OpenTelemetry LogRecord data model

An OpenTelemetry log record consists of a set of named top-level fields with well-defined semantics, plus flexible attributes for event-specific context. The full list of fields are:

FieldDescription
TimestampTime when the event occurred at the source.
ObservedTimestampTime when the collection system first saw the event.
TraceIdUnique identifier for a distributed trace
SpanIdSpan ID within the trace.
TraceFlagsW3C trace flags (e.g. sampled).
SeverityTextOriginal severity label from the source.
SeverityNumberNormalized numeric severity for cross-system comparison.
BodyPrimary log content (string or structured data).
ResourceEntity that produced the log.
InstrumentationScopeLibrary or module that emitted the record.
AttributesArbitrary key-value pairs with event-specific context.
EventNameIdentifies the class or type of a structured Event.

A few of these deserve a closer look.

Timestamp vs ObservedTimestamp

Timestamp records when the log actually happened, measured by the origin clock, while ObservedTimestamp records when OpenTelemetry's collection pipeline first saw it.

For logs produced directly through the OTel SDK, these two values are usually identical because the SDK sets both at generation time. The distinction matters for logs collected externally, like those the OpenTelemetry Collector tails from a file or receives over syslog. In those cases, Timestamp reflects the original log time (once parsed), while ObservedTimestamp reflects when the Collector ingested it.

If a downstream system only supports a single timestamp, the spec recommends preferring Timestamp when it's present and falling back to ObservedTimestamp otherwise.

Instrumentation scope

The InstrumentationScope field identifies which library or module produced a log record. It's a (name, version) tuple, like ("logback", "1.4.0") or ("@opentelemetry/instrumentation-pino", "0.49.0").

In the OpenTelemetry Protocol (OTLP) wire format, logs from the same scope are grouped under a shared scopeLogs entry, which avoids repeating scope metadata on every individual record. You'll see this grouping in the OTLP JSON example below.

Trace context fields

To enable trace-log correlation, the data model incorporates fields from the W3C Trace Context specification:

  • TraceId: The unique identifier for a distributed trace.
  • SpanId: The identifier for a specific span (operation) within that trace.
  • TraceFlags: Flags providing metadata about the trace, such as whether it was sampled.

When present, these fields let you link an individual log record directly to the trace that produced it, so you can navigate between logs and their execution context in distributed systems.

Log levels

The data model also standardizes how log severity is expressed by decoupling severity semantics from language-specific logging conventions:

  • SeverityText: The original severity label emitted by the source.

  • SeverityNumber: A numeric value that enables consistent comparison and filtering across systems. Smaller numbers represent less severe events, larger numbers more severe ones.

SeverityNumber RangeSeverityText
1–4TRACE
5–8DEBUG
9–12INFO
13–16WARN
17–20ERROR
21–24FATAL

Events and the EventName field

A log record with a non-empty EventName is considered an Event. Where a regular log record can contain anything in its Body, an Event declares upfront what kind of thing happened and follows a known attribute schema for that event type. That predictability is the point: backends and tooling can recognize and route Events without parsing the body.

Going forward, the OTel project is defining all new log-related semantic conventions as Events, which means typed, schema-driven log records will become the standard rather than the exception.

The most mature example is the exception event. A log record with EventName: "exception" is expected to carry exception.type, exception.message, and exception.stacktrace as attributes. Any backend that understands the convention can detect, group, and alert on exceptions automatically.

This doesn't mean every log record needs an EventName. Ad-hoc logs can continue to use Body alone, but if you're building instrumentation that emits known event types, setting EventName gives backends a structured hook to work with.

How OTLP represents log records

To see how all of these fields fit together on the wire, here's a log record in OTLP JSON format:

12345678910111213141516171819202122232425262728293031323334353637

"value": { "stringValue": "checkoutservice" }

"scope": { "name": "logback", "version": "1.4.0" },

"timeUnixNano": "1756571696706248000",

"observedTimeUnixNano": "1756571696710000000",

"body": { "stringValue": "Database connection failed" },

{ "key": "thread.id", "value": { "intValue": 42 } },

"value": { "stringValue": "SQLException" }

"traceId": "da5b97cecb0fe7457507a876944b3cf",

"spanId": "fa7f0ea9cb73614c"

At the top level, resourceLogs groups records emitted by the same resource which in this case is a single microservice identified by service.name. Within each resource, scopeLogs group records by instrumentation scope, indicating which library or module produced them. The logRecords array then contains the individual events, each enriched with timestamps, severity, context, and optional trace identifiers.

With this structure in place, logs from different services, languages, and runtimes can be processed and queried the same way regardless of where they originated.

But a data model is only a specification, not a runtime. Your applications won't produce compliant log records on their own; they need to be wired into the OpenTelemetry pipeline through SDKs and log bridges.

Integrating application logs with OpenTelemetry

Unlike traces and metrics, which rely on OpenTelemetry-specific APIs for instrumentation, logging follows a different model. Given the long history and diversity of logging frameworks, OpenTelemetry is designed to integrate with existing libraries rather than replace them.

Application logs enter the OpenTelemetry ecosystem through log bridges: adapters that forward records from familiar libraries such as Python's logging, Java's SLF4J or Logback, and .NET's Serilog.

This design means you can keep your existing logging code and tooling, while gaining the OpenTelemetry log data model, trace correlation, and consistent export to observability backends.

Understanding the OpenTelemetry logging components

How the OpenTelemetry Logs API and SDK interact

The Logs API defines the contract for passing log records into the OpenTelemetry pipeline. It's primarily intended for library authors to build appenders or handlers, but it can also be called directly from instrumentation libraries or application code.

It consists of the following core components:

  • LoggerProvider: Creates and manages Logger instances. Typically, one is configured per process and registered globally for consistent access.

  • Logger: Responsible for emitting logs as LogRecords. In practice, your existing logging library (via a bridge) will call it for you.

  • LogRecord: The data structure representing a single log event, with all the fields defined in the logs data model described earlier.

While the Logs API defines how logs are created, the Logs SDK handles processing and export. It provides:

  • A concrete LoggerProvider implementation.
  • A LogRecordProcessor that sits between log creation and export and is responsible for enriching and batching LogRecords.
  • A LogRecordExporter that takes processed records and exports them to set destinations (often an OTLP endpoint).

OpenTelemetry logging example using log bridges

The OpenTelemetry Logs SDK does not automatically capture application logs. It provides the processing and export pipeline, but log records must be explicitly fed into it through a log bridge.

A log bridge (or appender) connects an existing logging framework to the OpenTelemetry Logs API. Rather than rewriting applications to emit logs through OpenTelemetry directly, you only need to attach a bridge to the logger you already use.

For example, consider a Node.js application using Pino:

By default, Pino produces JSON logs like this:

To bring these logs into an OpenTelemetry pipeline, you must configure the OpenTelemetry SDK, register a LogRecordProcessor and LogRecordExporter, and include the Pino log bridge via the @opentelemetry/instrumentation-pino package:

123456789101112131415

import { PinoInstrumentation } from "@opentelemetry/instrumentation-pino";

import { logs, NodeSDK } from "@opentelemetry/sdk-node";

const sdk = new NodeSDK({

logRecordProcessor: new logs.SimpleLogRecordProcessor(

new logs.ConsoleLogRecordExporter(),

instrumentations: [new PinoInstrumentation()],

The SimpleLogRecordProcessor immediately exports each log, which is useful for development and debugging. In production, it is typically replaced with a BatchLogRecordProcessor to reduce overhead, and the ConsoleLogRecordExporter is swapped for an OTLPLogExporter that streams logs to an OTLP endpoint (typically the OpenTelemetry Collector):

12345678910

import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";

import { PinoInstrumentation } from "@opentelemetry/instrumentation-pino";

import { logs, NodeSDK } from "@opentelemetry/sdk-node";

const sdk = new NodeSDK({

logRecordProcessor: new logs.BatchLogRecordProcessor(new OTLPLogExporter()),

instrumentations: [new PinoInstrumentation()],

When viewed through the Collector using the debug exporter, the resulting log record appears as follows:

12345678910111213141516171819202122232425262728293031

2025-09-22T05:31:27.964Z info ResourceLog #0

-> host.name: Str(falcon)

-> host.id: Str(4a3dc42bf0564d50807d1553f485552a)

-> process.pid: Int(59532)

-> process.executable.name: Str(node)

-> process.executable.path: Str(/home/ayo/.local/share/mise/installs/node/24.8.0/bin/node)

-> process.command_args: Slice(["/home/ayo/.local/share/mise/installs/node/24.8.0/bin/node","--experimental-loader=@opentelemetry/instrumentation/hook.mjs","/home/ayo/dev/dash0/repro-contrib-2838/index.js"])

-> process.runtime.version: Str(24.8.0)

-> process.runtime.name: Str(nodejs)

-> process.runtime.description: Str(Node.js)

-> process.command: Str(/home/ayo/dev/dash0/repro-contrib-2838/index.js)

-> process.owner: Str(ayo)

-> service.name: Str(unknown_service:node)

-> telemetry.sdk.language: Str(nodejs)

-> telemetry.sdk.name: Str(opentelemetry)

-> telemetry.sdk.version: Str(2.0.1)

InstrumentationScope @opentelemetry/instrumentation-pino 0.49.0

ObservedTimestamp: 2025-09-22 05:31:27.924 +0000 UTC

Timestamp: 2025-09-22 05:31:27.924 +0000 UTC

This output shows how the bridge and Logs SDK work together. The original Pino log is translated into an OpenTelemetry LogRecord, enriched with resource metadata, mapped to standardized severity fields, and annotated with instrumentation scope information. Trace correlation fields are present, though unset in this example because no active span was in scope.

Note that log bridge availability and maturity varies by language and framework. Always consult the OpenTelemetry documentation for your language to confirm what is supported and how it should be configured. For example, see the Go otelslog guide for the equivalent setup with Go's slog.

Correlating OpenTelemetry logs and traces

Correlating logs and traces in OpenTelemetry

When you use the OTel SDK for both tracing and logging, it automatically correlates the two.

For this to work, you need to emit logs within an active span. When you do, the SDK attaches the current trace and span identifiers to each log record automatically.

In practice, spans are usually created via zero-code instrumentation around common operations such as HTTP requests or database calls. Spans can also be created manually when needed:

1234567

import { api, logs, NodeSDK } from "@opentelemetry/sdk-node";

const tracer = api.trace.getTracer("example");

tracer.startActiveSpan("manual-span", (span) => {

logger.info("in a span");

The resulting log record now includes the active trace context:

123456789

ObservedTimestamp: 2025-09-22 05:51:37.685 +0000 UTC

Timestamp: 2025-09-22 05:51:37.685 +0000 UTC

Trace ID: 6691c3b82c157705904ba3b5b921d60a

Span ID: 72efdc9ec81b179a

This creates a direct link between logs and traces: from a trace, you can navigate directly to the logs emitted within its spans, and from a log entry, you can jump to the full distributed trace that produced it.

OpenTelemetry-native Log-trace correlation in Dash0

What to do if a log bridge isn't available

If your logging library currently lacks a log bridge, you can still enrich your logs with trace context and let the Collector do the mapping.

Most logging libraries allow contextual fields to be injected into log output without repeating them each time at log point. If you include trace_id, span_id, and trace_flags with each log record, you keep trace-log correlation intact:

12345678

"timestamp": "2025-10-05T15:34:11.428Z",

"message": "Payment authorization failed",

"trace_id": "c8f4a2171adf3de0a2c0b2e8f649a21f",

"span_id": "d6e2b6c1a2f53e4b",

Once the Collector ingests these records, it can parse and map those fields to the canonical TraceId and SpanId fields in the OpenTelemetry log data model.

You get trace-log correlation without changing your logging calls, and you can adopt a proper log bridge later when one becomes available for your framework.

Python's Loguru is a good example of this pattern in practice. It doesn't have a first-party OTel integration, but you can inject the active trace and span IDs into every log record through middleware, giving you correlation now while the ecosystem catches up.

How the OpenTelemetry Collector ingests and transforms logs

So far, we've focused on applications emitting OpenTelemetry-native logs directly via SDKs and log bridges. In practice, however, not every component in a system is instrumented or even under your control. Legacy applications, third-party software, and infrastructure components typically emit logs in their own formats, with no awareness of OpenTelemetry.

The OpenTelemetry Collector addresses this gap. It can ingest logs from a wide range of sources, parse them, and map them into the OpenTelemetry logs data model before export. This allows systems that know nothing about OpenTelemetry to still participate in the same observability pipeline as instrumented applications.

Log ingestion via receivers

The Collector ingests logs through receivers, each designed to handle a specific input source or protocol. Common examples include:

Once a receiver ingests a log, it becomes an OpenTelemetry log record, but most of its fields will be empty or unpopulated. The receiver's job is to get the data in, not to interpret it.

Consider the following Linux authentication log entry:

1

Aug 20 18:23:23 ubuntu-lts sshd[47339]: Received disconnect from 180.101.88.228 port 11349:11: [preauth]

When ingested by the filelogreceiver and viewed with the debug exporter, it appears as follows:

12345678910

ObservedTimestamp: 2025-09-21 17:25:01.598645527 +0000 UTC

Timestamp: 1970-01-01 00:00:00 +0000 UTC

SeverityNumber: Unspecified(0)

Body: Str(Aug 20 18:23:23 ubuntu-lts sshd[47339]: Received disconnect from 180.101.88.228 port 11349:11: [preauth])

-> log.file.name: Str(auth.log)

At this stage, the log is little more than an unstructured string stored in the Body, accompanied by minimal metadata. Important fields such as Timestamp, SeverityNumber, and contextual attributes remain unset. This is expected as receivers prioritize ingestion, not interpretation.

Parsing and enrichment with operators and processors

To turn these bare log records into something you can actually query and correlate, you typically need to:

  • Extract timestamps and map to the Timestamp field.
  • Identify severity levels and map them to SeverityText and SeverityNumber.
  • Extract contextual fields into structured Attributes.
  • Enrich records with resource metadata (host, process, Kubernetes, cloud).
  • Populate trace context fields if available.

This work is performed using:

  • Operators, which act on individual log entries during ingestion.
  • Processors, which operate on batches of telemetry regardless of the ingestion source.

For example, applying the syslog_parser operator within the filelog receiver:

12345678

include: [/var/log/auth.log]

allow_skip_pri_header: true

Produces an updated OpenTelemetry LogRecord:

123456789101112131415

ObservedTimestamp: 2025-09-21 18:40:22.780865051 +0000 UTC

Timestamp: 2025-08-20 18:23:23 +0000 UTC

SeverityNumber: Unspecified(0)

Body: Str(Aug 20 18:23:23 ubuntu-lts sshd[47339]: Received disconnect from 180.101.88.228 port 11349:11: [preauth])

-> log.file.name: Str(auth.log)

-> message: Str(Received disconnect from 180.101.88.228 port 11349:11: [preauth])

-> hostname: Str(ubuntu-lts)

The timestamp is now correctly parsed, and key fields from the syslog prefix have been extracted into the Attributes map.

Additional operators can be chained to extract domain-specific details. For example, a regex_parser can extract the client IP and port:

12345

parse_from: attributes.message

'Received disconnect from (?P<client_ip>[\d.]+) port (?P<client_port>\d+)'

Resulting in new attributes:

123

-> client_ip: Str(180.101.88.228)

-> client_port: Str(11349)

Advanced transformations with OTTL

Operators handle parsing, but they work on individual fields during ingestion. When you need to restructure records after parsing, like moving attributes to the resource, normalizing field names to match semantic conventions, or mapping severity based on log content, you need the OpenTelemetry Transformation Language (OTTL).

OTTL runs in the transform processor (and other processors) and operates on complete log records with conditional logic. Here's an example that takes the ingested and parsed sshd records and reshapes them into a fully compliant OpenTelemetry record:

123456789101112131415161718192021222324252627282930

- set(resource.attributes["host.name"], log.attributes["hostname"])

- set(resource.attributes["process.executable.name"],

log.attributes["appname"])

- set(resource.attributes["process.pid"], Int(log.attributes["proc_id"]))

- set(log.attributes["client.address"], log.attributes["client_ip"])

- set(log.attributes["client.port"], Int(log.attributes["client_port"]))

- set(log.attributes["log.record.original"], log.body)

- set(log.body, log.attributes["message"])

- set(log.severity_number, SEVERITY_NUMBER_INFO) where IsMatch(log.body,

- set(log.severity_text, "INFO") where log.severity_number >=

SEVERITY_NUMBER_INFO and log.severity_number <= SEVERITY_NUMBER_INFO4

- delete_key(log.attributes, "hostname")

- delete_key(log.attributes, "appname")

- delete_key(log.attributes, "proc_id")

- delete_key(log.attributes, "client_ip")

- delete_key(log.attributes, "client_port")

- delete_key(log.attributes, "message")

After these transformations, the raw sshd line from earlier is now a complete OpenTelemetry log record with resource attributes, normalized severity, and semantic-convention-compliant fields:

123456789101112131415161718192021222324

2025-09-22T03:14:29.229Z info ResourceLog #0

-> host.name: Str(ubuntu-lts)

-> process.executable.name: Str(sshd)

-> process.pid: Int(47339)

ObservedTimestamp: 2025-09-22 03:14:29.130188792 +0000 UTC

Timestamp: 2025-08-20 18:23:23 +0000 UTC

Body: Str(Received disconnect from 180.101.88.228 port 11349:11: [preauth])

-> client.port: Int(11349)

-> client.address: Str(180.101.88.228)

-> log.file.name: Str(auth.log)

-> log.record.original: Str(Aug 20 18:23:23 ubuntu-lts sshd[47339]: Received disconnect from 180.101.88.228 port 11349:11: [preauth])

The pattern is consistent: compose receivers, operators, and processors into a pipeline, and the Collector converts whatever raw log format you throw at it into structured OpenTelemetry records. For a worked example that takes this further with trace correlation, see our guide on transforming PostgreSQL logs with the OpenTelemetry Collector.

Best practices for OpenTelemetry logging

Getting OpenTelemetry logging right in production takes more than just turning on a bridge or deploying a Collector. The practices below help you produce logs that are reliable and operationally useful without blowing up your ingestion bill.

1. Start with structure

When logs are unstructured, like the raw sshd example, you end up stacking operators and transform rules just to pull out basics like timestamp or client IP.

That effort largely disappears if your application emits structured logs from the outset, because log bridges and the Collector can map named fields directly into the OpenTelemetry data model instead of parsing them out of raw text.

The rule of thumb is simple: reserve parsers and regex operators for legacy systems you cannot change, and ensure new services emit structured logs in JSON by default.

2. Embrace high-cardinality attributes

High-cardinality attributes in logs and traces are essential for cross-signal correlation and effective root-cause analysis. They enable an important distinction for troubleshooting: is this affecting everyone, or only a specific subset?

Without high cardinality, you can see that you have a spike in errors. With it, you can see that the error spike is coming from user:8675309 on the canary deployment in eu-west-1 who has the new-checkout-flow feature flag enabled.

3. Use semantic conventions

Rely on OpenTelemetry's semantic conventions for contextual log attributes wherever you can. This keeps your logs interpretable across OTel-compliant backends, aligns them with traces and metrics, and eliminates the post-ingestion cleanup that comes from having to rename or normalize attributes after parsing.

4. Always include resource attributes

Resource attributes anchor logs to the services and environments that produced them. Set these attributes in the SDK Resource where possible, and enrich them further in the Collector using processors such as resourcedetection or k8sattributes.

Without consistent resource metadata, even well-structured logs lose much of their diagnostic value.

5. Scrub sensitive data

It's easy for tokens, credentials, or PII to end up in log output without anyone realizing it, especially when logging request bodies or error payloads. The safest approach is to handle this at both ends: configure your logging libraries to avoid emitting secrets in the first place, and use Collector processors like transform and redaction as a second line of defense before logs leave your environment.

For a detailed walkthrough, see our guide on scrubbing sensitive data with OpenTelemetry.

6. Control your log volume

Unchecked log volume is one of the fastest ways to make observability costs unpredictable. The goal is to keep the logs you'll actually use during an incident and discard the rest before they reach your backend.

Start by setting appropriate log levels in your application frameworks so that DEBUG output stays out of production. Then use the Collector's filter processor to drop logs you'll never query, and the deduplication processor to collapse repeated entries.

Final thoughts

At this point, you understand how OpenTelemetry logging works conceptually: the data model, log bridges, trace correlation, and the role of the Collector. The next step depends on where you are in your OpenTelemetry adoption journey.

If you're instrumenting applications directly, start with language-specific logging guides that show how to wire existing logging frameworks into OpenTelemetry using log bridges and SDKs.

For legacy systems or third-party logs, focus instead on configuring the Collector to ingest raw logs, then incrementally add parsing and enrichment until logs conform to the OpenTelemetry data model.

Once logs are flowing through your telemetry pipeline, the next question is which observability backend to send them to.

Most backends accept OTLP today, but how they handle it varies. Some map OpenTelemetry data into an older internal schema, which can mean losing attribute fidelity, paying more for high-cardinality fields, or dropping native support for semantic conventions.

An OpenTelemetry-native backend is built around the OTel data model as its core representation, not mapped onto a legacy schema, so the data model you've been instrumenting against is the same one you query in production.

Dash0 works this way. It's built from the ground up to be OpenTelemetry-native, accepts OTLP out of the box, preserves every field and attribute in the data model as-is, and lets you correlate logs with traces and metrics without penalizing high-cardinality data.

Start a free trial with Dash0 and see how the data model, trace correlation, and Collector pipeline described in this guide work in practice.