Notes / System Design / 15 Complete Case Studies / 01 Telemetry Ingestion Pipeline

3.3 Layer 3: Processing / Enrichment

Layer 3 of the telemetry ingestion pipeline: processing and enrichment — the metric processor, cardinality enforcement, tail-based sampling, the log processor, metric temporality, and Kubernetes metadata enrichment.

Appears in: Telemetry Ingestion Pipeline — §3, Deep Dives — this is §3.3.

3.3 Layer 3: Processing / Enrichment

Layer 3 Mental Model:

flowchart TD
    IN(["From Kafka\n(Layer 2)"]) --> SPLIT{"⚖\nSignal type"}

    SPLIT -->|"metrics"| MENR["⚙\nEnrich + relabel\n(k8s metadata, cardinality)"]
    MENR --> MBUDGET{"⚖\nWithin tenant\ncardinality budget?"}
    MBUDGET -->|"No"| MREJ["👁\nReject batch\ncardinality_limit_exceeded"]
    MBUDGET -->|"Yes"| MAGG["⚙\nAggregate (optional)\n+ temporality convert"]
    MAGG --> MSTORE(["Mimir"])

    SPLIT -->|"traces"| TASM["⚙\nSpan assembler\nhold by trace_id\nuntil root arrives"]
    TASM --> TDEC{"⚖\nError/slow,\nor within 1% sample?"}
    TDEC -->|"Yes"| TSTORE(["Tempo"])
    TDEC -->|"No"| TDROP["👁\nDrop"]

    SPLIT -->|"logs"| LPARSE["⚙\nParse (structured /\nunstructured) + dedup"]
    LPARSE --> LSTORE(["Loki"])

    NCARD["📝 Cardinality tracking\nApproximate structures\n(HyperLogLog / Count-Min Sketch)\nper tenant, not exact counting"]
    NTEMP["📝 Temporality mismatch\nOTel SDKs default to delta;\nMimir expects cumulative —\nprefer agent-side conversion\nover a stateful processor"]
    NK8S["📝 K8s enrichment\nPrefer injecting at the agent\n(DaemonSet, downward API) over\nper-span k8s API calls —\nk8sattributes processor if not"]
    NLOG["📝 Schema-on-read vs write\nLoki: parse at query time\n(cheap write, costly query) vs\npre-structured (costly write,\nfast query)"]

    MBUDGET -.- NCARD
    MAGG -.- NTEMP
    MENR -.- NK8S
    LPARSE -.- NLOG

    classDef mech fill:#7db3ec22,stroke:#7db3ec,stroke-width:1.5px
    classDef decision fill:#ffab7322,stroke:#ffab73,stroke-width:1.5px
    classDef watch fill:#f0669722,stroke:#f06697,stroke-width:1.5px
    classDef terminal fill:transparent,stroke:#14213d,stroke-width:2px
    classDef note fill:#eab30822,stroke:#eab308,stroke-width:1.5px,stroke-dasharray:4 3

    class MENR,MAGG,TASM,LPARSE mech
    class SPLIT,MBUDGET,TDEC decision
    class MREJ,TDROP watch
    class IN,MSTORE,TSTORE,LSTORE terminal
    class NCARD,NTEMP,NK8S,NLOG note

Metric Processor

flowchart TD
    A["Read from Kafka partition"] --> B["Deserialize OTLP protobuf"]
    B --> C["Enrich: inject k8s metadata\nnode · namespace · cluster via resource attributes"]
    C --> D["Relabel: rename / drop labels\nper schema — cardinality enforcement"]
    D --> E{"Active series\n> tenant budget?"}
    E -->|Yes| REJ["Reject batch\nemit cardinality_limit_exceeded counter"]
    E -->|No| F["Aggregate optional\npre-aggregate histograms to reduce storage fan-out"]
    F --> G["Write to Mimir\nremote-write or gRPC ingest"]

The metric processor’s job: it reads metric data from Kafka, cleans it up, and writes it to Mimir, which is the long-term storage.

The pipeline has steps. First, you read from Kafka. Then you deserialize — that’s turning the binary protobuf bytes back into readable metric data. Then you enrich it — you add extra information that wasn’t in the original metric, like which Kubernetes namespace it came from. Then you relabel — you rename or drop certain labels based on rules you’ve set. Then you write to Mimir.

But before you write, there’s a critical gate: cardinality enforcement.

Here’s the problem. A metric has labels — think service=payment, method=POST, status=200. That’s three label dimensions. Now imagine a service that uses user_id as a label. There are millions of users. So you get millions of unique combinations of labels — one for each user. That’s called high cardinality.

If one tenant’s service starts sending millions of unique label combinations, it bloats the shared database for everyone — not just that tenant. The TSDB slows down, memory explodes, and every query gets slower.

So the processor checks: “Does this tenant already have too many active series?” If yes, reject the batch. Tell the tenant “your cardinality limit exceeded” so they know to fix their service.

So you reject batches that exceed cardinality, and you emit a counter that tells the tenant they’ve hit the limit.

Now, before writing to Mimir, there’s one more optional step: pre-aggregation. Some histograms can be aggregated early to reduce how much data you write downstream. Not always necessary, but it’s there.

Then you write to Mimir via remote-write or gRPC ingest, and that’s the end of the metric processor pipeline.

Cardinality enforcement is non-negotiable at scale. A single misbehaving service can send 10M unique label combinations and collapse a shared TSDB. Enforce at the processor:

  • Track active series per tenant with an approximate data structure (HyperLogLog or Count-Min Sketch)
  • Reject or drop metric families that exceed budget; emit a “cardinality limit exceeded” counter visible in the platform’s self-telemetry
  • Send the OTLP PartialSuccess signal back through the buffer (or emit a platform-level alert to the tenant)

Trace processor — tail-based sampling

This one’s fundamentally different from metrics because of a question you can’t answer until the trace is finished: was this trace interesting?

The problem with head-based sampling: You could decide at the very first span whether to keep the trace or drop it. Simple, stateless. But you don’t know yet if the trace will error out, or take ten seconds when your P99 is two seconds. You’re flying blind.

Tail-based sampling: You wait until the root span arrives — the span that started the whole request — then you look at the full trace and decide. Did it error? Keep it. Did it exceed your P99 latency? Keep it. Otherwise, maybe sample it at one percent and drop the rest.

Here’s the flow. Spans arrive partitioned by trace_id, so all spans from one trace land on the same processor. The processor holds them in memory or Redis in a span assembler. You keep checking: has the root span arrived yet? Once it does, you evaluate your sampling policy. Error or slow? Write to Tempo. Otherwise, flip a coin at one percent odds — if you lose, drop it.

The hard part: at one billion spans per second, you cannot hold every span in memory forever. So you bound your window — keep spans for maybe the last thirty seconds in an LRU cache in memory, overflow older ones to disk or Redis. And use a TTL — if the root span hasn’t shown up in thirty seconds, call it an orphan, sample it low, and move on.

flowchart TD
    A(["Spans arrive\npartitioned by trace_id"]) --> B["⚙\nSpan assembler\nholds spans in memory or Redis"]
    B --> C{"⚖\nRoot span\narrived?"}
    C -->|"Still waiting"| B
    C -->|"Yes — evaluate policy"| D{"⚖\nSampling\ndecision"}
    D -->|"Error or latency > P99"| SAM(["Write to Tempo"])
    D -->|"Healthy trace"| RATE{"⚖\nWithin 1%\nsample rate?"}
    RATE -->|"Yes"| SAM
    RATE -->|"No"| DROP["👁\nDrop"]
    B -->|"TTL expired — no root span"| ORPHAN["👁\nOrphan flush\nlow-rate sample or drop"]

    classDef mech fill:#7db3ec22,stroke:#7db3ec,stroke-width:1.5px
    classDef decision fill:#ffab7322,stroke:#ffab73,stroke-width:1.5px
    classDef watch fill:#f0669722,stroke:#f06697,stroke-width:1.5px
    classDef terminal fill:transparent,stroke:#14213d,stroke-width:2px

    class B mech
    class C,D,RATE decision
    class DROP,ORPHAN watch
    class A,SAM terminal

The span assembler is the hardest part: at 1B spans/sec, you cannot hold everything in memory. Solutions:

  • Hash-partition spans to processors by trace_id so all spans of a trace land on the same worker
  • Use a bounded in-memory LRU (hold spans for the last N seconds) + overflow to local disk or Redis
  • TTL-based flush: if root span hasn’t arrived in 30s, treat as orphan and drop or sample at low rate

Log Processor

Logs are different again — the main decision is whether you parse them at write time or at query time.

Schema-on-write: Parse the log at ingest, extract fields, enforce structure. More work upfront, but queries are fast because the data is already structured.

Schema-on-read: Store the raw log as-is, parse it when someone queries. Cheaper writes, more expensive queries.

Most systems use schema-on-read (that’s Loki’s model) because logs are voluminous and you often don’t know what fields you’ll need to query until later.

One more thing: deduplication. Logs can arrive twice, so you hash the timestamp plus the log body, keep that hash in a sliding five-minute window per stream, and drop exact duplicates.

  • Parse structured (JSON, logfmt) vs unstructured (regex extraction, drain/spell-based pattern mining)
  • Schema-on-read (Loki model): store raw, parse at query time via LogQL → lower write cost, higher query cost
  • Schema-on-write (pre-structured model): parse at ingest, enforce schema → higher write cost, much faster queries
  • Deduplication: hash(timestamp + body) within a sliding 5-minute window per stream

Metric temporality — the silent OTLP impedance mismatch

The problem: OTLP defines two ways metrics can be reported. Cumulative means the value accumulates from when the process started. Delta means the value is just the change since the last export.

Most OTel SDKs, Java, Node, Go, default to delta. But Prometheus and Mimir expect cumulative. So when delta metrics arrive at your processor, you have to convert them.

How conversion works: You keep a running total per series. When a delta sample arrives, you add it to the running total, and that becomes the cumulative value you write to Mimir. Delta(5) + previous_cumulative(95) = new_cumulative(100).

The catch: That running total per series is state. At one billion active series, that state doesn’t fit on one machine. It’s a stateful component in your pipeline, which changes the memory footprint, the failure model, everything.

The fix: Push this upstream. Configure your agents to emit cumulative from the start, one config flag, and you eliminate this stateful piece entirely from the pipeline.

If you can’t do that, the OTel Collector has a deltaToCumulative processor, but it leaks state on restart — meaning you see negative rate() values on your dashboards after a processor restart, which is confusing and wrong.

The key question to ask an interviewer: “Are agents sending delta or cumulative?” Because it changes whether this component is stateless or stateful.


OTLP defines two temporality modes for metrics:

ModeDescriptionDefault for
CumulativeValue accumulates from process start; rate() works directlyPrometheus native
DeltaValue = change since last export; must be summed before storageMost OTel SDK langs (Java, Node, Go)

The problem: OTel SDKs often default to delta temporality. Prometheus remote-write and Mimir expect cumulative. The processor must convert:

Delta(t=100, value=5) + previous_cumulative(95) = Cumulative(t=100, value=100)

This requires stateful per-series accumulation in the processor. At 1B active series that state doesn’t fit on one machine:

  • Preferred fix: Force agents to emit cumulative at the SDK level — one config flag (temporality_preference: cumulative), eliminates a stateful component from the pipeline entirely.
  • Pipeline fix: The OTel Collector deltaToCumulative processor uses a local state store (bounded LRU). It leaks state on restart, causing counter resets visible as negative rate() values in dashboards.
  • Always ask the interviewer: “Are agents sending delta or cumulative? Delta changes the processor from stateless to stateful, which changes the memory footprint, failure model, and restart semantics.”

Kubernetes metadata enrichment:

A pod emits spans with service.name = payment-service. Queries filter by namespace, cluster, node — labels the SDK doesn’t inject. The processor must join on k8s state:

Span arrives: pod_name="payment-xyz-abc"
  → Join k8s metadata: namespace, node_name, cluster, app.kubernetes.io/version
  → Attach as resource attributes
  → Forward enriched span to Tempo

At 10K pods and 1M spans/sec you cannot call the k8s API per span:

  1. In-memory pod cache with informer watch: Processor watches k8s pod events and caches metadata locally. Cache size = O(pod count), manageable. Cache invalidation happens on pod lifecycle events. Cold start gap: brief window after processor restart where enrichment is incomplete.
  2. Enrich at the agent (preferred): Alloy/OTel Collector DaemonSet has local node access via downward API and can inject labels at source. Moves enrichment left, removes the hot path on the k8s API server, and survives processor restarts without gaps.
  3. OTel k8sattributes processor: Standard implementation of option 1. Use this; don’t reinvent it.

Local graph

Full graph →

Linked from 12 notes

2. High-Level Architecture

The producers → ingestion gateway → Kafka → processors → storage diagram for the telemetry ingestion pipeline, plus the push-over-pull key insight to state early in the interview.

3.2 Layer 2: Durable Buffer (Kafka)

Layer 2 of the telemetry ingestion pipeline: the Kafka durable buffer — topic design, partitioning strategy, hot-spots, retention, retry/delivery semantics, producer config, consumer lag, and schema evolution.

3.4 Scaling Each Layer

Scaling unit and trigger for every layer of the telemetry ingestion pipeline, from the ingestion gateway through to storage.

3.5 Failure Modes and Mitigations

Failure modes and mitigations across the telemetry ingestion pipeline — gateway crashes, broker failure, processor crashes, span explosion, cardinality rejection, storage saturation, and clock skew.

3.6 Multi-Tenancy

Multi-tenancy isolation layers and quota enforcement points across the telemetry ingestion pipeline, from network inbound to the storage write path.

5. Trade-offs at 10x Scale

The 'what would you do differently at 10x' trade-off questions for the telemetry ingestion pipeline: Kafka vs. direct write, trace-assembly sharding, schema-on-read vs. write, sampling strategy, protocol choice, and push vs. pull.

6. Interview Anchor Points (What to Say Out Loud)

The sentences that signal principal-level thinking for the telemetry ingestion pipeline design — ready to say unprompted in an interview.

8. Quick-Reference Cheat Sheet

One-line answers for every load-bearing design decision in the telemetry ingestion pipeline — the last thing to review before an interview.

Q4: A Metric's Journey From Pod to Dashboard — Every Failure Point

Full principal-level solution: trace a single metric data point from a Kubernetes pod to a queryable dashboard, identifying every failure point along the way and how each is detected.

Q8: Counters Resetting to Zero After an OTel SDK Upgrade

Full principal-level solution: diagnose and fix a tenant's dashboards showing counters reset to zero every few minutes after an OTel SDK upgrade, without requiring instrumentation changes.

Schema Validation and Rejection at the Ingestion Frontier

What the gateway actually checks before accepting a payload — structural validation vs semantic cardinality checks, why rejection has to happen before the buffer, OTLP PartialSuccess as an alternative to whole-batch rejection, and the forward-compatibility trap of validating too strictly.

Chapter 1 — Telemetry Ingestion Pipeline

Principal/Staff-level design of a high-throughput telemetry ingestion pipeline — requirements, architecture, deep dives, and trade-offs at 10x scale.