🔭 Why Tracing? The Limits of Logs and Metrics

Logs tell you what happened in one service. Metrics tell you aggregate rates and latencies. Neither answers: "why did this specific user's checkout request take 4.2 seconds?" Distributed tracing answers exactly that — by threading a unique ID through every service call.

📋 Logs

What happened in one service. Hard to correlate across 10 services for a single request without a shared ID.

📊 Metrics

Aggregate rates, p99 latency. Tell you that something is slow — not where or why for a specific request.

🔍 Traces

End-to-end journey of a single request. Shows every service, span duration, and causal relationships. Answers where and why.

Core Concepts

TermDefinition
TraceThe complete journey of a request from start to finish. Identified by a globally unique TraceID.
SpanA single unit of work within a trace (e.g. "call payments-svc", "SELECT query"). Has a start time, duration, and optional attributes.
Parent spanThe span that caused another span. Forms a directed acyclic graph (DAG) of causality.
Context propagationPassing TraceID + SpanID in HTTP headers (or gRPC metadata) so each service can attach its spans to the same trace.
BaggageKey-value pairs propagated alongside the trace context (e.g. user tier, A/B flag). Expensive — use sparingly.
SamplingRecording every trace is too expensive. Head-based sampling decides at trace start; tail-based sampling decides after seeing the full trace.

A Trace Visualised

0ms 200ms 400ms api-gateway POST /checkout [TraceID: abc123] 400ms checkout-svc processOrder() 240ms inventory-svc checkStock() 100ms payments-svc charge() 120ms postgres INSERT 50ms ⚠️ redis SET 12ms notification-svc sendEmail() 135ms

The postgres INSERT inside payments-svc took 50ms — a suspicious hot spot to investigate.

🗄️ Trace Backends — Jaeger vs Grafana Tempo

JaegerGrafana Tempo
OriginUber, CNCF graduatedGrafana Labs, CNCF incubating
StorageCassandra, Elasticsearch, BadgerObject storage (S3, GCS, Azure) — very cheap
Query UIJaeger UI (standalone)Grafana Explore (unified with logs/metrics)
Query languageJaeger UI searchTraceQL — structured trace querying
Best forTeams already on ELK, need mature CNCF stackTeams on Grafana stack (Loki + Prometheus) — single pane of glass
Cost at scaleHigher (Elasticsearch/Cassandra ops)Much lower (S3 object storage)

Installing Grafana Tempo (recommended for new setups)

helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

# Minimal Tempo with local storage (dev/staging)
helm install tempo grafana/tempo \
  --namespace monitoring \
  --set tempo.storage.trace.backend=local

# Production: object storage backend
helm install tempo grafana/tempo-distributed \
  --namespace monitoring \
  --set storage.trace.backend=s3 \
  --set storage.trace.s3.bucket=my-traces-bucket \
  --set storage.trace.s3.region=us-east-1

Installing Jaeger

# Via Jaeger Operator (recommended)
kubectl create namespace observability
kubectl apply -f https://github.com/jaegertracing/jaeger-operator/releases/latest/download/jaeger-operator.yaml -n observability

# Deploy a Jaeger instance (all-in-one for dev)
apiVersion: jaegertracing.io/v1
kind: Jaeger
metadata:
  name: jaeger
  namespace: observability
spec:
  strategy: allInOne    # production: use "production" with Elasticsearch

TraceQL — Query Traces Like Data

Grafana Tempo's TraceQL lets you query spans by attributes, duration, and status — like SQL for traces:

// Find all spans from checkout-svc that took > 500ms
{ .service.name = "checkout-svc" && duration > 500ms }

// Find error spans in the payments service
{ .service.name = "payments-svc" && status = error }

// Find traces that touched both checkout AND payments
{ .service.name = "checkout-svc" } >> { .service.name = "payments-svc" }

// Find DB spans slower than 100ms with specific query
{ span.db.system = "postgresql" && duration > 100ms }

🔗 Connecting the Three Pillars — Exemplars

The real power of observability comes from linking metrics → traces → logs. Exemplars are data points embedded in Prometheus metrics that carry a TraceID — clicking a high-latency data point on a Grafana graph jumps you directly to the trace.

// Expose exemplars from your Go app
histogram.With(prometheus.Labels{"handler": "/checkout"}).
    (Observe)(duration.Seconds(),
        prometheus.Labels{"traceID": span.SpanContext().TraceID().String()})

# In Grafana: enable "Exemplars" on your histogram panel
# → each point shows a TraceID → click → Tempo trace view
# → trace shows every span → click span → Loki logs for that span

Sampling Strategy

StrategyWhen decidedProsCons
Head-based At trace start (root span) Low overhead, predictable cost May drop interesting error traces (decided before outcome known)
Tail-based After full trace received Always sample errors and slow traces Must buffer traces in memory — higher collector resource use
Always-on (100%) Every span recorded Complete data for debugging Prohibitive cost at scale — only for dev/staging
💡 Practical sampling recommendation Use 1–10% head-based sampling for normal traffic plus 100% tail-based sampling for all error traces. The OTel Collector's tail sampling processor handles this elegantly — letting you capture all failures without drowning in normal traffic traces.

🔭 OpenTelemetry — The Unified Standard

OpenTelemetry (OTel, CNCF graduated) is the industry standard for instrumentation. It merges the old OpenTracing and OpenCensus projects into a single API, SDK, and protocol (OTLP). Instrument once, send to any backend.

API

Language-specific interfaces for creating spans, setting attributes, and propagating context. Stable — your app code calls this.

SDK

The implementation: batching, sampling, exporting. Configured at startup, not in application logic.

OTLP

OpenTelemetry Protocol — the wire format (gRPC or HTTP/Protobuf) for sending telemetry to collectors and backends.

Collector

A vendor-agnostic proxy/pipeline. Receives OTLP, transforms, and exports to Jaeger, Tempo, Datadog, etc. Deployed as DaemonSet or Deployment.

Instrumenting a Go service

// main.go — set up OTel tracing at startup
import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/sdk/trace"
)

func initTracer(ctx context.Context) (*trace.TracerProvider, error) {
    exporter, _ := otlptracegrpc.New(ctx,
        otlptracegrpc.WithEndpoint("otel-collector.monitoring:4317"),
        otlptracegrpc.WithInsecure(),
    )
    tp := trace.NewTracerProvider(
        trace.WithBatcher(exporter),
        trace.WithSampler(trace.TraceIDRatioBased(0.1)), // 10% sampling
        trace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceName("checkout-svc"),
            semconv.ServiceVersion("v2.1.0"),
        )),
    )
    otel.SetTracerProvider(tp)
    return tp, nil
}

// Creating spans in handler code
func (s *Server) ProcessOrder(ctx context.Context, req *pb.OrderRequest) (*pb.OrderResponse, error) {
    ctx, span := otel.Tracer("checkout").Start(ctx, "ProcessOrder")
    defer span.End()

    span.SetAttributes(
        attribute.String("order.id",      req.OrderId),
        attribute.String("customer.tier", req.CustomerTier),
        attribute.Int("item.count",       len(req.Items)),
    )

    result, err := s.inventory.CheckStock(ctx, req.Items) // context carries TraceID
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        return nil, err
    }
    return result, nil
}

Auto-instrumentation — zero code changes

The OpenTelemetry Operator can inject instrumentation automatically into pods via an annotation — no code changes required for supported languages (Java, Node.js, Python, .NET, Go via eBPF):

# Install the OTel Operator
kubectl apply -f https://github.com/open-telemetry/opentelemetry-operator/releases/latest/download/opentelemetry-operator.yaml

# Create an Instrumentation CR for Java auto-instrumentation
apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: java-instrumentation
  namespace: production
spec:
  exporter:
    endpoint: http://otel-collector.monitoring:4318
  sampler:
    type: parentbased_traceidratio
    argument: "0.1"
  java:
    image: ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-java:latest

# Annotate the pod/deployment to enable injection
metadata:
  annotations:
    instrumentation.opentelemetry.io/inject-java: "true"

OTel Collector — the routing hub

# otel-collector-config.yaml — receive OTLP, export to Tempo + Jaeger
receivers:
  otlp:
    protocols:
      grpc: { endpoint: "0.0.0.0:4317" }
      http: { endpoint: "0.0.0.0:4318" }

processors:
  batch:
    timeout: 1s
    send_batch_size: 1024
  memory_limiter:
    limit_mib: 512

exporters:
  otlp/tempo:
    endpoint: tempo.monitoring:4317
    tls: { insecure: true }
  jaeger:
    endpoint: jaeger-collector.monitoring:14250
    tls: { insecure: true }
  logging:
    loglevel: warn

service:
  pipelines:
    traces:
      receivers:  [otlp]
      processors: [memory_limiter, batch]
      exporters:  [otlp/tempo, logging]

Context Propagation — the glue

For traces to span multiple services, the TraceID must be passed in every outbound call. OTel uses the W3C Trace Context standard (traceparent header) by default:

# HTTP header injected automatically by OTel SDK
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             ^^ version  ^^ TraceID (128-bit)               ^^ SpanID  ^^ flags

# gRPC metadata equivalent
grpc-trace-bin: <binary encoded trace context>

# Older B3 format (Zipkin/Jaeger legacy)
X-B3-TraceId: 4bf92f3577b34da6a3ce929d0e0e4736
X-B3-SpanId:  00f067aa0ba902b7
X-B3-Sampled: 1

🧠 Knowledge Check

Q1. What is the difference between a Trace and a Span?

A) A Trace is a single service call; a Span is the full request journey
B) They are synonyms — both refer to the same concept
C) A Trace is the full request journey (one TraceID); a Span is a single unit of work within that trace
D) A Trace is a metric; a Span is a log entry

Q2. How does the TraceID travel from the checkout service to the payments service?

A) It is stored in a shared database that both services query
B) Via the W3C traceparent HTTP header — the OTel SDK injects and extracts it automatically
C) The Kubernetes API server forwards it as a pod annotation
D) Via a shared environment variable injected by the OTel Operator

Q3. What is a key advantage of tail-based sampling over head-based sampling?

A) It uses less memory in the OTel Collector
B) It makes the sampling decision faster, reducing latency
C) It can guarantee all error and slow traces are kept because sampling happens after the full trace is known
D) Tail-based sampling works without instrumenting application code

Q4. What is an Exemplar in the context of Prometheus and Grafana?

A) A pre-built Grafana dashboard template for common services
B) A sample span used for performance benchmarking
C) A Prometheus recording rule that approximates p99 latency
D) A data point in a Prometheus metric that carries a TraceID, linking a metric spike directly to a specific trace