Tools Used in SRE · Zipkin

Zipkin

Zipkin is the original open-source distributed tracer — built inside Twitter in 2012, released as Apache-2.0 not long after, and still the reason the words "span" and "trace" mean what they mean across the entire tracing ecosystem. It receives the timing data your services report, stitches it back into one request's full journey across every hop, and shows you the result as a waterfall you can read in seconds. What makes Zipkin worth a page of its own in 2026 isn't that it's the tool to reach for on a new project — it usually isn't anymore — it's that a meaningful share of production systems still run it, its data model is the ancestor of what OpenTelemetry and Jaeger both use today, and the path off it onto OTel is short, well-understood, and worth knowing cold before you're the SRE who inherits a "why does this still say Zipkin" ticket.

☺ Explain it like I'm 10

Imagine the very first person to write down exactly how a relay race baton gets passed: who held it, for how many seconds, and who they handed it to next — one notebook page per runner, with a note pointing back to whoever handed them the baton. That page format becomes the standard every later stopwatch app copies, even long after faster apps replace the original notebook. Zipkin is that original notebook, built for real: an actual system Twitter built in 2012 to answer "where did this one request's time go across a dozen services," and the page format it invented — a span for each runner's leg, a trace for the whole relay, a pointer back to the parent leg — is still exactly what OpenTelemetry and Jaeger use today, even inside systems that have never run an actual Zipkin server.

🐘Your host for this topic: Ellie the Elephant — she holds every metric, log, and trace for this course's system, and Zipkin is the tool that first taught the whole industry what a "trace" made of "spans" should even look like.

What Zipkin is and why it exists

☺ Like you're 10: Twitter engineers read Google's paper on tracing, built their own open version of it in 2012, and it's still running quietly inside plenty of companies today.

Zipkin began at Twitter in 2012, largely credited to engineer Adrian Cole, as an open-source implementation of the ideas in Google's 2010 Dapper paper — the internal system Google built to answer exactly one question at scale: for a single user request that crosses dozens of internal services, which one actually ate the latency? Twitter needed the same answer for its own fan-out-heavy architecture and, unlike Dapper itself, published the result. It moved to the openzipkin GitHub organization, stayed Apache-2.0 licensed throughout, and — unlike Jaeger, which graduated as a CNCF project in 2019 — Zipkin was never brought under CNCF governance; it has always been an independent open-source project with its own maintainer group.

That history matters for a practical reason: Zipkin predates almost everything else on this page. It predates OpenTracing, OpenCensus, and OpenTelemetry by years, and it predates Jaeger itself (which started at Uber in 2015, three years after Zipkin shipped). Widely, the project is now considered to be in maintenance mode — it still receives releases and security fixes, but new feature development has slowed as the ecosystem has consolidated around OpenTelemetry as the instrumentation standard; verify the current release cadence on the openzipkin GitHub org before treating it as an actively evolving project. None of that makes it unimportant to know. Distributed systems built in the mid-2010s through roughly the early 2020s frequently chose Zipkin because it was, for years, simply the most mature open-source tracer available, and a real number of those systems are still in production. See distributed systems reliability fundamentals for why tracing became necessary in the first place — the short version is the same one Jaeger's own page opens with: metrics tell you that latency changed, logs tell you what one service said in isolation, and only a trace tells you where the time actually went across the whole call graph.

The data model it established: spans, traces, and B3 propagation

☺ Like you're 10: The vocabulary — span, trace, parent span — and the little headers that carry a request's trace ID from service to service both started here, and every newer tracer still speaks a dialect of it.

A Zipkin span represents one unit of work — an RPC call, a database query, a queue publish — and carries a traceId, its own id, a parentId pointing at the span that caused it, a name, a timestamp and duration in microseconds, a kind (CLIENT, SERVER, PRODUCER, or CONSUMER), localEndpoint/remoteEndpoint service identifiers, a bag of key/value tags, and timestamped free-text annotations. A trace is simply every span sharing one traceId, reassembled into a tree by following parentId links. That vocabulary — span, trace, parent span, tags — is exactly the vocabulary Jaeger and OpenTracing adopted afterward, and it is recognizably the ancestor of the resource/span model OpenTelemetry uses today, even though OTel's own spec was drafted independently by merging OpenTracing and OpenCensus rather than by copying Zipkin's code.

The span shape above is Zipkin's v2 format, and it's worth knowing there was a v1. The original Zipkin span had no kind field at all — instead, four specially named annotations marked the lifecycle of an RPC directly on the timeline: cs (client send), sr (server receive), ss (server send), and cr (client receive). The gap between cs and sr is network latency; the gap between sr and ss is server processing time — a genuinely clever way to derive network time without a dedicated field for it, and the reason those four letters still turn up in old Zipkin dashboards and archived JSON. The v2 kind field replaced that annotation quartet with an explicit, structured value, and it's what every current Zipkin instrumentation library emits — but a system integrating an old Zipkin export or a legacy client library may still need to reckon with v1-shaped data.

The other half of the model is B3 propagation — the header format Zipkin defined for carrying trace context between services, named after Zipkin's original internal project name, "BigBrotherBird." It ships as either five separate headers or one combined header:

FormHeader(s)Carries
Multi-headerX-B3-TraceId, X-B3-SpanId, X-B3-ParentSpanId, X-B3-Sampled, X-B3-FlagsThe classic form — five headers, one concern each
Single-headerb3: {TraceId}-{SpanId}-{SamplingState}-{ParentSpanId}The same data, hyphen-delimited in one header — fewer bytes on the wire

Zipkin's original trace IDs were 64 bits, matching Dapper's own format; v2 added optional 128-bit trace IDs for interoperability with systems that need the larger ID space. This is precisely where B3 and today's default OTel propagator part ways: OpenTelemetry's default is the W3C Trace Context standard's traceparent header, which is 128-bit-only and formatted differently from B3 end to end.

⚠ B3 and W3C traceparent do not understand each other

A service propagating B3 headers and a service propagating W3C traceparent headers do not interoperate by default — the trace doesn't degrade gracefully, it simply splits in two at the boundary, exactly the failure mode Jaeger's own page warns about for dropped headers generally. OpenTelemetry SDKs ship a configurable B3Propagator (both single- and multi-header variants) specifically so a service can speak B3 to its Zipkin-instrumented neighbors while the rest of the fleet moves to traceparent — and OTel also supports a composite propagator that reads and writes both formats at once during a migration window. Skipping this step is the single most common reason a "we migrated to OTel" trace mysteriously stops at the one service nobody re-instrumented yet.

Architecture: reporters, transport, one process, one storage backend

☺ Like you're 10: One helper library in your app sends timesheets over the network to one program that catches them, files them, and shows them to you — Zipkin does the catching, filing, and showing with a single piece of software instead of three separate ones.

Zipkin's architecture is deliberately simpler than Jaeger's, and that simplicity is one of its enduring appeals for a small or legacy deployment. A reporter runs inside your instrumented application — for JVM languages this is almost always Brave, Zipkin's own instrumentation library — batching finished spans and handing them to a sender, which ships them over one of several transports: an HTTP POST to /api/v2/spans, a message onto a Kafka topic, a message to RabbitMQ, or, in genuinely old deployments, the legacy Scribe RPC transport. Whichever transport is used, spans arrive at zipkin-server — a single Spring Boot application distributed as one runnable JAR (or the openzipkin/zipkin Docker image) that bundles the collector, the storage query API, and the web UI in one process. Zipkin-server writes to a storage backend — in-memory, Cassandra, Elasticsearch, or a limited MySQL option — and serves the query API and UI back out of the same process on port 9411.

Service A Brave reporter emits B3 headers Service B Brave reporter receives B3 headers B3 header (peer to peer) zipkin-server collector + storage query API + Zipkin Lens UI one process, port 9411 HTTP POST · Kafka · RabbitMQ Storage In-memory (dev only) Elasticsearch Cassandra MySQL (limited) 🐘 Ellie reads Zipkin Lens spans write HTTP :9411 One process wears three hats — Jaeger splits the same jobs into separate collector, query, and ingester services.

Because zipkin-server holds no state of its own — every span it receives is written straight through to the storage backend — scaling it out is unusually simple: run several identical zipkin-server replicas behind a load balancer, all pointed at the same Cassandra or Elasticsearch cluster, and any replica can answer any query. That's the direct tradeoff against Jaeger's design: Jaeger separates collector, query, and (optionally) ingester into independently scalable services with a Kafka buffer in front of storage, which is more operationally flexible at very high volume; Zipkin gives up that separation in exchange for one binary, one deployment, and one thing to reason about when something breaks.

The instrumentation and config you actually write

☺ Like you're 10: A few lines of setup code wire your app's reporter to Zipkin's address, and a handful of environment variables tell zipkin-server itself where to store what it receives.

For JVM services, Brave (io.zipkin.brave) is the library that creates spans, runs the sampler, and hands finished spans to a reporter. A minimal setup looks like this:

Sender sender = URLConnectionSender.create("http://zipkin:9411/api/v2/spans");
AsyncReporter<zipkin2.Span> spanReporter = AsyncReporter.create(sender);

Tracing tracing = Tracing.newBuilder()
    .localServiceName("checkout")
    .spanReporter(spanReporter)
    .sampler(Sampler.create(0.1f))          // head-based: 10% of new traces, decided at the root
    .propagationFactory(B3Propagation.FACTORY)
    .build();

Tracer tracer = tracing.tracer();

Non-JVM services use one of the community zipkin-reporter libraries for Go, Node.js, Python, Ruby, and others — the wire format (JSON spans to /api/v2/spans) and the B3 header contract are the same regardless of language, which is precisely what let a mixed-language fleet adopt Zipkin at all. Every one of these libraries is also a plausible answer if you inherit a service still emitting Zipkin-format spans and need to identify what's producing them.

zipkin-server itself is configured almost entirely through environment variables or an application.yml, and the storage backend is the one decision that actually matters operationally:

# In-memory — for a laptop or a CI job only. Every span vanishes on restart.
docker run -d -p 9411:9411 openzipkin/zipkin

# Elasticsearch-backed — the common production choice
docker run -d -p 9411:9411 \
  -e STORAGE_TYPE=elasticsearch \
  -e ES_HOSTS=http://elasticsearch:9200 \
  -e ES_INDEX=zipkin \
  openzipkin/zipkin

# Cassandra-backed — the other production option, better raw write throughput
docker run -d -p 9411:9411 \
  -e STORAGE_TYPE=cassandra3 \
  -e CASSANDRA_CONTACT_POINTS=cassandra:9042 \
  openzipkin/zipkin

# Kafka as the transport in front of storage — durability under a storage outage
docker run -d -p 9411:9411 \
  -e STORAGE_TYPE=elasticsearch -e ES_HOSTS=http://elasticsearch:9200 \
  -e KAFKA_BOOTSTRAP_SERVERS=kafka:9092 \
  openzipkin/zipkin
⚠ Cassandra needs its schema created before Zipkin will store anything

Unlike Elasticsearch, which Zipkin will happily create indices against on first write, the Cassandra storage backend expects its keyspace and tables to already exist. Run the schema script shipped in the openzipkin/zipkin source tree (cassandra-schema.cql) against your cluster before pointing zipkin-server at it — skip this step and the server starts cleanly, accepts spans over the API, and silently drops every one of them because the write target doesn't exist.

Day-to-day commands and the API

☺ Like you're 10: Everything the web page shows you is also a plain URL you can curl — handy for checking "did a trace actually arrive" from a script instead of a browser.

# Which services have ever reported a span?
curl -s localhost:9411/api/v2/services | jq .

# Operations (span names) known for one service
curl -s "localhost:9411/api/v2/spans?serviceName=checkout" | jq .

# Recent traces for a service, newest first
curl -s "localhost:9411/api/v2/traces?serviceName=checkout&limit=20" | jq -r '.[][0].traceId'

# One specific trace, in full — every span sharing that traceId
curl -s localhost:9411/api/v2/trace/5b8efff798038103d269b633813fc60c | jq .

# The service dependency graph, built from observed traces over a window
curl -s "localhost:9411/api/dependencies?endTs=$(date +%s000)&lookback=86400000" | jq .

# Post a span directly — the same endpoint every reporter targets
curl -s -X POST localhost:9411/api/v2/spans \
  -H 'Content-Type: application/json' \
  -d '[{"traceId":"5b8efff798038103d269b633813fc60c","id":"eee19b7ec3c1b174",
       "name":"hello","kind":"SERVER","timestamp":1700000000000000,"duration":100000,
       "localEndpoint":{"serviceName":"curl-test"}}]'

# Health and metrics — zipkin-server is a Spring Boot Actuator app
curl -s localhost:9411/actuator/health
curl -s localhost:9411/actuator/prometheus | grep zipkin_collector   # scrapeable by Prometheus

The UI itself — Zipkin Lens, a React rewrite that replaced the original AngularJS UI — lives on the same port 9411 and reads from exactly these same API endpoints. Search by service, span name, tags, and a minimum duration; open a trace to get the waterfall; open the dependency graph to see the DAG the way Jaeger's System Architecture tab shows it. If you've read Jaeger's page first, the UI vocabulary and the API shape will already feel familiar — that's the shared ancestry, not a coincidence.

Gotchas and failure modes

☺ Like you're 10: Most of the ways Zipkin quietly disappoints you have nothing to do with a bug — they're a default setting, or an old system talking a slightly different dialect than a new one.

The in-memory storage trap is the same one Jaeger's allInOne strategy sets: the plain docker run openzipkin/zipkin command with no STORAGE_TYPE set keeps every trace in a JVM heap that empties on restart or redeploy. It's exactly right for a laptop or a CI smoke test and exactly wrong for anything a team is relying on during an incident.

Sampling has no central control plane. Where Jaeger's collector serves per-service sampling policy over a remote-sampling endpoint that clients poll, Brave's sampler is configured locally, in each service's own startup code. Changing a sample rate fleet-wide means changing and redeploying config in every service, or building your own shared-config convention around it — there's no single dial equivalent to Jaeger's port 5778. Teams migrating onto OpenTelemetry frequently cite exactly this as a reason: OTel's remote_sampling extension or a centrally-managed processor gives back the "change it in one place" control Zipkin never had built in.

Retention is nobody's job by default. Elasticsearch storage writes daily indices (zipkin-span-YYYY-MM-DD) that grow forever unless something — Curator, an ILM policy, a cron job — deletes old ones; Cassandra needs a TTL set on write, or old spans simply accumulate. Zipkin doesn't apply either for you, the same silent-until-the-disk-fills failure mode Jaeger's own storage section warns about.

128-bit vs. 64-bit trace IDs can bite at an integration boundary: Zipkin's original 64-bit trace ID format still shows up in older Brave configurations and legacy clients, while anything speaking W3C Trace Context — the OpenTelemetry default — is 128-bit only. A 64-bit Zipkin trace ID zero-pads cleanly into a 128-bit field, but a service or tool that assumes 128 bits throughout can mishandle an unpadded 64-bit ID it wasn't expecting.

v1 annotations turning up in a v2 world. A sufficiently old client library, or raw JSON exported years ago, can still carry the cs/sr/ss/cr annotation quartet instead of a kind field. Modern zipkin-server versions translate v1 spans on ingest, but a hand-rolled consumer of Zipkin's export format that only understands v2 will silently misread — or drop — anything still in the old shape.

And, underneath all of the above, the B3-vs-traceparent split from the data-model section remains the single most common integration failure once any part of the fleet starts speaking OpenTelemetry's default propagator — a trace doesn't corrupt, it just quietly becomes two traces at the boundary nobody re-instrumented.

Zipkin vs. Jaeger vs. OpenTelemetry — and the migration path off it

☺ Like you're 10: All three speak a related language now, so swapping the filing cabinet — even swapping it gradually, one shelf at a time — is a well-worn move, not a rewrite.

DimensionZipkinJaegerOpenTelemetry + a backend
RoleInstrumentation library (Brave) + trace backend, bundledTrace backend onlyVendor-neutral instrumentation + Collector; stores nothing itself
ArchitectureOne process (zipkin-server): collector + query API + UISeparate collector, query, and optional Kafka-buffered ingesterSDK in-process, Collector as a separate router — many possible backends
Default propagationB3 (multi- or single-header)W3C Trace Context natively; Jaeger-native and Zipkin-compatible ingest also supportedW3C Trace Context by default; B3 available via a configurable propagator
GovernanceIndependent OSS (openzipkin), never CNCFCNCF graduated (2019)CNCF top-tier project (graduated 2021)
MomentumWidely considered maintenance modeActively developed; v2 built on the OTel Collector frameworkThe industry's converging standard for new instrumentation
Best whenYou're operating an existing Zipkin deployment, or need the smallest possible footprint to try tracing at allYou want a dedicated, self-hosted tracing UI and are starting freshAlways, for new instrumentation — it's the layer in front of whichever backend you pick

The genuinely good news for anyone holding a Zipkin deployment is that the migration path onto OpenTelemetry is short and has been walked by enough teams to be boring rather than risky, and it doesn't require a flag day. The OpenTelemetry Collector ships both a zipkin receiver and a zipkin exporter, which means the swap can happen in stages:

# otel-collector-config.yaml — transitional pipeline: old apps keep pointing at
# Zipkin's format, new apps send OTLP, and BOTH still land in the existing
# Zipkin server while a new backend is proven out alongside it.
receivers:
  zipkin:                              # legacy apps' Zipkin reporters point HERE now, unchanged
    endpoint: 0.0.0.0:9411
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 } # new/re-instrumented apps send OTLP here

processors:
  batch: {}

exporters:
  zipkin:                              # keep populating the existing Zipkin UI during the transition
    endpoint: http://zipkin-server:9411/api/v2/spans
  otlp/jaeger:                         # the new backend, running alongside it
    endpoint: jaeger-collector:4317
    tls: { insecure: true }

service:
  pipelines:
    traces:
      receivers:  [zipkin, otlp]
      processors: [batch]
      exporters:  [zipkin, otlp/jaeger]   # fan out to BOTH during the cutover window

Read that pipeline as four moves, run in order rather than all at once. First, put the Collector in front of the existing Zipkin server: legacy services keep reporting to the exact same address and format, now landing on the Collector's zipkin receiver instead of directly on zipkin-server, with zero code change anywhere. Second, instrument any new or re-instrumented service with the OpenTelemetry SDK, sending OTLP straight to the same Collector. Third, fan the Collector's output to both the old Zipkin backend and a new one — Jaeger, or any OTLP-native backend — so both UIs stay populated while trust builds in the new one; note that even simpler than running two exporters, Jaeger itself accepts Zipkin-format spans natively on its own port 9411, so "swap the backend, keep the format" can be a same-day change if Jaeger is the target. Fourth, once every service is OTel-instrumented, switch default propagation from B3 to W3C traceparent using a composite propagator during the switch so nothing splits mid-migration, drop the zipkin exporter, and decommission zipkin-server and its storage cluster last, only after nothing is reading from it anymore.

Where Zipkin fits in the SREF blueprint

☺ Like you're 10: The exam cares that you recognize "this is a distributed tracer" from a description, not that you've memorized Zipkin's specific port numbers.

The DevOps Institute SRE Foundation (SREF) exam is closed-book and tests tool categories rather than vendor or project trivia, as SRE Tools & Automation covers in full. Zipkin is a representative name for the distributed-tracing row alongside Jaeger and OpenTelemetry, and Monitoring & Service Level Indicators is the domain that actually gets tested on what tracing is for. As with the other tool-specific pages on this site, verify current exam weightings directly with the DevOps Institute before treating any percentage as fixed. What's durable knowledge either way: what a trace is, why span-level timing beats logs and metrics for a "where did the time go across services" question, and that Zipkin is the project that first made that idea a working open-source tool — not the current market leader for adopting it fresh.

🎬 At the Reliability Watch
🐘

Ellie the Elephant: The payments service we inherited from the acquisition is still reporting spans in Zipkin's format. Nobody's touched it in three years.

🦊

Foxy: Why is anything still running Zipkin? Isn't that the old one?

🐿️

Nutty the Squirrel: "Old" is doing a lot of work in that sentence. I catalogued it — Zipkin is where the words "span" and "trace" came from in the first place. OpenTelemetry's whole data model is downstream of it.

🦫

Benny the Beaver: So I don't need to rewrite payments to get it onto our new pipeline. I just put the OTel Collector's Zipkin receiver in front of it — the reporter in that service doesn't even know anything changed.

🐢

Timmy the Turtle: Before you call it done — is payments still on B3 headers, and is everything downstream of it on W3C traceparent now? A trace that quietly splits in two is worse than one that never showed up.

🐘

Ellie the Elephant: Checked. We're running a composite propagator on the boundary services until every hop is confirmed OTel-native. Nothing gets cut over until Timmy signs off on that.

✓ Checkpoint

1. Where did Zipkin originate, roughly when, and what earlier system did it implement the ideas of? 2. What four vocabulary terms did Zipkin establish that OpenTelemetry and Jaeger both still use? 3. What is B3 propagation, and what happens when a service using it talks to a service using W3C traceparent instead? 4. Name two concrete architectural differences between Zipkin and Jaeger. 5. What's the biggest practical gap in Zipkin's sampling model compared to Jaeger's remote sampling? 6. Describe, in order, the four moves of a typical Zipkin-to-OpenTelemetry migration.

Check your answers
  1. Zipkin began at Twitter in 2012 (credited largely to Adrian Cole) as an open-source implementation of the ideas in Google's 2010 Dapper paper on production tracing at scale.
  2. Span, trace, parent span (the parent/child relationship), and tags — the vocabulary that OpenTracing, OpenCensus, and ultimately OpenTelemetry all inherited, directly or through that lineage, along with Jaeger's own data model.
  3. B3 propagation is Zipkin's header format for carrying trace context between services — either five X-B3-* headers or one combined b3 header. A service using B3 and a service using W3C traceparent do not interoperate automatically; the trace doesn't degrade, it splits into two separate traces at the boundary, unless a configurable or composite propagator (available in OpenTelemetry SDKs) is used to bridge the two formats.
  4. Zipkin bundles the collector, storage query API, and UI into one process (zipkin-server), scaled by running identical stateless replicas; Jaeger splits those into separate collector, query, and (optionally) Kafka-buffered ingester services that scale independently. Zipkin was never a CNCF project; Jaeger graduated CNCF in 2019.
  5. Zipkin's sampler (via Brave) is configured locally in each service's own code, with no central endpoint to change it fleet-wide; Jaeger's collector serves a per-service sampling policy over a remote-sampling endpoint (port 5778) that clients poll, so a rate change is a config push rather than a redeploy everywhere.
  6. First, put an OpenTelemetry Collector in front of the existing Zipkin server using its zipkin receiver, so legacy apps keep reporting unchanged. Second, instrument new or updated services with the OTel SDK sending OTLP to the same Collector. Third, fan the Collector's output to both the old Zipkin backend and a new OTLP-native backend (or point straight at Jaeger's own Zipkin-compatible ingest port) so both stay populated during the transition. Fourth, once every service is OTel-instrumented, switch propagation from B3 to W3C traceparent using a composite propagator during the cutover, drop the Zipkin exporter, and decommission zipkin-server and its storage last.