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.
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"),
)
Point the exporter at your backend with environment variables rather than hardcoding the endpoint:
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.
| Language | Logs SDK status |
|---|---|
| Java | Stable |
| .NET | Stable |
| C++ | Stable |
| PHP | Stable |
| Go | Beta |
| Rust | Beta |
| Python | Development |
| JavaScript | Development |
| Ruby | Development |
| Swift | Development |
| Erlang | Development |
| Kotlin | Development |
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:
| Field | Description |
|---|---|
Timestamp | When the event happened, nanoseconds since the Unix epoch |
ObservedTimestamp | When the collection system saw it, used when Timestamp is absent |
TraceId | Trace this record belongs to |
SpanId | Span active when the record was emitted |
TraceFlags | W3C trace flags, including whether the trace was sampled |
SeverityNumber | Severity as a number from 1 to 24 |
SeverityText | Original severity string, such as WARN or warning |
Body | The message, either a string or structured data |
Resource | What produced the record: service name, version, host |
Attributes | Key-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.
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"),
)
}
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.
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())
}
A record produced this way looks like:
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.
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)
Structured logging
OpenTelemetry stores attributes as typed key-value pairs. Formatting values into the message text throws that away.
// 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, notip. Names outside the conventions should carry your own namespace, such asapp.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
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
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:
*.* 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:
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.
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
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.
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:
- Is the provider shut down? Batched records are dropped if the process exits before flush. Call
provider.Shutdown(ctx)on exit. - Is the exporter configured? With the Java agent, logs need
-Dotel.logs.exporter=otlp; the default in some versions isnone. - 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.
- Does the Collector see them? Add a
debugexporter to the logs pipeline and watch its output:
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
Contextvariant. - The bridge was created before the
LoggerProviderwas 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
filterprocessor 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?
- OpenTelemetry distributed tracing — spans and trace context
- OpenTelemetry semantic conventions — standard attribute names
- OpenTelemetry Collector — receivers, processors, exporters
- Get started with OpenTelemetry — SDK setup for your language
- OpenTelemetry APM — where logs get correlated with traces