Lab: OpenTelemetry Positioning — Where the Collector Sits

OpenTelemetry is not a dashboard and not a database. It's the vendor-neutral instrumentation, transport, and routing layer that sits upstream of Prometheus, Tempo, and Grafana. This lab makes that positioning concrete by handing you exactly one config decision.

Setup mode: local-mock (Docker Compose) Time box: 45–75 min Complexity: intermediate Concepts: 4

What problem this demonstrates

"Just use OpenTelemetry" and "just use Prometheus + Grafana" get said in the same breath so often that it's easy to assume they're competing choices. They're not — they're different layers, and confusing them is exactly why "why can't I see a trace in my Prometheus + Grafana stack" is a common, structurally-unanswerable question: nothing in that stack can store a trace.

Minimum concepts involved:

System architecture — five layers, one diagram

Five-layer OpenTelemetry positioning diagram The app creates telemetry via the OpenTelemetry API and SDK, sends it as OTLP to a Collector, which routes the metrics pipeline to Prometheus and the traces pipeline to Tempo. Grafana queries both backends read-only and never receives OTLP directly. ① instrument ② transport ③ route ④ store + query ⑤ visualize Your App OTel API + SDK — creates spans & a counter OTLP / gRPC+HTTP OpenTelemetry Collector metrics pipeline receiver: otlp processor: batch exporter: prometheus traces pipeline receiver: otlp processor: batch exporter: otlp/tempo ↑ your core task: service.pipelines exporter wiring ↑ Prometheus metrics storage + PromQL query Tempo trace storage + TraceQL query read-only query (2 datasource plugins) Grafana visualization only — stores no telemetry itself Logs (3rd signal): same pattern — add a `logs` pipeline + loki exporter. Not built here, see "Scope" below.
metrics path traces path OTel-owned (instrumentation, transport, routing) visualization, read-only

Two things this diagram is built to make undeniable: (1) the app never names Prometheus, Tempo, or Grafana — it only knows OTLP and one Collector address; (2) Grafana sits entirely below the line, drawing from two backends it never writes to. Everything above the "your core task" marker is OTel. Everything below the metrics/traces split is a single-signal specialist backend OTel routes into, not replaces.

What each tool does — and where responsibility crosses

Layer / toolResponsible forNot responsible for
App + OTel SDKCreating spans and metric instruments through a vendor-neutral API; encoding and sending OTLPKnowing which backend telemetry ends up in — that's a Collector-config concern, zero app code
OTLPA common wire format/transport so any OTel SDK can talk to any OTLP-speaking receiverMeaning — two systems can exchange valid OTLP bytes and still disagree on what a field means without shared semantic conventions
OpenTelemetry CollectorReceiving OTLP, then routing each signal type to the exporter(s) built for it, via named service.pipelinesStoring or querying telemetry long-term — it is a router/processor, not a database
PrometheusStoring and querying metrics only, pull-scraped from the Collector's prometheus exporter pageTraces or logs — its exporter component has no traces/logs consumer at all (see Failure case)
TempoStoring and querying traces only, received as OTLP directly from the CollectorMetrics or dashboards
GrafanaRendering panels/Explore views by querying Prometheus and Tempo through separate datasource pluginsReceiving OTLP, storing telemetry, or making routing decisions — it has no opinion on where data came from before it landed in a backend

Learner steps & checkpoints

  1. Read the scaffold — start with collector/otel-collector-config.yaml, then skim app/server.js.
  2. Write down predictions for 5 checkpoints before running anything.
  3. Fill in the two exporters: [] lines under service.pipelines — your only edit.
  4. Fast check: ./scripts/validate-config.sh — no full stack required, should exit 0 once correct.
  5. Full run: ./scripts/run.sh — brings up app, Collector, Prometheus, Tempo, Grafana.
  6. Observe a trace in Grafana via the Tempo datasource, and a rising counter via the Prometheus datasource (and directly in Prometheus's own UI).
  7. Run the failure case, read the error, restore your working config.

Observable signals at each stage: the validator's exit code and error text, docker compose ps container health, Prometheus's own query UI, and Grafana's Explore view for both datasources.

Failure case — what to inspect (not the fix)

Wiring traces to the prometheus exporter

A pre-built, deliberately wrong config routes the traces pipeline to the prometheus exporter instead of otlp/tempo. Run it with ./scripts/failure-case.sh. The Collector does not start up and quietly drop traces — it refuses to start at all, before any receiver port opens, and exits nonzero with an exporter/signal-incompatibility error printed to stderr.

What to inspect: the exact wording of that error, and why it happens at startup rather than showing up later as "traces just aren't arriving." That timing is the point — a mismatched signal/backend pairing is a config-build-time failure for a signal-restricted exporter like prometheus, not a silent runtime data-loss bug.

The exact error text and the deeper "is this failure symmetric in both directions" nuance are in this lab's private answer key — compare after you've run it yourself and after lab-review.md.

Acceptance criteria

Prerequisites, time, cost, and scope

RequiresDocker + Docker Compose (v2 docker compose syntax). No cloud accounts, no API keys, no cost — everything runs locally.
Estimated time45–75 minutes.
Cost riskNone — fully local containers, no metered APIs.
No-Docker fallback./scripts/validate-config.sh validates your service.pipelines wiring with a standalone otelcol-contrib binary (auto-downloadable, no root needed) when Docker isn't available — you still get the core "does my routing build" feedback loop without the full Grafana/Prometheus/Tempo experience.
Why logs and OTTL aren't a second required task

Logs are OTel's third signal and would follow the exact same pattern taught here: a loki exporter definition plus a logs: { receivers: [otlp], exporters: [loki] } pipeline, same file, same shape as the two you already wired. Building a runnable Loki backend into this lab would triple its footprint to teach a pattern you'd already have proven twice. OTTL (declarative in-pipeline transformation — redacting or rewriting fields as they pass through) is a different concern from routing entirely: it changes what's inside a pipeline, not which pipeline a signal takes. Both are one-paragraph extensions once the routing lesson lands, not separate core tasks.