The previous lesson left Jordan Hall holding an alert: OrdersFastBurnRate, 8% of orders are failing. Metrics tell him how much and since when; they don't tell him which orders, in which service, or why. In the Kilometre Zero monolith the answer was one grep away in a single log file. Today an order passes through Kong, three replicas of orders, two of inventory, payments, a Kafka topic and analytics, each writing inside its own container; the log for order P-2026-000125 is scattered across seven places and goes by a different name in each. This lesson builds the other two pillars of observability: structured, centralised logs, so that one identifier brings together everything that happened to a request, and distributed traces, to see the path and the timing of that request across every service. By the end, the three signals are tied together in Grafana: from the metric that alerts, to the trace that pinpoints, to the log that explains.

Contents

  1. Why tail -f no longer cuts it
  2. Structured logs: standard fields, levels and what not to log
  3. Collection pipeline: agent, store and query
  4. services/common/logs.py: JSON with an injected trace_id
  5. Distributed tracing: traces, spans and context
  6. Instrumenting with OpenTelemetry: services/common/traces.py
  7. Collector, backends and sampling
  8. A "create order" trace, annotated
  9. Bringing the three signals together
  10. Common mistakes and tips
  11. Exercises and solutions
  12. Conclusion

  1. Why tail -f no longer cuts it

On a single machine, a log is a file and an incident is investigated with tail -f, grep and less. In a distributed system that stops working for reasons that have nothing to do with convenience; they are structural:

  • The information about a request is scattered. Creating order P-2026-000125 produces lines in Kong, orders, inventory, payments and analytics. To piece the story together you have to open five terminals and guess which line in each one belongs to that order.
  • The clocks don't agree. As we saw in 01-05, sorting lines from different machines by timestamp can swap cause and effect. You need a correlation identifier, not just the time.
  • Containers are ephemeral. When Kubernetes (07-05) replaces an orders pod, its log disappears with it. If the failure happened just before it died, the evidence is gone.
  • Free-form text can't be queried. Order 125 rejected for stock (pink-tomato) is readable for a person; to ask "how many stock rejections of pink-tomato in Lleida in the last hour" you have to parse it with brittle regular expressions.
  • Volume. 140 couriers sending a position every 5 s, 100 requests/s on orders: that is millions of lines a day. Nobody reads them; you need to be able to filter them.

The solution has two parts: every service writes structured logs with common fields, and a pipeline collects them from every node into a central, queryable store.

  1. Structured logs: standard fields, levels and what not to log

A structured log is a record with named fields, normally one JSON line per event:

{"ts": "2026-09-12T10:13:41.208Z", "level": "warning", "service": "orders", "instance": "orders-2",
 "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7",
 "request_id": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f", "user": "u:9f1c3a",
 "event": "stock_reservation_rejected", "order_id": "P-2026-000125",
 "product": "crianza-wine", "requested": 6, "available": 2, "replica": "inv-bcn"}

The fields that every service in km0/ always includes:

Field Content Why
ts Instant in UTC, ISO 8601 with milliseconds Without a time zone there is no way to order across nodes; UTC sidesteps daylight saving changes
level debug, info, warning, error, critical Filtering and alerting
service orders, inventory, ... The first filter in any query
instance orders-2, the pod name Telling a sick replica apart
trace_id OpenTelemetry trace identifier (32 hex) Correlation across services and with traces
span_id Current span (16 hex) Linking the line to the specific step of the trace
request_id The X-Request-Id that Kong generates and propagates (06-05) It is the identifier the client sees and that Anna can give to support
user Pseudonymised identifier of the subject (06-02) Investigating without exposing identity
event Short, stable name of what happened (order_created, stock_reservation_rejected) Counting and filtering by type without parsing the message
message (optional) Text for humans Additional context

Specific fields (order_id, product, replica) are added as extra keys, not inside the message. And yes: order_id is welcome here. What was a cardinality problem in metrics (07-01) is exactly what you want in logs, because a log is indexed by text or by a handful of labels; it doesn't create a series per value.

Levels

Level When Example in orders
debug Detail for development; switched off in production except for a specific investigation Body of the gRPC request to inventory
info Normal business milestones order_created, saga_completed
warning Something unexpected that the system has handled stock_reservation_rejected, a retried call
error An operation has failed and someone should take a look payment_technical_error, compensation_failed
critical The service cannot carry on Cannot connect to km0_orders at start-up

What not to log

Logs travel over the network, are stored for months and are read by a lot of people. Everything Module 6 protected with encryption and access control can end up in the clear in a log if nobody is watching:

  • Secrets: JWT tokens (not even "for debugging"), passwords, Vault credentials, API keys for the payment gateway, full Authorization headers. An HTTP trace at debug that dumps headers is a leak.
  • Personal data in the clear: Anna's name, email, phone number and address. You record user: "u:9f1c3a" (the pseudonym from 06-02) and order_id; anyone who needs the phone number gets it from the database with their own permission and their own audit trail.
  • Card data: never, in any form (06-02).
  • Full request bodies: besides personal data, they are huge. Log the relevant fields, picked one by one.

The audit logs from 06-05 (who did what, hash chain, object lock in MinIO) are a separate channel with different guarantees: immutability and long retention. The operational logs in this lesson are for diagnosis, are kept for weeks and can be deleted; the two must not be mixed.

  1. Collection pipeline: agent, store and query

flowchart LR
    subgraph node1["Node 1"]
        O1[orders-1<br/>stdout JSON]
        I1[inventory-1<br/>stdout JSON]
        A1[Promtail]
        O1 --> A1
        I1 --> A1
    end
    subgraph node2["Node 2"]
        O2[orders-2]
        K[Kong]
        A2[Promtail]
        O2 --> A2
        K --> A2
    end
    L[(Loki<br/>label index<br/>chunks in MinIO km0-logs)]
    G[Grafana<br/>LogQL]
    A1 --> L
    A2 --> L
    G --> L

Each service writes JSON to stdout (not to files of its own: the container shouldn't know where its logs end up). One agent per node reads the output of every container on the node, adds labels (service, instance, node) and ships it to the central store. Queries are run from an interface that understands the format.

Component "Loki" option "ELK" option Others
Agent Promtail (or Grafana Alloy) Filebeat, Logstash Fluent Bit / Fluentd (both stacks), Vector
Store Loki Elasticsearch / OpenSearch
Query Grafana (LogQL) Kibana / OpenSearch Dashboards (KQL, Lucene)

Loki versus Elasticsearch, which is the real architectural decision:

Aspect Loki Elasticsearch
What it indexes Only the labels (service, instance, level); the content is stored compressed and unindexed The full text of every field (inverted index)
Storage cost Low (compressed chunks in an object bucket, e.g. MinIO 04-03) High (the index can take up more than the data)
Ingestion cost Low High in CPU and memory
Query "everything from orders with trace_id=X in the last hour" Filters by label and then scans that hour's content: fast if the range is bounded Instant thanks to the index
Free-text search across months of data Slow (scan) Fast
Analytics over logs (complex aggregations) Limited (LogQL has metrics over logs, but it is not an analytics engine) Powerful
Integration Native with Grafana, Prometheus and Tempo: same label language, log↔trace↔metric links Kibana; its own APM
When Operational diagnosis with known labels, a tight budget, the Grafana stack Heavy exploratory search, compliance with ad hoc searches, teams already running Elasticsearch

Kilometre Zero chooses Loki because its query pattern is almost always "service + range + trace_id", storage goes to MinIO and it already uses Grafana and Prometheus. Cardinality matters again, this time in Loki's labels: service, instance and level are labels; trace_id, order_id and user are not (they would create a stream per value); you search for them inside the content with | json | trace_id="...".

Retention and cost

Logs grow without limit unless someone decides otherwise. A typical policy: debug is not shipped in production; info is kept for 14 days; warning/error for 90 days; Kong logs (one line per request) for 30 days; audit logs are a different system with years of retention. In Loki retention is configured per stream and old chunks are deleted from the bucket. Reducing volume at the source (not logging every delivery position, but a per-minute summary plus the rejections) is usually the most effective measure.

A minimal Promtail and Loki configuration in docker-compose.yml:

# km0/docker-compose.yml (excerpt)
  loki:
    image: grafana/loki:3.1.0
    command: ["-config.file=/etc/loki/loki.yaml"]
    volumes:
      - ./observability/loki.yaml:/etc/loki/loki.yaml:ro
    ports: ["3100:3100"]
  promtail:
    image: grafana/promtail:3.1.0
    command: ["-config.file=/etc/promtail/promtail.yaml"]
    volumes:
      - ./observability/promtail.yaml:/etc/promtail/promtail.yaml:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
# km0/observability/promtail.yaml
server:
  http_listen_port: 9080
clients:
  - url: http://loki:3100/loki/api/v1/push
scrape_configs:
  - job_name: containers
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
    relabel_configs:
      # The compose label "com.docker.compose.service" becomes the "service" label
      - source_labels: [__meta_docker_container_label_com_docker_compose_service]
        target_label: service
      - source_labels: [__meta_docker_container_name]
        regex: "/(.*)"
        target_label: instance
    pipeline_stages:
      - json:
          expressions:
            level: level
      - labels:
          level:            # only "level" is promoted to a label; trace_id stays in the content
# km0/observability/loki.yaml (relevant excerpt)
storage_config:
  aws:
    s3: s3://km0-logs
    endpoint: minio:9000
    access_key_id: ${LOKI_MINIO_KEY}
    secret_access_key: ${LOKI_MINIO_SECRET}
    s3forcepathstyle: true
limits_config:
  retention_period: 336h        # 14 days by default
compactor:
  retention_enabled: true

  1. services/common/logs.py: JSON with an injected trace_id

With structlog, each service configures the logger once and then uses log.info("event", key=value). A processor looks up the OpenTelemetry context (section 6) and adds trace_id and span_id if there is an active span:

# km0/services/common/logs.py
"""Structured JSON logs with correlation by trace_id / request_id."""
import logging
import os
import sys
from contextvars import ContextVar

import structlog
from opentelemetry import trace

# Kong's request_id is kept in a ContextVar: it is local to the request
# in progress (works with threads and with asyncio) and any log can read it.
current_request_id: ContextVar[str | None] = ContextVar("request_id", default=None)

SERVICE = os.environ.get("KM0_SERVICE", "unknown")
INSTANCE = os.environ.get("HOSTNAME", "local")

# Keys that must never appear in a log, even if someone passes them by mistake.
FORBIDDEN_KEYS = {"authorization", "password", "passwd", "token", "card", "cvv", "phone", "email"}


def inject_context(logger, method, event: dict) -> dict:
    """structlog processor: adds service, instance, trace_id, span_id and request_id."""
    event["service"] = SERVICE
    event["instance"] = INSTANCE
    span = trace.get_current_span()
    ctx = span.get_span_context()
    if ctx.is_valid:
        # format(x, "032x") = 32 hexadecimal digits, the W3C format
        event["trace_id"] = format(ctx.trace_id, "032x")
        event["span_id"] = format(ctx.span_id, "016x")
    rid = current_request_id.get()
    if rid:
        event["request_id"] = rid
    return event


def redact(logger, method, event: dict) -> dict:
    """Processor: replaces the value of any sensitive key with '[redacted]'."""
    for key in list(event):
        if key.lower() in FORBIDDEN_KEYS:
            event[key] = "[redacted]"
    return event


def configure_logs(level: str = "INFO") -> None:
    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,   # fields bound with bind_contextvars
            structlog.processors.add_log_level,         # -> "level"
            structlog.processors.TimeStamper(fmt="iso", utc=True, key="ts"),
            inject_context,
            redact,
            structlog.processors.EventRenamer("event"),   # the first argument becomes "event"
            structlog.processors.JSONRenderer(),
        ],
        wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, level)),
        logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
    )


log = structlog.get_logger()

How it is used in orders, with the middleware that captures the X-Request-Id propagated by Kong:

# km0/services/orders/app.py (excerpt)
from services.common.logs import log, current_request_id, configure_logs

configure_logs(level=os.environ.get("KM0_LOG_LEVEL", "INFO"))


@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
    rid = request.headers.get("x-request-id", "no-request-id")
    token = current_request_id.set(rid)
    try:
        return await call_next(request)
    finally:
        current_request_id.reset(token)


# In the saga (03-05), when a reservation is rejected:
log.warning("stock_reservation_rejected", order_id=order.id, product=line.product,
            requested=line.quantity, available=resp.available, replica=resp.replica)

Details worth noticing:

  • structlog doesn't format strings: log.warning("stock_reservation_rejected", product=...) produces separate keys. Writing log.warning(f"Rejected {product}") works, but loses the structure. The discipline is "stable event name + fields".
  • The redact processor is a safety net, not the policy: the policy is not to pass that data in. But it costs little and stops a log.debug("request", headers=dict(request.headers)) written late on a Tuesday night from dumping tokens.
  • The trace_id is read from the active OpenTelemetry span. Without tracing configured, the context is invalid and it simply doesn't appear; with it, every log line from any service taking part in the same request carries the same trace_id, even though the services have done nothing explicit to pass it along.

LogQL queries in Grafana:

# Everything that happened in a given trace, across all services, in order
{service=~"orders|inventory|payments|analytics"} | json | trace_id="4bf92f3577b34da6a3ce929d0e0e4736"

# Stock rejections per product in the last hour (a metric derived from logs)
sum by (product) (count_over_time({service="orders"} | json | event="stock_reservation_rejected" [1h]))

# Errors from orders-2 that don't come from the payment gateway
{service="orders", instance="orders-2", level="error"} | json | event != "payment_technical_error"

# What Anna reports to support: her request_id
{service=~".+"} | json | request_id="c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f"

The second query shows a valuable use: metrics from logs for questions that don't deserve a metric of their own (07-01, exercise 1) but that you do want to graph now and then.

  1. Distributed tracing: traces, spans and context

Logs with a trace_id answer "what happened"; traces answer "where it went and how long each step took". The model, inherited from Dapper (Google) and standardised by OpenTelemetry:

  • A trace is the whole tree of work caused by one external request. It is identified by a 128-bit trace_id.
  • A span is a unit of work with a name, start, end, attributes (order_id, rpc.method, db.statement), status (OK/ERROR) and events. It has a span_id and a parent_span_id, except for the root.
  • The propagation context is what travels from one service to the next so that the receiver's span hangs off the sender's. The standard is W3C Trace Context: a traceparent header:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             ^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ ^^
          version          trace_id (32 hex)      parent span    flags (01 = sampled)

(Plus an optional tracestate for vendor data.) In HTTP and gRPC it is a header; in Kafka, a message header; in the saga, it is stored alongside the state in the sagas table so that a compensation hours later still hangs off the original trace.

sequenceDiagram
    participant Anna
    participant Kong
    participant Ord as orders-2
    participant Inv as inventory-1
    participant Pay as payments
    participant Kafka
    participant Anl as analytics
    Anna->>Kong: POST /api/v1/orders
    Note over Kong: creates trace_id 4bf9...<br/>root span "kong.proxy"
    Kong->>Ord: POST /orders<br/>traceparent: 00-4bf9...-a1..-01<br/>X-Request-Id: c1d2...
    Note over Ord: span "POST /orders" child of a1
    Ord->>Inv: gRPC ReserveStock<br/>metadata traceparent: 00-4bf9...-b2..-01
    Note over Inv: span "ReserveStock" + span "SELECT ... FOR UPDATE"
    Inv-->>Ord: OK
    Ord->>Pay: gRPC Charge (traceparent ...-b2..)
    Pay-->>Ord: OK
    Ord->>Kafka: order.confirmed<br/>header traceparent: 00-4bf9...-c3..-01
    Ord-->>Kong: 201
    Kong-->>Anna: 201
    Kafka-->>Anl: consume (140 ms later)
    Note over Anl: span "consume orders.events"<br/>linked to c3

  1. Instrumenting with OpenTelemetry: services/common/traces.py

OpenTelemetry (OTel) brings three things: an SDK that creates spans and manages context, automatic instrumentations for well-known libraries (gRPC, FastAPI, psycopg, redis, requests, kafka-python/confluent-kafka), and an export protocol (OTLP) towards a Collector or a backend. The km0/ services share the configuration:

# km0/services/common/traces.py
"""OpenTelemetry configuration for distributed tracing."""
import os

from opentelemetry import trace, context, propagate
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_INSTANCE_ID
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.grpc import GrpcInstrumentorServer, GrpcInstrumentorClient
from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.propagators.textmap import Getter, Setter


def configure_traces(service: str) -> trace.Tracer:
    resource = Resource.create({
        SERVICE_NAME: service,                          # "orders", "inventory"...
        SERVICE_INSTANCE_ID: os.environ.get("HOSTNAME", "local"),
        "deployment.environment": os.environ.get("KM0_ENVIRONMENT", "production"),
    })
    # ParentBased: if the request arrives with a sampling decision already made (flags=01), it is honoured;
    # if it is a root, 10% is sampled. That way a trace is never left "half done".
    sampler = ParentBased(root=TraceIdRatioBased(float(os.environ.get("KM0_TRACES_RATIO", "0.1"))))
    provider = TracerProvider(resource=resource, sampler=sampler)
    exporter = OTLPSpanExporter(endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector:4317"),
                                insecure=False)   # mTLS with the Vault certificate (06-04)
    provider.add_span_processor(BatchSpanProcessor(exporter))   # exports in batches, in a separate thread
    trace.set_tracer_provider(provider)

    # Auto-instrumentation: these patch the libraries to create spans and propagate traceparent
    GrpcInstrumentorServer().instrument()
    GrpcInstrumentorClient().instrument()
    PsycopgInstrumentor().instrument(enable_commenter=True)   # adds the trace_id as an SQL comment
    RedisInstrumentor().instrument()
    return trace.get_tracer(service)


# ---- Manual propagation through Kafka headers ---------------------------------
# Kafka headers are a list of (key, bytes); OTel needs a Getter/Setter
# that knows how to read and write them.

class _KafkaSetter(Setter):
    def set(self, carrier: list, key: str, value: str) -> None:
        carrier.append((key, value.encode("utf-8")))


class _KafkaGetter(Getter):
    def get(self, carrier: list, key: str):
        return [v.decode("utf-8") for k, v in carrier if k == key] or None

    def keys(self, carrier: list):
        return [k for k, _ in carrier]


def inject_into_kafka_headers() -> list[tuple[str, bytes]]:
    """Returns Kafka headers carrying the traceparent of the active span."""
    headers: list[tuple[str, bytes]] = []
    propagate.inject(headers, setter=_KafkaSetter())
    return headers


def context_from_kafka_headers(headers: list[tuple[str, bytes]] | None):
    """Rebuilds the trace context from the headers of a message."""
    return propagate.extract(headers or [], getter=_KafkaGetter())

How orders uses it when publishing the event after the saga, and analytics when consuming it:

# km0/services/orders/events.py (excerpt)
from services.common.traces import inject_into_kafka_headers

def publish_order_confirmed(producer, order):
    with tracer.start_as_current_span("publish orders.events",
                                      kind=trace.SpanKind.PRODUCER,
                                      attributes={"messaging.system": "kafka",
                                                  "messaging.destination": "orders.events",
                                                  "km0.order_id": order.id}):
        producer.produce("orders.events", key=order.id.encode(), value=order.to_json().encode(),
                         headers=inject_into_kafka_headers())   # traceparent travels with the message
# km0/services/analytics/orders_consumer.py (excerpt)
from opentelemetry import context, trace
from services.common.traces import context_from_kafka_headers

for msg in consumer:
    ctx = context_from_kafka_headers(msg.headers())
    # The consumer span is a child of the "publish" span in orders: same trace, 140 ms later
    with tracer.start_as_current_span("consume orders.events", context=ctx,
                                      kind=trace.SpanKind.CONSUMER,
                                      attributes={"messaging.kafka.partition": msg.partition(),
                                                  "messaging.kafka.offset": msg.offset()}):
        process(msg)
        log.info("event_processed", order_id=msg.key().decode())   # carries the trace_id from orders

Two clarifications:

  • gRPC, HTTP and PostgreSQL need no code: the instrumentors intercept the calls, create spans and write/read traceparent in the metadata. In inventory, AuthInterceptor and the MetricsInterceptor from 07-01 live alongside the OTel interceptor; the order does not affect the context.
  • Kafka does need manual propagation (or the confluent-kafka instrumentor, which does the same under the hood): the message is an inert piece of data consumed later, in another process; nobody "calls" it. The same goes for the saga: order_saga.py stores traceparent in the row of the sagas table, and when the outbox relay or a compensation picks the work back up, they rebuild the context with propagate.extract on that value. Without this, the compensation for P-2026-000125 would show up as an orphan trace with no relation to the order.

  1. Collector, backends and sampling

Services don't send traces to the backend directly but to the OpenTelemetry Collector: an intermediate process that receives OTLP, processes it (adds attributes, filters, batches, samples) and exports to one or more destinations. Advantages: services only know one endpoint; switching from Jaeger to Tempo means changing the Collector; tail sampling happens there.

# km0/observability/otel-collector.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
        tls:
          cert_file: /certs/otel-collector.crt     # issued by Vault PKI (06-04)
          key_file: /certs/otel-collector.key
          client_ca_file: /certs/ca.crt

processors:
  batch:
    timeout: 5s
  memory_limiter:
    limit_mib: 512
    check_interval: 1s
  # Tail sampling: decides with the complete trace. Keeps every trace that has an error
  # or lasts longer than 500 ms (the SLO from 07-01), and 10% of the rest.
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: slow
        type: latency
        latency: {threshold_ms: 500}
      - name: rest
        type: probabilistic
        probabilistic: {sampling_percentage: 10}

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls: {insecure: true}       # internal compose network; in production, mTLS
  # otlp/jaeger:                # equivalent alternative
  #   endpoint: jaeger:4317

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, tail_sampling, batch]
      exporters: [otlp/tempo]
# km0/docker-compose.yml (excerpt)
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.108.0
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      - ./observability/otel-collector.yaml:/etc/otelcol/config.yaml:ro
      - ./certs/otel-collector:/certs:ro
    ports: ["4317:4317"]
  tempo:
    image: grafana/tempo:2.5.0
    command: ["-config.file=/etc/tempo/tempo.yaml"]
    volumes:
      - ./observability/tempo.yaml:/etc/tempo/tempo.yaml:ro
    # tempo.yaml points block storage at the km0-traces bucket in MinIO

Backends

Backend Model Storage Integration When
Jaeger The CNCF classic; its own very mature UI for exploring traces Cassandra, Elasticsearch, or its own store Receives OTLP; Grafana datasource Teams that want the Jaeger UI or already have Cassandra/ES
Tempo Only stores and indexes by trace_id (plus TraceQL to search by attributes) Object storage (MinIO, S3): very cheap Native in Grafana; links from Loki and Prometheus Grafana stack, high volume, a tight budget
Zipkin The pioneer (Twitter); its own format, which OTel also exports Cassandra, ES, MySQL Its own UI Legacy systems that already use it

Kilometre Zero uses Tempo, with the blocks in the km0-traces bucket in MinIO, for consistency with Loki and Grafana.

Sampling: head versus tail

Keeping every trace of 100 requests/s with 8-12 spans each is expensive and almost pointless: most are identical and successful. Two strategies:

Sampling Where the decision is made With what information Advantage Drawback
Head (TraceIdRatioBased in the SDK) In the first service, when the trace is created None: it is a probability Cheap; services that don't sample don't even create spans Discards 90% of errors, which is exactly what you care about
Tail (tail_sampling in the Collector) In the Collector, once the trace is complete Duration, status, attributes Keeps 100% of traces with errors or slow ones Services send everything; the Collector needs memory to wait 10 s per trace

The usual approach, and what km0/ does, is to combine them: a ParentBased sampler in the SDK so that the decision is consistent across the whole trace, a high ratio at the source (or 100% on low-traffic services) and tail sampling in the Collector to keep what is interesting.

  1. A "create order" trace, annotated

This is how trace 4bf92f35... for Anna's order P-2026-000125, which took 482 ms and ended well, appears in Grafana (Tempo). Indentation shows the parent-child relationship:

Span Service Start (ms) Duration (ms) Relevant attributes / remarks
kong.proxy POST /api/v1/orders Kong 0 482 http.status_code=201, km0.request_id=c1d2...
POST /orders orders-2 3 476 km0.order_id=P-2026-000125, enduser.pseudo=u:9f1c3a
  ⎿ saga.reserve_stock orders-2 6 118 manual span from order_saga.py
    ⎿ km0.Inventory/ReserveStock (client) orders-2 7 116 rpc.grpc.status_code=0, deadline 300 ms
      ⎿ km0.Inventory/ReserveStock (server) inventory-1 9 111 net.peer.name=orders-2 (mTLS, SPIFFE)
        ⎿ SELECT km0_inventory inventory-1 11 4 db.statement=SELECT ... FROM stock WHERE product=$1 FOR UPDATE
        ⎿ UPDATE km0_inventory inventory-1 16 98 db.statement=UPDATE stock SET ...: lock wait
        ⎿ COMMIT inventory-1 115 5
  ⎿ saga.charge orders-2 126 331
    ⎿ km0.Payments/Charge (client → server) orders-2 → payments 127 329
      ⎿ POST gateway.example/charges payments 131 318 http.status_code=200: the external gateway dominates the latency
  ⎿ INSERT km0_orders (Cassandra) orders-2 459 9 db.system=cassandra, consistency LOCAL_QUORUM
  ⎿ publish orders.events orders-2 469 8 messaging.destination=orders.events
consume orders.events analytics 612 21 child of "publish": starts 140 ms after Anna already has her 201

What the trace tells us that the metrics couldn't:

  • Of the 482 ms, 318 are the external payment gateway. Nothing in km0/ can speed them up; what you can do is run the reservation and the charge in parallel, or accept the order as "pending confirmation" (07-04).
  • The UPDATE in inventory took 98 ms on an operation that normally takes 2: there is lock contention on crianza-wine (Grape Harvest Week, lots of orders for the same product). The p99 metric for ReserveStock showed it climbing; the trace says which statement.
  • The analytics span belongs to the same trace even though it happens after the response: that is the effect of propagating traceparent in the Kafka headers.
  • Every span carries the trace_id, so one click takes you to the log lines of all five services with that identifier, in order.

  1. Bringing the three signals together

Observability isn't three tools but a journey: the metric says something is wrong, the trace says where, the log says why. Grafana lets that journey be three clicks:

  • Exemplars: Prometheus can store, next to each histogram bucket, the trace_id of a recent observation. In prometheus_client you pass exemplar={"trace_id": ...} to observe(); on the p99 graph, each point has a diamond that opens that trace. This is the metric → trace link.
  • Log ↔ trace link: the Loki datasource in Grafana is configured with a derived field that recognises trace_id in the JSON and shows a "view trace in Tempo" button; and Tempo, the other way round, with "logs for this trace", which runs {service=~".+"} | json | trace_id="..." in Loki.
  • Trace → metric: Tempo can generate RED metrics from spans (span metrics), useful for services that aren't instrumented with Prometheus.
# km0/observability/grafana/provisioning/datasources/observability.yml
apiVersion: 1
datasources:
  - name: Loki
    type: loki
    url: http://loki:3100
    jsonData:
      derivedFields:
        - name: trace_id
          matcherRegex: '"trace_id":\s*"(\w+)"'
          url: "$${__value.raw}"
          datasourceUid: tempo
  - name: Tempo
    type: tempo
    uid: tempo
    url: http://tempo:3200
    jsonData:
      tracesToLogsV2:
        datasourceUid: loki
        filterByTraceID: true
        tags: [{key: "service.name", value: "service"}]
      tracesToMetrics:
        datasourceUid: prometheus

An example of an exemplar in metrics.py (07-01), now that the trace context exists:

# km0/services/common/metrics.py (addition)
def _current_exemplar() -> dict | None:
    ctx = trace.get_current_span().get_span_context()
    return {"trace_id": format(ctx.trace_id, "032x")} if ctx.is_valid and ctx.trace_flags.sampled else None

# inside measure(), in the finally:
REQUEST_DURATION.labels(...).observe(duration, exemplar=_current_exemplar())

With this in place, at 10:13 on Saturday Jordan's journey is: OrdersFastBurnRate alert → dashboard with the error rate → exemplar diamond over the spike → trace of a failed order with the ReserveStock span in red and rpc.grpc.status_code=UNAVAILABLE → "logs for this trace" → {"event": "circuit_open", "dependency": "inventory"}... which is where the rest of the module comes in.

Common Mistakes and Tips

  • Free-text logs "because they read better". They read better one at a time; they can't be queried. Stable event name + fields; for reading locally, structlog has a colourised console renderer.
  • Promoting trace_id or order_id to a Loki label. Every value creates a stream and Loki degrades just as Prometheus does with high cardinality. Labels: service, instance, level, environment. Everything else, in the JSON.
  • Logging full headers or bodies at debug and leaving it switched on. This is the most common leak of tokens and personal data. A redaction processor as a net, and debug off by default.
  • Losing the context across asynchronous hops. A ThreadPoolExecutor, an asyncio.create_task or a Kafka message without propagate.inject breaks the trace. When a trace "ends abruptly", look for the hop with no propagation.
  • Head sampling only. It discards 90% of errors. Tail sampling in the Collector to keep errors and slow traces.
  • Traces without business attributes. A POST /orders span without km0.order_id forces you to search by time. Add the domain identifier as an attribute (there is no cardinality problem in traces and logs).
  • Confusing operational logs with auditing. Retention, immutability and access are different (06-05). The audit trail doesn't go to Loki with 14 days, and debug logs don't go to the bucket with object lock.
  • Sending traces straight to the backend from each service. It couples every service to the backend and makes tail sampling impossible. Put a Collector in between.

Exercises

Exercise 1. Mark tells support that his Artisan Cheese Week order "gave an error" and provides the X-Request-Id the website showed him: 7a1b.... Martha opens Grafana. (a) Write the LogQL query that gathers everything that happened to that request across all services. (b) The query returns lines from Kong and orders with trace_id 9c4d..., but none from inventory; instead, there are inventory lines with a different trace_id in the same second. What has broken and where would you look for it? (c) On the orders line Martha finds the field "user": "u:2b77e1". How does she confirm that it is Mark without anyone else being able to do so on their own, and why doesn't the log contain his email?

Exercise 2. The team decides that delivery should publish one log line for every position received (140 couriers, one every 5 s) at the info level, and that debug should include the message body. (a) Work out how many lines a day delivery alone generates and estimate the volume if each line takes up 350 bytes. (b) Propose an alternative that preserves the ability to investigate "why the dashboard showed van-3-017 in Girona when it was in Lleida" without that volume. (c) What privacy problem does debug with the body add, and which mechanism from this lesson would you use to mitigate it as a last resort?

Exercise 3. The trace in section 8 shows 318 ms in the payment gateway and 98 ms of lock wait in inventory. A colleague suggests "setting TraceIdRatioBased(1.0) on every service so we never lose a trace like this one". (a) Work out how many spans per second the Collector would receive with 100 requests/s to orders and 12 spans per trace, plus the catalog traffic (400 requests/s, 4 spans). (b) Explain why the otel-collector.yaml configuration in section 7 would already have kept this trace even if the ratio were 0.1, and what would have happened if the 0.1 ratio had been configured without ParentBased. (c) A span from the saga's compensation, executed two hours after the order failed, shows up as a new trace rather than inside the order's trace. What is missing, and where?

Solutions

Exercise 1.

(a) {service=~".+"} | json | request_id="7a1b...", with the time range narrowed down to the moment of the order (Loki scans the content, so the range matters). (b) Kong and orders share a trace_id, so HTTP propagation works; inventory has a different trace_id at the same instant, so inventory is creating new traces instead of continuing the one from orders: the traceparent isn't arriving in the gRPC metadata or isn't being read. Suspects in order: GrpcInstrumentorClient not instrumented in orders (the client doesn't inject), a custom interceptor in inventory that rebuilds the metadata and drops traceparent (check AuthInterceptor/ServiceInterceptor from 06-04), or a gRPC stub created before configure_traces was called. You check it with a log.debug of the metadata received in inventory (redacting authorization) or by looking in Tempo at whether the inventory server span is a root. (c) The pseudonymisation from 06-02 is a keyed function (HMAC) over the user identifier: Martha asks the identity service, with her operator role and her support ticket (it is audited, 06-05), to resolve u:2b77e1; or she computes the pseudonym of Mark's sub and compares. The log doesn't contain the email because it is personal data that would travel to Loki, to MinIO and to the screen of anyone with access to Grafana: what gets recorded is the pseudonym, which identifies nobody without the key, and the order_id, which is what support needs.

Exercise 2.

(a) 140 × (86,400 / 5) = 2,419,200 lines a day; at 350 bytes, about 847 MB a day uncompressed from positions alone, about 25 GB a month. (b) Log at info only what is anomalous (positions rejected by validation, impossible jumps between two positions, out-of-order messages) plus a summary per courier per minute (positions_received, last_ts); to reconstruct the route of van-3-017, the data is already in delivery.positions with hours of retention and in the HDFS lake (04-02): investigate by querying that data, not the log. In addition, one sampled trace per courier every N messages gives the internal path without logging each one. (c) The body contains the exact position of an employee along with their identifier, which is personal data (06-05, exercise 3); if someone switches on debug in production, it gets copied to Loki without the access guarantees of delivery.positions. As a last resort, the redact processor with position/lat/lon in FORBIDDEN_KEYS (and with the body logged as fields, never as an opaque string, so that the redactor can do its job); but the right policy is not to log the body.

Exercise 3.

(a) orders: 100 × 12 = 1,200 spans/s; catalog: 400 × 4 = 1,600 spans/s; about 2,800 spans/s in total, 242 million a day, which the Collector must receive and hold for 10 s each in order to decide (about 28,000 spans permanently in memory, feasible), and of which Tempo would write whatever the policies let through. With TraceIdRatioBased(1.0) the cost is in the network and in the Collector, not in storage, as long as there is tail sampling. (b) The trace lasts 482 ms and the slow policy keeps everything over 500 ms... so it would not keep it on duration; but that doesn't matter either: if it had a span with an error, the errors policy stores it; if not, it falls into the probabilistic 10%. With the 0.1 head ratio, the decision was taken by Kong (or orders) when the trace was created, and ParentBased makes every service honour it: either the whole trace is sampled or nobody generates it. Without ParentBased, each service would roll its own dice: orders would keep it and inventory wouldn't, and the trace would be left with holes (the server ReserveStock span missing, precisely the one with the 98 ms). (c) What is missing is storing the order's traceparent in the row of the sagas table when the saga is created, and, when running the compensation, rebuilding the context with propagate.extract on that value and creating the compensation span with context=ctx (or as a link to the original trace if you prefer a new, linked trace). It belongs in order_saga.py, at the point where the relay or the scheduler picks up a pending saga.

Conclusion

With this lesson, Kilometre Zero's observability has its three signals. Logs stop being text in the files of ephemeral containers and become JSON events with common fields (ts in UTC, level, service, instance, trace_id, request_id, pseudonymised user, event), free of secrets and personal data, collected by Promtail on each node and stored in Loki with low-cardinality labels, queryable with LogQL by the X-Request-Id that Kong assigns or by the trace_id that OpenTelemetry injects. Traces give the path: nested spans with the W3C traceparent propagated through HTTP and gRPC with no code, and by hand through Kafka headers and the sagas table, sent to a Collector that tail-samples to keep errors and slowness, and stored in Tempo. And all three come together in Grafana with exemplars and log↔trace links, so that an alert turns, in three clicks, into a trace with the guilty span and the log lines that explain it.

We now know that it fails, where and why. Jordan's journey ended at an orders log saying circuit_open towards inventory, and in Tempo a ReserveStock span in red with UNAVAILABLE: inventory-1 isn't responding. The next question isn't about observability but about survival: how does the platform detect that a node has gone down and isn't merely slow? Who decides that inv-vlc becomes the primary, and how do you stop inv-bcn from coming back believing it still is? And what if what has been lost is not a process but the last two hours of km0_inventory data? The next lesson covers failure management and recovery: detection, failover with fencing, backups and PITR, and what to do during the minutes in which all of the above is happening.

Distributed Architectures Course

Module 1: Introduction to Distributed Systems

Module 2: Communication in Distributed Systems

Module 3: Consistency and Replication

Module 4: Distributed Storage

Module 5: Distributed Computing

Module 6: Security in Distributed Systems

Module 7: Monitoring and Maintenance

Module 8: Case Studies and Applications

© Copyright 2026. All rights reserved