Hands-On Labs · Guided Drills

Drill — Trace a Broken Telemetry Pipeline

A teammate adds a filter processor to your OpenTelemetry Collector, meant to strip the constant liveness- and readiness-probe spans before they ever reach the tracing backend — cheap, sensible cost control. The config applies cleanly. The Collector reports Ready. Nothing anywhere logs an error, warns, or so much as raises an eyebrow. And weeks later the backend's storage bill has crept up in a way nobody can explain, because the filter that was supposed to be doing real work has, this whole time, been running for absolutely nothing — declared in the config, never actually wired into the pipeline that processes spans. This drill hands you that exact silence and asks you to find it using nothing but two of the Collector's own metrics, the same evidence a real on-call engineer would reach for first. It's fully self-contained: a throwaway kind cluster and a single Collector, no dependency on the capstone. Budget 25-40 minutes before you open the walkthrough below.

☺ Explain it like I'm 10

Picture a library return cart with a big handwritten sign taped above it: "Comic books go to the kids' shelf, not the main stacks." The sign is real, it's accurate, it's even in the librarian's own handwriting. But her actual daily routine — the laminated card she checks every single time she sorts the cart — was printed months before that sign went up, and nobody ever added a line to it. So every comic book she sorts still lands on the main stacks anyway, exactly like before the sign existed. Nothing about her workflow looks wrong from the outside; books keep moving, the cart keeps emptying, no alarm goes off. The only way to catch it is to count what's actually landing on which shelf, not to trust the sign taped to the wall. Today's Collector has exactly that sign, and exactly that overlooked laminated card.

🐘🦊Your hosts for this drill: Ellie the Elephant & Foxy — Ellie owns the Collector and lives inside its self-metrics; Foxy never trusts a config file until the numbers back it up, and today the numbers are the entire investigation.
⚠ Before you start

You need Docker, kind, kubectl, curl, and openssl (for generating span and trace IDs). Everything else — the cluster, the namespace, the Collector — is brand-new and throwaway; tear it all down when you're done (kind delete cluster --name pipeline-drill). No git repo, no GitHub CLI, no backend to stand up — this drill lives entirely inside one cluster. Install URLs and CRD field names drift over time; if a command below errors, check that project's current docs and adapt — that's a small rep of the same diagnostic instinct this drill is teaching.

Declared vs wired — the bug hiding in plain sight

☺ Like you're 10: A rule taped to the wall doesn't count until it's on today's actual checklist — the Collector obeys the checklist, never the wall.

Every OpenTelemetry Collector config does two genuinely separate things. The top-level receivers:, processors: and exporters: blocks only declare components — they say "here is a thing that exists and here is how it's configured," nothing more. Separately, service.pipelines wires a subset of those declared components into an actual signal pipeline, in an explicit, ordered list. A component that's declared but never named inside service.pipelines is fully valid YAML, passes every schema check, and does precisely nothing at runtime — no warning at startup, no error in the logs, no difference in the Collector's Ready status. It just quietly never runs.

◆ Key idea

Because a declared-but-unwired component is silent by design, you cannot catch this bug by reading the config once and deciding it "looks right." You catch it by measuring behavior against a known input and noticing the numbers don't match what a correctly wired pipeline would produce. That's exactly why the Collector exposes its own internal telemetry on port 8888otelcol_receiver_accepted_spans and otelcol_exporter_sent_spans among them — and why those two counters are your first, best diagnostic tool, ahead of logs, ahead of re-reading YAML.

Build the scratch world

☺ Like you're 10: One cluster, cert-manager, the Operator, one Collector — all thrown away when you're done.

Stand up a fresh kind cluster and the OpenTelemetry Operator, which needs cert-manager installed first for its admission webhooks:

kind create cluster --name pipeline-drill
kubectl create namespace observability

kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
kubectl -n cert-manager rollout status deploy/cert-manager-webhook

kubectl apply -f https://github.com/open-telemetry/opentelemetry-operator/releases/latest/download/opentelemetry-operator.yaml
kubectl -n opentelemetry-operator-system rollout status deploy/opentelemetry-operator-controller-manager

Now the Collector itself. It's meant to filter out /healthz and /readyz spans before they leave the cluster — the exact OTTL filter processor pattern, correctly written. Save this as collector.yaml. Notice the filter/drop-health-checks processor is fully declared with both its where-style conditions, and then look closely at the pipeline at the bottom:

# collector.yaml — BROKEN, as shipped
apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
  name: pipeline-drill
  namespace: observability
spec:
  mode: deployment
  replicas: 1
  image: otel/opentelemetry-collector-contrib:latest   # contrib build — filter needs it
  config:
    receivers:
      otlp:
        protocols:
          grpc: { endpoint: 0.0.0.0:4317 }
          http: { endpoint: 0.0.0.0:4318 }
    processors:
      memory_limiter:
        check_interval: 1s
        limit_percentage: 80
        spike_limit_percentage: 25
      filter/drop-health-checks:
        error_mode: ignore
        traces:
          span:
            - 'attributes["http.route"] == "/healthz"'
            - 'attributes["http.route"] == "/readyz"'
      batch:
        timeout: 5s
        send_batch_size: 8192
    exporters:
      debug:
        verbosity: normal
    service:
      telemetry:
        metrics:
          level: detailed        # exposes the Collector's own metrics on :8888
      pipelines:
        traces:
          receivers: [otlp]
          processors: [memory_limiter, batch]   # filter/drop-health-checks isn't here
          exporters: [debug]

Apply it and wait for it to come up. The Operator names the Deployment and Service <cr-name>-collector:

kubectl apply -f collector.yaml
kubectl -n observability rollout status deploy/pipeline-drill-collector

kubectl -n observability port-forward svc/pipeline-drill-collector 4318:4318 >/tmp/otlp-pf.log 2>&1 &
kubectl -n observability port-forward svc/pipeline-drill-collector 8888:8888 >/tmp/metrics-pf.log 2>&1 &
One Collector, three processors declared — only two ever run otlp receiver 4317 grpc · 4318 http memory_limiter next processor? batch runs top → bottom, exactly as listed debug exporter stand-in tracing backend processors: filter/drop-health-checks declared, fully configured, two where-clauses never referenced in service.pipelines.traces.processors expected wiring — never happens Declaring a processor is not the same as running it — service.pipelines decides.

Generate mixed traffic — signal and noise, together

☺ Like you're 10: Send exactly fifteen spans that should get dropped and five that shouldn't, so the right answer is a number you already know.

Define a small helper that posts one span over OTLP/HTTP with a chosen http.route, then fire fifteen fake health-check spans and five fake real-request spans — the same shape of traffic a checkout service under a Kubernetes liveness probe actually produces:

send_span() {
  local route="$1"
  local trace_id span_id now end
  trace_id=$(openssl rand -hex 16)
  span_id=$(openssl rand -hex 8)
  now=$(date +%s%N)
  end=$((now + 5000000))
  curl -s -o /dev/null -X POST http://localhost:4318/v1/traces \
    -H 'Content-Type: application/json' \
    -d '{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"checkout"}}]},
         "scopeSpans":[{"spans":[{"traceId":"'"$trace_id"'","spanId":"'"$span_id"'",
         "name":"'"$route"'","kind":2,
         "attributes":[{"key":"http.route","value":{"stringValue":"'"$route"'"}}],
         "startTimeUnixNano":"'"$now"'","endTimeUnixNano":"'"$end"'"}]}]}]}'
}

for i in $(seq 1 15); do send_span "/healthz";  done
for i in $(seq 1 5);  do send_span "/checkout"; done

Twenty spans total, sent once, straight into the receiver. If filter/drop-health-checks were actually wired in, exactly fifteen of them should never make it out the other side.

Diagnose it from two counters, nothing else

☺ Like you're 10: Don't reopen the YAML yet. Ask the Collector's own metrics whether anything is actually being thrown away.

Scrape the Collector's self-telemetry — no dashboard, no backend query, just the raw Prometheus text the Collector itself exposes on :8888:

curl -s localhost:8888/metrics | grep -E 'otelcol_(receiver_accepted_spans|exporter_sent_spans)'
otelcol_receiver_accepted_spans{receiver="otlp",transport="http"} 20
otelcol_exporter_sent_spans{exporter="debug"} 20

Twenty accepted, twenty sent — a perfect one-to-one match. That match is the entire bug, stated in two numbers. If filter/drop-health-checks were running, fifteen of those twenty spans should have been discarded before they ever reached the exporter, and sent_spans should read 5, not 20. It doesn't. Nothing crashed, nothing logged an error, the receiver accepted every span exactly as it should — the gap that should exist between "accepted" and "sent" simply never opened.

Before the fix — one broken batch of 20 spans (15 healthz + 5 checkout) accepted_spans +20 sent_spans +20 — identical, nothing dropped zero-span gap → the filter never ran After the fix — identical batch sent again accepted_spans +20 sent_spans +5

Underneath that second bar chart the arithmetic is simple and worth doing by hand once: a fifteen-span gap between a bar that stopped at 5 and one that reached 20 is exactly the fifteen /healthz spans you sent — not roughly, not approximately, exactly, because you know precisely what you fed the receiver.

Confirm against the resource, then fix it

☺ Like you're 10: The metrics told you something's wrong. The resource's own spec tells you exactly what.

The metrics prove a component isn't dropping anything. Confirm which one, and why, straight from the object Kubernetes is actually running:

kubectl -n observability get opentelemetrycollector pipeline-drill -o yaml | grep -A6 'pipelines:'
    pipelines:
      traces:
        exporters:
        - debug
        processors:
        - memory_limiter
        - batch

There it is: filter/drop-health-checks is declared, fully configured, sitting right there in the CR's own processors: block — and it is absent from this list. Add it, in the position that actually matters: after memory_limiter (so an overloaded Collector still sheds load first) and before batch (so you don't spend CPU batching spans you're about to throw away):

# collector.yaml — the fix
    service:
      telemetry:
        metrics:
          level: detailed
      pipelines:
        traces:
          receivers: [otlp]
          processors: [memory_limiter, filter/drop-health-checks, batch]
          exporters: [debug]

Reapply, and let the Operator roll the Deployment:

kubectl apply -f collector.yaml
kubectl -n observability rollout status deploy/pipeline-drill-collector
🐘 Ellie's-eye view

"The first time this happened to me it wasn't health-check spam, it was a tenant's PII in tracestate — someone wrote a beautiful transform processor to strip it, watched the config apply cleanly, and moved on. Nobody noticed the processor was never wired in for eleven days, because nothing about a Collector running normally looks any different from a Collector quietly doing less than you think. I don't trust a processor exists until I've watched a known batch of traffic shrink by the amount it's supposed to shrink by. Everything else is a config file telling me what it intends to do, not what it's doing."

Prove the fix with the same two counters

☺ Like you're 10: Run the identical batch again and watch the gap between the two numbers finally open up.

Because both metrics are cumulative Prometheus counters, note the baseline before you send anything new — it should still read the old totals from the broken run:

curl -s localhost:8888/metrics | grep -E 'otelcol_(receiver_accepted_spans|exporter_sent_spans)'
# otelcol_receiver_accepted_spans{receiver="otlp",transport="http"} 20
# otelcol_exporter_sent_spans{exporter="debug"} 20   ← unchanged since the redeploy, as expected

for i in $(seq 1 15); do send_span "/healthz";  done
for i in $(seq 1 5);  do send_span "/checkout"; done

curl -s localhost:8888/metrics | grep -E 'otelcol_(receiver_accepted_spans|exporter_sent_spans)'
# otelcol_receiver_accepted_spans{receiver="otlp",transport="http"} 40   (+20 — the receiver still accepts everything)
# otelcol_exporter_sent_spans{exporter="debug"} 25                       (+5 — only the /checkout spans got through)

Done when: the second batch's exporter delta is exactly +5, the receiver's delta is exactly +20, and the gap between them — 15 — matches the number of /healthz spans you sent, exactly. Optionally, confirm a second way: port-forward 55679 and open /debug/pipelinez to see filter/drop-health-checks now listed as an active stage of the traces pipeline, not just a declared, orphaned component.

The transferable habit

☺ Like you're 10: When nothing errors and nothing looks broken, count what's actually happening before you trust what's declared.

The fix today was one word in one list — adding filter/drop-health-checks where it was always supposed to be. The skill worth keeping is the sequence that found it: don't trust a config file's stated intent, measure a known input against the Collector's own output, notice exactly where the numbers disagree with what a correctly wired pipeline should produce, and only then go confirm the precise cause in the resource's own spec. That sequence generalizes past filters completely — a tail_sampling processor that never trims a bill, a k8sattributes processor whose enrichment never shows up on a span, a second exporter that never receives a single record. Every one of those is the identical shape: a component sitting fully configured and completely silent, findable only by counting, never by reading.

🦊 Foxy's challenge · going further

Push past the minimum fix. First, reproduce a subtler version of the same bug: leave filter/drop-health-checks correctly wired, but typo one of its where clauses — attributes["http.route"] == "/healthzz" — and watch the same one-to-one accepted-to-sent match reappear for a completely different reason. Second, add a spanmetrics connector to derive RED metrics from the surviving spans, and deliberately forget to list it as an exporter of the traces pipeline while correctly listing it as a receiver of a metrics pipeline — connectors need to be wired on both sides, and getting only one side right produces a metrics pipeline that runs forever and receives nothing. Third, write a Kyverno ClusterPolicy in audit mode that flags any OpenTelemetryCollector whose declared processor names aren't a subset of every pipeline's processor list combined — you won't fully validate it declaratively, but the attempt is a real rep for KCA.

0 / 7 steps complete
1Stand up the cluster, cert-manager, the Operator, and the broken Collector
Done when: kubectl -n observability rollout status deploy/pipeline-drill-collector reports success.
2Confirm the OTLP receiver and the metrics endpoint both answer
Done when: curl -s localhost:8888/metrics returns text and doesn't error.
3Send the first mixed batch — 15 healthz spans, 5 checkout spans
Done when: all 20 send_span calls return without error.
4Read accepted vs sent and catch the missing drop
Done when: you can state, from the two grepped metric lines alone, that accepted_spans and sent_spans are equal and that's wrong.
5Confirm the root cause against the resource's own spec
Done when: you can point at the exact service.pipelines.traces.processors list and show filter/drop-health-checks is missing from it.
6Add the processor to the pipeline, reapply, wait for the rollout
Done when: kubectl -n observability rollout status deploy/pipeline-drill-collector reports success again after the edit.
7Resend the identical batch and confirm the drop count matches exactly
Done when: the exporter delta is +5, the receiver delta is +20, and the gap between them is exactly 15.
🎬 At Mission Control
🦊

Foxy: I've read this config four times. The filter is right there, the syntax is fine, the Collector says Ready. Why are healthz spans still showing up downstream?

🐘

Ellie the Elephant: Stop reading it a fifth time, Foxy. Ask it a question instead — send twenty known spans and see how many come out the other side.

🦊

Foxy: …twenty in, twenty out. Nothing got filtered. Not one span.

👺

Gizmo the Gremlin: Cheap fix — just crank the tail sampling rate down to 1% instead. Fewer spans total, nobody asks why the filter isn't working. 🤑

🐢

Timmy the Turtle: That throws away 99% of your real signal to hide a config bug you haven't even found yet, Gizmo. You'd be sampling away the evidence, not fixing anything.

🦊

Foxy: Found it — the filter's declared up top and just never made it into the pipeline list. One missing line.

🐘

Ellie the Elephant: And you found it the right way — by counting, not by staring. That habit is worth more than this one fix.

🐢 Timmy's checkpoint

1. Why did the health-check spans keep flowing through the pipeline even though the filter processor was fully and correctly configured? 2. What two Collector metrics did you compare to catch the bug, and what did their relationship tell you before the fix? 3. Why couldn't Kubernetes' API server or the Operator's admission webhook catch this at apply time? 4. After the fix, why does otelcol_receiver_accepted_spans still climb by the same amount as before, while otelcol_exporter_sent_spans climbs by less? 5. Name one other Collector component type where this exact "declared but not wired" bug could hide, and describe its silent symptom. 6. Why is comparing two cumulative counters across a known batch of traffic a more reliable diagnostic here than reading the YAML once?

Check your answers
  1. Declaring a processor in the top-level processors: block only defines it — it does not run unless its name also appears in service.pipelines.traces.processors. The CR shipped with filter/drop-health-checks declared but left out of that list, so every span, healthz included, passed straight from memory_limiter to batch untouched.
  2. otelcol_receiver_accepted_spans and otelcol_exporter_sent_spans. Before the fix they were exactly equal (20 and 20) for a batch that should have lost fifteen spans to the filter — a one-to-one match where a gap was expected is the signature of a component that isn't actually running.
  3. The API server validates a Collector CR against its CRD's schema alone — field types, required fields, allowed values. It has no way to know that a string inside processors: is "supposed to" also appear inside service.pipelines; that relationship is meaningful only to the Collector's own runtime, which builds pipelines from the second list and simply never looks at declared components the list doesn't mention.
  4. The receiver's job is to accept everything that arrives over OTLP regardless of what happens to it afterward, so its counter reflects input traffic only and is unaffected by anything a processor does. The exporter's counter reflects what actually survives the whole processor chain — once the filter is correctly wired and dropping /healthz and /readyz spans, only the spans that pass its where clauses ever reach the exporter to be counted.
  5. Any processor, exporter, or connector works the same way — a k8sattributes processor left out of a pipeline would leave every span missing its expected k8s.pod.name and k8s.namespace.name attributes with no error, and a second otlp exporter declared for a backup backend but never listed would simply never receive a single span, silently, forever.
  6. The YAML only ever tells you what's declared, and a declared-but-unwired component is indistinguishable from a correctly wired one just by looking at it — both are valid, both are present, both look intentional. A known batch of traffic against two cumulative counters tells you what actually ran, which is the only question that matters when the config itself gives no warning either way.

Fixed and confirmed? Good — that's the whole drill. For the full component model this bug lives inside, see OpenTelemetry Collector; for the exam that puts 26% of its weight on exactly this domain, see OTCA — the exam. For the data model underneath every span you generated today, see The OpenTelemetry Data Model; for where metrics like these eventually land in a real fleet, see The Prometheus Model. Ready for a different single skill? Try Drill — Lock Down a Mesh Namespace or Drill — Write an Enforcing Kyverno Policy, or go build the full loop in Build Your Cert Tracker — Start Here.