OpenTelemetry Logs: Setup, Data Model, and Trace Correlation

OpenTelemetry Logs is a way to collect, correlate, and export log data alongside traces and metrics. Unlike traces and metrics, it does not ask you to replace your logging library. You keep using Zap, logging, Winston, or Logback, and OpenTelemetry adds trace context and a shared export pipeline on top.

The practical result: a log line emitted inside a span carries that span's trace_id and span_id, so you can jump from a slow request to the log records written while it was running.

Quick start

The usual starting point is a bridge (also called an appender or handler) that hands records from your existing logging library to the OpenTelemetry SDK.

go Go
import (
    "context"

    "go.opentelemetry.io/contrib/bridges/otelzap"
    "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
    "go.opentelemetry.io/otel/log/global"
    sdklog "go.opentelemetry.io/otel/sdk/log"
    "go.uber.org/zap"
)

exporter, err := otlploggrpc.New(ctx)
if err != nil {
    panic(err)
}

provider := sdklog.NewLoggerProvider(
    sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter)),
)
defer provider.Shutdown(ctx)

global.SetLoggerProvider(provider)

logger := zap.New(otelzap.NewCore("my-service", otelzap.WithLoggerProvider(provider)))

logger.Info("processing started",
    zap.Any("context", ctx),
    zap.String("user.id", "12345"),
)
python Python
import logging

from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor

logger_provider = LoggerProvider()
set_logger_provider(logger_provider)
logger_provider.add_log_record_processor(
    BatchLogRecordProcessor(OTLPLogExporter())
)

logging.getLogger().addHandler(LoggingHandler(logger_provider=logger_provider))

logging.info("processing started", extra={"user.id": "12345"})
javascript Node.js
const { logs } = require('@opentelemetry/api-logs');
const {
  LoggerProvider,
  BatchLogRecordProcessor,
} = require('@opentelemetry/sdk-logs');
const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-grpc');

const loggerProvider = new LoggerProvider({
  processors: [new BatchLogRecordProcessor(new OTLPLogExporter())],
});

logs.setGlobalLoggerProvider(loggerProvider);

const logger = logs.getLogger('my-service');
logger.emit({
  severityText: 'INFO',
  body: 'processing started',
  attributes: { 'user.id': '12345' },
});
java Java
// No code change required. Run the application with the OpenTelemetry Java agent:
//
//   java -javaagent:opentelemetry-javaagent.jar \
//        -Dotel.service.name=my-service \
//        -Dotel.logs.exporter=otlp \
//        -jar myapp.jar
//
// The agent installs Logback and Log4j appenders and injects trace_id and
// span_id into MDC automatically.

Point the exporter at your backend with environment variables rather than hardcoding the endpoint:

shell
export OTEL_EXPORTER_OTLP_ENDPOINT=https://api.uptrace.dev:4317
export OTEL_EXPORTER_OTLP_HEADERS='uptrace-dsn=https://<secret>@api.uptrace.dev?grpc=4317'
export OTEL_SERVICE_NAME=my-service

If logs do not show up, see troubleshooting below.

SDK status by language

The logs specification and data model are stable, but the SDK implementations are not uniformly stable. This matters when you decide whether to route production logs through the SDK or through a file and the Collector instead.

LanguageLogs SDK status
JavaStable
.NETStable
C++Stable
PHPStable
GoBeta
RustBeta
PythonDevelopment
JavaScriptDevelopment
RubyDevelopment
SwiftDevelopment
ErlangDevelopment
KotlinDevelopment

Check the current status page before relying on this table — implementations move.

What the levels mean in practice:

  • Stable — the API will not break within the major version. Safe for production.
  • Beta — usable, and many teams run it, but the API surface is not frozen. Pin your versions.
  • Development — expect breaking changes between releases. Workable for new projects, risky for large codebases.

If your language is in Development and you need stability today, write structured logs to stdout or a file and collect them with the OpenTelemetry Collector. You lose automatic trace correlation from the SDK, but you can inject trace_id yourself — see manual correlation below.

Log data model

Every log record follows the same data model, whatever produced it:

FieldDescription
TimestampWhen the event happened, nanoseconds since the Unix epoch
ObservedTimestampWhen the collection system saw it, used when Timestamp is absent
TraceIdTrace this record belongs to
SpanIdSpan active when the record was emitted
TraceFlagsW3C trace flags, including whether the trace was sampled
SeverityNumberSeverity as a number from 1 to 24
SeverityTextOriginal severity string, such as WARN or warning
BodyThe message, either a string or structured data
ResourceWhat produced the record: service name, version, host
AttributesKey-value pairs describing this specific event

SeverityNumber is what backends sort and filter on. The ranges are: TRACE 1-4, DEBUG 5-8, INFO 9-12, WARN 13-16, ERROR 17-20, FATAL 21-24. Within a range, higher numbers are more severe, which lets a library map its own levels without losing ordering.

The split between Resource and Attributes matters for cost. Resource fields are set once per process and stored once per batch; attributes are stored per record. Put service.name and host.name in the resource, not on every line.

Where logs come from

There are three cases, and they need different handling.

System and infrastructure logs

Produced by the operating system, network devices, and third-party services. The format is fixed and you cannot add trace context to it. Collect these with the Collector, using the filelog, syslog, or journald receivers.

Existing application logs

Your own services, already writing logs through a logging library. Two options: attach a bridge so records go through the SDK, or keep writing to a file and inject trace_id into the message yourself. The bridge is less work and produces cleaner data.

New applications

Start with a bridge from the beginning, or call the Logs API directly. Either way you get trace correlation and resource attributes with no extra code at the call site.

Log-trace correlation

When a record is emitted inside an active span, the SDK attaches:

  • trace_id — links the record to the whole distributed trace
  • span_id — links it to the specific operation
  • trace_flags — indicates whether the trace was sampled

This gives you both directions: from a span, list every log written during it; from a log line, open the trace it belongs to.

Automatic correlation with log bridges

Bridges read the active context and fill these fields in for you.

go Go (otelzap)
import (
    "go.opentelemetry.io/contrib/bridges/otelzap"
    "go.uber.org/zap"
)

logger := zap.New(otelzap.NewCore("my-service", otelzap.WithLoggerProvider(provider)))

func handleRequest(ctx context.Context) {
    ctx, span := tracer.Start(ctx, "handle_request")
    defer span.End()

    // The bridge reads context.Context passed as a field and correlates the record.
    logger.Info("processing started",
        zap.Any("context", ctx),
        zap.String("user.id", "12345"),
    )
}
python Python
from opentelemetry import trace
import logging

tracer = trace.get_tracer(__name__)

def handle_request():
    with tracer.start_as_current_span("handle_request"):
        # LoggingHandler picks up the active span from context.
        logging.info("processing started", extra={"user.id": "12345"})
        logging.info("processing completed")
javascript Node.js (winston)
const winston = require('winston');
const { trace } = require('@opentelemetry/api');

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format((info) => {
      const span = trace.getActiveSpan();
      if (span) {
        const spanContext = span.spanContext();
        info.trace_id = spanContext.traceId;
        info.span_id = spanContext.spanId;
        info.trace_flags = spanContext.traceFlags.toString(16).padStart(2, '0');
      }
      return info;
    })(),
    winston.format.json(),
  ),
  transports: [new winston.transports.Console()],
});
java Java (Logback)
// The Java agent injects trace_id and span_id into MDC. Reference them in
// logback.xml:
//
//   <pattern>%d{HH:mm:ss.SSS} trace_id=%X{trace_id} span_id=%X{span_id} - %msg%n</pattern>
//
// No code change is needed at the call site:
logger.info("processing started");

The Go bridge is the one that surprises people: go.opentelemetry.io/contrib/bridges/otelzap has no logger.Ctx(ctx) method. You pass the context as a field, and the bridge extracts it. A different package, uptrace/opentelemetry-go-extra/otelzap, does provide .Ctx() — the two have similar names and incompatible APIs.

Manual correlation

When you cannot use a bridge, read the span context and put the ids into the message yourself. Use the same field names the data model uses, so the Collector can promote them to real fields later: trace_id hex-encoded, span_id hex-encoded, trace_flags in W3C format.

go Go
func logWithContext(ctx context.Context, msg string) {
    span := trace.SpanFromContext(ctx)
    if !span.SpanContext().IsValid() {
        log.Print(msg)
        return
    }

    spanCtx := span.SpanContext()
    log.Printf("%s trace_id=%s span_id=%s",
        msg, spanCtx.TraceID(), spanCtx.SpanID())
}
python Python
from opentelemetry import trace
import logging

def log_with_context(msg):
    span_ctx = trace.get_current_span().get_span_context()
    if not span_ctx.is_valid:
        logging.info(msg)
        return

    logging.info(
        "%s trace_id=%032x span_id=%016x",
        msg, span_ctx.trace_id, span_ctx.span_id,
    )
javascript Node.js
const { trace } = require('@opentelemetry/api');

function logWithContext(msg) {
  const span = trace.getActiveSpan();
  if (!span) {
    console.log(msg);
    return;
  }

  const spanCtx = span.spanContext();
  console.log(`${msg} trace_id=${spanCtx.traceId} span_id=${spanCtx.spanId}`);
}

A record produced this way looks like:

text
request failed trace_id=958180131ddde684c1dbda1aeacf51d3 span_id=0cf859e4f7510204

If the ids end up inside the message body rather than as fields, parse them out in the Collector with a regex_parser operator, then move them onto the record with a trace operator. Otherwise the backend stores them as text and cannot link anything.

Using the logs API directly

Most applications should use a bridge. Call the Logs API directly when you are writing a library that emits telemetry, or when you want log records that never go through a logging library at all.

go Go
import "go.opentelemetry.io/otel/log/global"

logger := global.GetLoggerProvider().Logger("my-library")

var record log.Record
record.SetSeverity(log.SeverityInfo)
record.SetBody(log.StringValue("cache miss"))
record.AddAttributes(log.String("cache.key", key))

logger.Emit(ctx, record)
python Python
from opentelemetry._logs import get_logger_provider, SeverityNumber, LogRecord

logger = get_logger_provider().get_logger("my-library")

logger.emit(LogRecord(
    body="cache miss",
    severity_number=SeverityNumber.INFO,
    severity_text="INFO",
    attributes={"cache.key": key},
))
javascript Node.js
const { logs, SeverityNumber } = require('@opentelemetry/api-logs');

const logger = logs.getLogger('my-library');

logger.emit({
  severityNumber: SeverityNumber.INFO,
  severityText: 'INFO',
  body: 'cache miss',
  attributes: { 'cache.key': key },
});

Structured logging

OpenTelemetry stores attributes as typed key-value pairs. Formatting values into the message text throws that away.

go
// Values are trapped in a string and cannot be filtered or aggregated.
logger.Info(fmt.Sprintf("user %s failed login from %s after %d attempts",
    userID, ip, attempts))

// Values stay queryable.
logger.Info("login failed",
    zap.String("user.id", userID),
    zap.String("client.address", ip),
    zap.Int("login.attempts", attempts),
)

Two rules keep the data usable:

  • Use semantic conventions for attribute names that already exist — client.address, not ip. Names outside the conventions should carry your own namespace, such as app.login.attempts.
  • Keep the message a constant. "login failed" groups; "login failed for user 12345" produces a new distinct message per user and breaks grouping.

Do not log credentials, tokens, payment data, or personal data. If you need an identifier, log an opaque user id rather than an email address.

Collecting logs with the Collector

The OpenTelemetry Collector reads logs from files, syslog, and the Kubernetes API, parses them, and exports them over OTLP. Use it for anything you cannot instrument: system logs, third-party services, and applications in languages where the Logs SDK is still in development.

For transformations beyond what receivers offer — mapping fields to semantic conventions, dropping attributes conditionally — use the OpenTelemetry Transformation Language (OTTL) in the transform processor.

The examples below export to Uptrace. OpenTelemetry is vendor-neutral: swap the exporter block for any OTLP-compatible backend.

Tailing a JSON file

yaml
receivers:
  filelog:
    include: [/var/log/myservice/*.json]
    operators:
      - type: json_parser
        timestamp:
          parse_from: attributes.time
          layout: '%Y-%m-%d %H:%M:%S'

processors:
  batch:

exporters:
  otlp/uptrace:
    endpoint: api.uptrace.dev:4317
    headers:
      uptrace-dsn: 'https://<secret>@api.uptrace.dev?grpc=4317'

service:
  pipelines:
    logs:
      receivers: [filelog]
      processors: [batch]
      exporters: [otlp/uptrace]

See filelog receiver for parsing options.

Syslog

yaml
receivers:
  syslog:
    tcp:
      listen_address: '0.0.0.0:54527'
    protocol: rfc3164
    location: UTC # server timezone
    operators:
      - type: move
        from: attributes.message
        to: body

processors:
  batch:

exporters:
  otlp/uptrace:
    endpoint: api.uptrace.dev:4317
    headers:
      uptrace-dsn: 'https://<secret>@api.uptrace.dev?grpc=4317'

service:
  pipelines:
    logs:
      receivers: [syslog]
      processors: [batch]
      exporters: [otlp/uptrace]

Forward rsyslog to the Collector by appending this to /etc/rsyslog.conf:

text
*.* action(type="omfwd" target="0.0.0.0" port="54527" protocol="tcp"
           action.resumeRetryCount="10"
           queue.type="linkedList" queue.size="10000")

Then restart the service:

shell
sudo systemctl restart rsyslog.service

See OpenTelemetry Syslog Receiver for rsyslog and syslog-ng setup, TLS, and Kubernetes.

Kubernetes logs

Container runtimes write logs in three different formats. This configuration detects the format, parses it, and promotes pod metadata from the file path to resource attributes.

yaml
receivers:
  filelog:
    include:
      - /var/log/pods/*/*/*.log
    include_file_name: false
    include_file_path: true
    start_at: beginning
    operators:
      - id: get-format
        type: router
        routes:
          - expr: body matches "^\\{"
            output: parser-docker
          - expr: body matches "^[^ Z]+ "
            output: parser-crio
          - expr: body matches "^[^ Z]+Z"
            output: parser-containerd
      - id: parser-crio
        type: regex_parser
        output: extract_metadata_from_filepath
        regex: ^(?P<time>[^ Z]+) (?P<stream>stdout|stderr) (?P<logtag>[^ ]*) ?(?P<log>.*)$
        timestamp:
          layout: 2006-01-02T15:04:05.999999999Z07:00
          layout_type: gotime
          parse_from: attributes.time
      - id: parser-containerd
        type: regex_parser
        output: extract_metadata_from_filepath
        regex: ^(?P<time>[^ ^Z]+Z) (?P<stream>stdout|stderr) (?P<logtag>[^ ]*) ?(?P<log>.*)$
        timestamp:
          layout: '%Y-%m-%dT%H:%M:%S.%LZ'
          parse_from: attributes.time
      - id: parser-docker
        type: json_parser
        output: extract_metadata_from_filepath
        timestamp:
          layout: '%Y-%m-%dT%H:%M:%S.%LZ'
          parse_from: attributes.time
      - id: extract_metadata_from_filepath
        type: regex_parser
        parse_from: attributes["log.file.path"]
        regex: ^.*\/(?P<namespace>[^_]+)_(?P<pod_name>[^_]+)_(?P<uid>[a-f0-9\-]+)\/(?P<container_name>[^\._]+)\/(?P<restart_count>\d+)\.log$
      - type: move
        from: attributes.stream
        to: attributes["log.iostream"]
      - type: move
        from: attributes.container_name
        to: resource["k8s.container.name"]
      - type: move
        from: attributes.namespace
        to: resource["k8s.namespace.name"]
      - type: move
        from: attributes.pod_name
        to: resource["k8s.pod.name"]
      - type: move
        from: attributes.restart_count
        to: resource["k8s.container.restart_count"]
      - type: move
        from: attributes.uid
        to: resource["k8s.pod.uid"]
      - type: move
        from: attributes.log
        to: body

processors:
  batch:

exporters:
  otlp/uptrace:
    endpoint: api.uptrace.dev:4317
    headers:
      uptrace-dsn: 'https://<secret>@api.uptrace.dev?grpc=4317'

service:
  pipelines:
    logs:
      receivers: [filelog]
      processors: [batch]
      exporters: [otlp/uptrace]

Kubernetes events

yaml
receivers:
  k8s_events:
    auth_type: serviceAccount

processors:
  batch:

exporters:
  otlp/uptrace:
    endpoint: api.uptrace.dev:4317
    headers:
      uptrace-dsn: 'https://<secret>@api.uptrace.dev?grpc=4317'

service:
  pipelines:
    logs:
      receivers: [k8s_events]
      processors: [batch]
      exporters: [otlp/uptrace]

See OpenTelemetry Kubernetes Events Receiver for RBAC setup, filtering, and alerting on CrashLoopBackOff and OOM events.

Go slog

otelslog bridges Go's standard log/slog to OpenTelemetry.

go
import (
    "context"
    "log/slog"

    "go.opentelemetry.io/contrib/bridges/otelslog"
    "go.opentelemetry.io/otel/exporters/stdout/stdoutlog"
    "go.opentelemetry.io/otel/log/global"
    sdklog "go.opentelemetry.io/otel/sdk/log"
)

exp, err := stdoutlog.New()
if err != nil {
    panic(err)
}

provider := sdklog.NewLoggerProvider(
    sdklog.WithProcessor(sdklog.NewSimpleProcessor(exp)),
)
defer provider.Shutdown(context.Background())

global.SetLoggerProvider(provider)

logger := otelslog.NewLogger("app_or_package_name")
logger.ErrorContext(ctx, "hello world", slog.String("error", "error message"))

The Context variants — ErrorContext, InfoContext — are what carry trace correlation. logger.Error(...) without a context produces an uncorrelated record.

Troubleshooting

Logs do not appear in the backend

Work through the pipeline in order:

  1. Is the provider shut down? Batched records are dropped if the process exits before flush. Call provider.Shutdown(ctx) on exit.
  2. Is the exporter configured? With the Java agent, logs need -Dotel.logs.exporter=otlp; the default in some versions is none.
  3. Is the endpoint right? OTLP/gRPC uses port 4317. For Uptrace Cloud, OTLP/HTTP is on 443, not 4318 — 4318 is the self-hosted default.
  4. Does the Collector see them? Add a debug exporter to the logs pipeline and watch its output:
yaml
exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    logs:
      receivers: [filelog]
      exporters: [debug, otlp/uptrace]

If debug prints records and the backend stays empty, the problem is the exporter or credentials. If debug prints nothing, the problem is the receiver.

Trace correlation is missing

  • The record was emitted outside an active span. Check that the context reaches the log call — in Go, that the context is passed as a field or through a Context variant.
  • The bridge was created before the LoggerProvider was registered globally.
  • In Java, MDC keys are only populated when the agent is attached.
  • With manual correlation, the ids ended up in the message body rather than as fields. Parse them in the Collector.

Timestamps are wrong

The filelog receiver uses ObservedTimestamp — the moment it read the line — unless you configure a timestamp block. Logs then appear bunched at collection time. Set parse_from and a layout matching your format, and set location for formats without a timezone.

Parsing errors in the Collector

Check the Collector's own logs for Error while parsing. Common causes: a regex_parser that does not match every line, a json_parser applied to lines that are not JSON, and multiline stack traces split into separate records. For the last one, configure multiline on the receiver with a line_start_pattern.

Storage costs are too high

  • Drop DEBUG in production with a filter processor rather than emitting and storing it.
  • Move constant fields from attributes to resource so they are stored once per batch.
  • Keep message strings constant and put variables in attributes — repeated identical strings compress far better.

What's next?