Kubernetes Observability

Correlating Kubernetes logs and traces

Chris Churilo
August 3, 2026
 |  
7
min read
August 3, 2026
7
min read
Kubernetes Observability

Correlating Kubernetes logs and traces means writing the same trace_id into every log line and every span, so any pod log pivots directly to the full distributed trace that produced it. Measure the payoff in incident minutes: HDFC Bank's CNCF case study reports a greater than 60% improvement in mean time to detection and roughly 50% improvement in MTTR after its Kubernetes and OpenTelemetry observability rollout.

What Kubernetes log trace correlation is and why it matters

Kubernetes log trace correlation is the practice of threading a shared identifier, the trace_id, through both your log pipeline and your tracing pipeline so the two signals join on a common key. Without it, the two signals live in separate silos: a node-level agent collects pod logs per container, while the tracing backend assembles spans per request across services. During an incident you end up grepping timestamps across namespaces trying to guess which log lines belong to which failed request.

Operational reports quantify the cost of that silo. New Relic's 2024 Observability Forecast found organizations with more unified telemetry data spent 79% fewer hours per year detecting outages (28 hours versus 225) and 76% fewer hours resolving them compared to organizations with siloed telemetry. Elastic's 2024 Observability Landscape survey found 70% of respondents wanted cross-signal correlation as additional functionality, which tells you how many teams still don't have it.

When correlation works, the debugging loop inverts. Instead of starting from a vague symptom and searching logs, you start from a slow or failed trace and jump straight to every log line emitted during that request, across every service and pod it touched.

How correlation works: trace_id as the shared key

Two identifiers do all the work. The trace_id is a globally unique 16-byte value shared by every operation in a single request; the span_id (8 bytes) identifies one specific operation within that trace. If both appear as fields in your structured logs and your spans, any backend can join them.

The W3C Trace Context recommendation standardizes how these identifiers travel between services, via the traceparent HTTP header:

version "-" trace-id "-" parent-id "-" trace-flags# Sampled:00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

The trace-id is 32 lowercase hex characters and globally unique; an all-zero value is invalid, and vendors MUST ignore the entire traceparent when it is. In trace-flags, bit 0 is the sampled flag, where 01 means the caller may have recorded trace data and 00 means it did not.

Picture a checkout request hitting three services. The gateway generates trace_id 4bf92f..., logs it, and forwards traceparent downstream. The cart service extracts the same trace_id; the payment service does the same. Each service logs the ID in its own pod logs and creates child spans under it.

Query your log backend for that one value and you get every log line the request produced, in order, across all three pods. Query your trace backend for it and you get the timing waterfall. That single shared key is the entire mechanism.

Instrumenting applications to emit trace IDs in structured logs

Emit JSON logs with trace_id and span_id as top-level fields. Top-level fields survive parsing. Collectors can promote them to queryable metadata without a fragile regex.

Automatic injection with the OpenTelemetry SDK and Operator

The OpenTelemetry Operator injects the OTel SDK into your pods without code changes. As of v0.156.0, auto-instrumentation covers these runtimes:

  • Java, .NET, Node.js, Python, and Go
  • Apache HTTPD and Nginx

You opt in per workload with a pod annotation:

instrumentation.opentelemetry.io/inject-java: "true"

For Java, Python, Node.js, and .NET, the operator injects an opentelemetry-auto-instrumentation init container that loads the SDK before your application starts. Platform teams can also apply annotations at the namespace level to opt in every service in that namespace. The automatic instrumentation docs cover the annotation values, including named Instrumentation CRs and cross-namespace references.

Go auto-instrumentation uses an eBPF sidecar agent. It requires privileged: true, runAsUser: 0, an OTEL_GO_AUTO_TARGET_EXE annotation, and a feature gate (enable-go-instrumentation) that defaults to false. OpenTelemetry disables Python logs auto-instrumentation by default; enable it with OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true or your Python pods will trace but never write trace IDs into their logs.

Language-specific fallbacks

Teams that can't run the Operator, because of security policy or unsupported runtimes, wire trace context into their logging framework directly.

  • Java: The Java MDC docs describe three MDC keys in every logging event: trace_id, span_id, and trace_flags. It supports Logback 1.0+, Log4j 2 2.7+, Log4j 1 1.2+, and JBoss Log Manager 1.1+.
  • Python: If you use Python auto-instrumentation, enable OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true; for manual logging, configure your formatter to emit IDs from the active span context.
  • Node.js: Use OpenTelemetry auto-instrumentation when possible; for manual logging, configure Pino or Winston to emit IDs from the active span context rather than passing them through every call.

A Logback pattern then emits the Java MDC values:

<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36}  trace_id=%X{trace_id} span_id=%X{span_id} - %msg%n</pattern>

Log4j2 uses the same %X{key} syntax in its pattern layout.

eBPF as a zero application instrumentation alternative

eBPF sensors read request data directly from the Linux kernel, skipping SDKs entirely.

groundcover's eBPF sensor captures traces, logs, and Kubernetes events at the kernel level with auto-correlation between them, with no code changes and no per-service agents. eBPF correlates signals at capture time instead of assembling them downstream, which removes those pipeline assembly steps entirely.

Enriching pod logs with Kubernetes metadata

Trace IDs tell you which request a log belongs to; Kubernetes resource attributes tell you where it ran. OpenTelemetry semantic conventions v1.42.0 (June 2026) graduated the core Kubernetes resource attributes to Stable, which means these names are now long-lived contracts you can build pipelines on:

  • k8s.pod.name and k8s.pod.uid
  • k8s.namespace.name
  • k8s.container.name and k8s.container.restart_count
  • k8s.node.name and k8s.node.uid
  • Name and UID pairs for Deployments, StatefulSets, DaemonSets, Jobs, CronJobs, and ReplicaSets

Both the OpenTelemetry Collector and Fluent Bit can attach these fields to records as they pass through the node-level agent. Name the attributes you want explicitly rather than accepting whatever the default set happens to be.

The Kubernetes semconv docs assign no Required level to the Stable attributes; the spec marks them Recommended or Opt-In. Treat the Recommended set as your baseline anyway, because a trace pivot that lands you on a log line with no pod or namespace context only answers half the question.

On top of resource attributes, apply unified service tagging on every signal with env, service, and version. These tags let you filter a correlated view down to "payments service, production, release 2.4" across every signal at once.

Propagating trace context across microservices

A trace ID is only useful if every service in the request path carries it forward, and this is where correlation most often breaks. Each service must read the incoming traceparent and tracestate headers and write them onto every outbound HTTP and gRPC call it makes. OTel SDKs do this automatically.

Hand-rolled HTTP clients, custom thread pools, and message queues do not, and one non-propagating hop resets the trace: everything downstream starts a new trace_id, and your logs split into two unjoinable halves.

Service meshes leave this requirement in application code, and both major meshes say so explicitly. Istio's documentation states: "Although Istio proxies can automatically send spans, extra information is needed to join those spans into a single trace. Applications must propagate this information in HTTP headers, so that when proxies send spans, the backend can join them together into a single trace." The Istio FAQ explains why: a sidecar processes both inbound and outbound requests but "has no implicit way of correlating the outbound requests to the inbound request that caused them."

Linkerd's docs make the same point: "distributed tracing requires some cooperation from the underlying application: it must propagate certain headers from inbound requests to any corresponding outbound requests."

Istio's tracing overview lists the headers your applications must forward:

  • x-request-id, used for consistent log and trace sampling
  • traceparent and tracestate (W3C)
  • For Zipkin/B3 backends: x-b3-traceid, x-b3-spanid, x-b3-parentspanid, x-b3-sampled, x-b3-flags, or the single b3 header

Check two mesh defaults before assuming W3C context flows through. Istio's default propagation format is USE_B3 (B3 headers only, for backward compatibility); W3C support requires USE_B3_WITH_W3C_PROPAGATION. And Istio's default trace sampling rate is 1%, which means 99% of mesh-generated traces are discarded before a log line can point at one. Linkerd propagates both W3C and B3 formats and prefers W3C when both headers are present.

One more requirement the meshes can't see: your loggers must read the incoming context too. A service can forward traceparentperfectly and still write logs with no trace_id if the logging framework never extracts the active span. Configure propagation and log injection separately.

Configuring the log collection pipeline

Logs leave the node through a DaemonSet collector, either Fluent Bit or the OpenTelemetry Collector (Grafana Alloy is an OTel Collector distribution with built-in Prometheus pipelines). The collector's job in a correlation pipeline is specific: parse the JSON log body, surface trace_id and span_id as fields the backend can act on, and forward without mangling the record.

The failure mode that mangles records is multi-line splitting. Container runtimes split long output: a Java stack trace becomes dozens of separate log lines, and only the first one carries your JSON structure and trace ID. Fluent Bit solves this in two layers.

First, the tail input reassembles runtime-split lines using built-in parsers, the cri parser (which uses the _p field's F/P markers for full versus partial lines) and the docker parser:

pipeline:  inputs:    - name: tail      path: /var/log/containers/*.log      multiline.parser: docker, cri

Second, a multiline filter reassembles application stack traces using built-in parsers for java, python, go, and ruby:

  filters:    - name: multiline      match: '*'      multiline.key_content: log      multiline.parser: java

Fluent Bit's docs recommend doing runtime reassembly in the tail plugin itself because "performing concatenation while reading the log file is more performant." Fluent Bit's documented constraints for the filter:

  • The multiline filter must be the first filter in the pipeline.
  • Do not define multiple multiline filters matching the same tag; it causes an infinite loop.
  • The assembly buffer defaults to a 2MB limit (multiline_buffer_limit); beyond it, Fluent Bit truncates messages and adds multiline_truncated: true.

After reassembly, a JSON parser lifts trace_id out of the log body so downstream systems receive it as a first-class field rather than a substring.

If you use the OpenTelemetry Collector on the file-log ingestion path, keep the same contract: reassemble split records before parsing, parse the JSON log body, and map trace_id and span_id onto log record fields before exporting.

Connecting logs to traces in a backend UI

The pipeline exists so an engineer can click from a log line to its trace. Each backend wires that pivot differently.

Grafana Loki to Tempo

Grafana wires this with derived fields on the Loki data source. Under Settings → Derived Fields, you define either a Regex type (a capture-group pattern like traceID=(\w+) run against the log message) or a Label type (a regex like trace[_]?id matched against label keys, including structured metadata). For an internal Tempo link, the query value must be ${__value.raw}. In provisioning YAML, escape $ as $$ because Grafana interpolates environment variables:

jsonData:  derivedFields:    - datasourceUid: tempo_uid      matcherRegex: "traceID=(\\w+)"      name: TraceID      url: '$${__value.raw}'      urlDisplayLabel: 'View Trace'

Configure the reverse trace-to-logs direction on the Tempo data source via tracesToLogsV2, with span-time shifting and tag mapping for pod and namespace. Grafana 12 or later adds rule-based Correlations with custom queries:

{service_name="$serviceName"} | trace_id=`$traceID`

Grafana marks provisioned settings read-only in the UI, so manage them in YAML from the start.

Avoiding high-cardinality pitfalls when indexing trace IDs

Keep trace_id out of indexed labels. In Loki specifically, the label best practices are blunt: "Do not extract ephemeral values like a trace ID or an order ID into a label; the values should be static, not dynamic." A trace ID is unique per request, so indexing it creates one stream per trace, the worst possible case for Loki's index.

Loki's docs spell out the consequence: high cardinality causes "Loki to build a huge index and flush thousands of tiny chunks to object storage, which significantly reduces performance and cost-effectiveness."

Store trace_id as structured metadata: values attached to log lines without touching the index. Loki's docs name trace IDs, pod names, and container IDs as the canonical use cases.

Structured metadata went GA in Loki 2.9.4 and is enabled by default in 3.0, which requires the tsdb index type and v13 schema or Loki refuses to start. Defaults cap it at 64KB and 128 entries per line; lines that exceed either limit are rejected with HTTP 400.

Querying it needs no index. Loki extracts structured metadata automatically for each returned log line and filters it with standard label matchers:

{job="example"} | trace_id="0242ac120002"

Handling sampling and orphaned log lines

Sampling creates a correlation gap that no pipeline configuration can close: most teams keep logs at 100% while traces are sampled, so a log line can carry a perfectly valid trace_id whose trace was never stored. The engineer clicks "View Trace" during an incident and gets nothing. These orphaned log lines erode trust in the pivot precisely when it matters most.

The two sampling strategies fail differently:

  • Head-based sampling: The decision happens at the trace's first span, before you know whether the request will error. Istio's mesh-generated spans default to the 1% rate noted earlier. Head-based sampling discards most traces blindly, including many of the failed requests you'll later be debugging.
  • Tail-based sampling: The decision happens after the trace completes, so it can keep errors and slow requests. Tail-based sampling reduces orphans for interesting traces but requires buffering entire traces in the collector, and it still drops the "boring" traces that turn out to matter in hindsight.

Sampling exists because ingestion-priced backends make full fidelity expensive. groundcover removes the trade-off architecturally: eBPF captures 100% of signals with zero sampling, and flat per-node pricing means keeping every trace costs the same as keeping one in a hundred.

If you run a sampled pipeline, at minimum log the sampled flag state so engineers can distinguish "trace was never recorded" from "correlation is broken."

Troubleshooting common correlation failures

When the pivot stops working, the cause is almost always one of four things. Check them in this order:

  • All-zero trace IDs: The W3C spec defines an all-zero trace-id as invalid, and vendors MUST ignore a traceparentcarrying one. Logs full of trace_id=00000000000000000000000000000000 mean the log injection is wired up but no span context is active. Check three things in order: the SDK isn't initialized, the code path runs outside any span, or an upstream service sent an invalid header that reset context.
  • Missing context across async boundaries: Thread handoffs drop context in most runtimes. Logback's manual is explicit that "a child thread does not automatically inherit a copy of the mapped diagnostic context of its parent"; for executor-managed threads, call MDC.getCopyOfContextMap() before submitting work and MDC.setContextMap() as the task's first action. Queue consumers and background jobs need explicit context extraction for the same reason.
  • Multi-line splitting: If trace IDs appear on the first line of a stack trace but the remaining lines are orphaned records, the runtime reassembly layer is missing. Confirm multiline.parser: docker, cri on the tail input and the application-language parser in the filter.
  • Misconfigured parsers and regexes: A derived-field regex with the wrong capture group, or a Fluent Bit JSON parser that never fires, fails silently: logs flow and dashboards render, but pivots go nowhere. Use Grafana's "Show example log message" feature to test derived-field extraction against real log lines. Alert on the absence of correlated data, not just on data volume.

Bonus: metrics and audit-log correlation

The same trace_id key extends beyond logs. Prometheus exemplars attach a trace reference to individual metric samples using OpenMetrics exposition:

http_requests_total{method="POST"} 1027 1395066363.000 # {trace_id="EpTxMJ40fUus7aGY"} 1.0 1395066363.000

The OpenMetrics spec says exemplars referencing a trace context SHOULD use the trace_id and span_id keys, with the exemplar's combined label length capped at 128 UTF-8 code points. Enable storage in Prometheus with --enable-feature=exemplar-storage, then set exemplarTraceIdDestinations on the Grafana Prometheus data source to make latency-spike stars on a dashboard click through to Tempo.

Tetragon (v1.7.0) emits process_exec events enriched with pod identity and a cluster-wide unique exec_id, but there is no shipped feature joining these with Kubernetes API audit logs; GitHub issue #3118 documents users correlating the two manually using exec_id and pod identity as join keys.

Getting correlated visibility without the pipeline assembly

Assembling that pipeline yourself means an SDK-injecting Operator, an audited propagation path, a DaemonSet collector with two multiline layers, escaped provisioning YAML, a structured-metadata schema migration, and a sampling policy that still orphans logs.

Any single misconfiguration silently breaks the pivot, and the platform team owns every upgrade cycle.

groundcover collapses that assembly into one component. The groundcover eBPF sensor deploys as a single DaemonSet, one pod per node, and captures logs, traces, and Kubernetes events directly from the Linux kernel with correlation applied at capture time. eBPF needs no SDK injection or per-service configuration. It also removes the header-propagation audit before application teams touch logging code.

Because capture happens at the kernel, correlation doesn't depend on every team's logging config being right, and with zero sampling, there is no sampled-away trace for a log line to point at.

The architecture is BYOC (Bring Your Own Cloud): your data plane runs in your own cloud account. It runs entirely in your VPC at every tier including free: ClickHouse for logs and traces; VictoriaMetrics for metrics.

If you want to validate the correlation claim on your own cluster, the free tier includes the full BYOC architecture. A proof of concept can be completed in a single day, with full-cluster visibility within hours of deploying the groundcover eBPF sensor.

FAQs

Deploy the OpenTelemetry Operator and annotate workloads with instrumentation.opentelemetry.io/inject-<language>: "true"; an init container loads the SDK for Java, Python, Node.js, and .NET without code changes. For Python, also set OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true, since log injection is off by default.

Either works as a DaemonSet collector; Grafana Alloy is itself an OTel Collector distribution. Configure whichever you choose to preserve trace_id, parse JSON, and perform runtime multiline reassembly before later processing.

Configure a derived field on the Loki data source, either a regex against the log body or a label match against structured metadata, with ${__value.raw} as the internal query value. Configure the reverse direction with tracesToLogsV2 on the Tempo data source.

The semantic conventions mark no Stable attribute as Required; treat the Recommended set as your baseline: k8s.pod.name, k8s.pod.uid, k8s.namespace.name, k8s.container.name, k8s.node.name, and the workload controller name/UID pairs, plus env, service, and version tags.

Sidecars generate spans but cannot correlate a pod's inbound request to its outbound calls; per Istio's own docs, "each application must collect headers from each incoming request and forward the headers to all outgoing requests triggered by that incoming request." Also check the propagation format: Istio defaults to B3 only.

Attach it as structured metadata, never as an indexed label. Structured metadata skips the index entirely, is default-on in Loki 3.0 with the tsdb index and v13 schema, and is queryable with | trace_id="..." label filters.

Teams usually keep logs at 100% while sampling traces, so a sampled-away trace leaves a valid trace ID in the log with nothing to pivot to. Tail-based sampling keeps errors and slow requests; full-fidelity capture with no sampling eliminates orphans entirely.

For Java, Python, Node.js, and .NET, yes: an annotation and an init container. Go is the exception; it needs an eBPF sidecar with privileged security context, a target-executable annotation, and a feature gate that ships disabled.

Sign up for Updates

Keep up with all things cloud-native observability.

We care about data. Check out our privacy policy.

Observability
for what comes next.

Start in minutes. No migrations. No data leaving your infrastructure. No surprises on the bill.