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
- Why
tail -fno longer cuts it - Structured logs: standard fields, levels and what not to log
- Collection pipeline: agent, store and query
services/common/logs.py: JSON with an injectedtrace_id- Distributed tracing: traces, spans and context
- Instrumenting with OpenTelemetry:
services/common/traces.py - Collector, backends and sampling
- A "create order" trace, annotated
- Bringing the three signals together
- Common mistakes and tips
- Exercises and solutions
- Conclusion
- Why
tail -f no longer cuts it
tail -f no longer cuts itOn 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-000125produces lines in Kong,orders,inventory,paymentsandanalytics. 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
orderspod, 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 ofpink-tomatoin 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.
- 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
Authorizationheaders. An HTTP trace atdebugthat 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) andorder_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.
- 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
services/common/logs.py: JSON with an injected trace_id
services/common/logs.py: JSON with an injected trace_idWith 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:
structlogdoesn't format strings:log.warning("stock_reservation_rejected", product=...)produces separate keys. Writinglog.warning(f"Rejected {product}")works, but loses the structure. The discipline is "stable event name + fields".- The
redactprocessor is a safety net, not the policy: the policy is not to pass that data in. But it costs little and stops alog.debug("request", headers=dict(request.headers))written late on a Tuesday night from dumping tokens. - The
trace_idis 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 sametrace_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.
- 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 aspan_idand aparent_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
traceparentheader:
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
- Instrumenting with OpenTelemetry:
services/common/traces.py
services/common/traces.pyOpenTelemetry (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 ordersTwo clarifications:
- gRPC, HTTP and PostgreSQL need no code: the instrumentors intercept the calls, create spans and write/read
traceparentin the metadata. Ininventory,AuthInterceptorand theMetricsInterceptorfrom 07-01 live alongside the OTel interceptor; the order does not affect the context. - Kafka does need manual propagation (or the
confluent-kafkainstrumentor, 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.pystorestraceparentin the row of thesagastable, and when the outbox relay or a compensation picks the work back up, they rebuild the context withpropagate.extracton that value. Without this, the compensation forP-2026-000125would show up as an orphan trace with no relation to the order.
- 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 MinIOBackends
| 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.
- 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
UPDATEininventorytook 98 ms on an operation that normally takes 2: there is lock contention oncrianza-wine(Grape Harvest Week, lots of orders for the same product). The p99 metric forReserveStockshowed it climbing; the trace says which statement. - The
analyticsspan belongs to the same trace even though it happens after the response: that is the effect of propagatingtraceparentin 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.
- 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_idof a recent observation. Inprometheus_clientyou passexemplar={"trace_id": ...}toobserve(); 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_idin 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: prometheusAn 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,
structloghas a colourised console renderer. - Promoting
trace_idororder_idto 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
debugand leaving it switched on. This is the most common leak of tokens and personal data. A redaction processor as a net, anddebugoff by default. - Losing the context across asynchronous hops. A
ThreadPoolExecutor, anasyncio.create_taskor a Kafka message withoutpropagate.injectbreaks 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 /ordersspan withoutkm0.order_idforces 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
- Basic Concepts of Distributed Systems
- Distributed System Models
- Advantages and Challenges of Distributed Systems
- The Fallacies of Distributed Computing
- Time, Clocks and Event Ordering
- From Monolith to Distributed Platform: the Kilometre Zero Case
Module 2: Communication in Distributed Systems
- Communication Protocols
- RPC and RMI
- gRPC and Data Serialization
- Messaging and Message Queues
- Asynchronous Communication Patterns
Module 3: Consistency and Replication
- Consistency Models
- The CAP Theorem and PACELC
- Consensus Algorithms
- Data Replication
- Distributed Transactions and Sagas
Module 4: Distributed Storage
- Data Partitioning and Consistent Hashing
- Distributed File Systems
- Object Storage
- Distributed Databases
- Distributed Caches
Module 5: Distributed Computing
- Distributed Computing Models
- MapReduce and Hadoop
- Spark and In-Memory Computing
- Stream Processing
- Job Scheduling and Data Pipelines
Module 6: Security in Distributed Systems
- Authentication and Authorization
- Encryption and Data Protection
- Identity Management
- Service-to-Service Security: mTLS and Secrets Management
- API Gateways, Rate Limiting and Auditing
Module 7: Monitoring and Maintenance
- Monitoring Distributed Systems
- Centralized Logs and Distributed Tracing
- Failure Management and Recovery
- Resilience Patterns: Timeouts, Retries and Circuit Breakers
- Automation and Orchestration
- Testing Distributed Systems and Chaos Engineering
