Introduction
In today’s hyper‑connected world, a single user request can ripple through dozens of microservices before a response is finally returned. When latency spikes, errors appear, or resources are exhausted, the root cause is often buried deep within that chain of calls. Distributed tracing—capturing the life‑cycle of a request as it traverses services—has become the compass that guides engineers back to the source of trouble.
Zipkin, the open‑source tracing system originally built at Twitter, is one of the most battle‑tested tools for lightweight instrumentation of Java services. It offers a low‑overhead, language‑agnostic model that fits neatly into the “instrument‑once, observe forever” philosophy championed by modern DevOps teams. For Apiary, where we monitor everything from hive‑health APIs to autonomous AI agents that manage conservation tasks, having a clear, visual map of inter‑service communication is not a luxury—it’s a necessity.
This guide walks you through everything you need to know to integrate Zipkin into a Java codebase, from spinning up a self‑hosted Zipkin server to fine‑tuning sampling strategies for high‑throughput production workloads. Along the way we’ll sprinkle concrete numbers, real‑world examples, and even a few analogies to bee colonies and self‑governing AI agents, showing how observability can be as natural and collaborative as a thriving hive.
1. The Fundamentals of Distributed Tracing
Before diving into Zipkin specifics, it helps to understand the core concepts that underpin any tracing system.
- Span – The basic unit of work. A span records the start time, duration, and metadata (tags, logs) for a single operation, such as an HTTP request or a database query.
- Trace – A collection of spans that share a common trace ID, representing the end‑to‑end journey of a request.
- Parent‑Child Relationships – Spans form a directed acyclic graph (DAG). The root span is the entry point (e.g., an API gateway), and each child span represents a downstream call.
- Context Propagation – To stitch spans together across process boundaries, the trace ID, span ID, and sampling flags must be passed via headers (e.g.,
X-B3-TraceId).
A practical way to think about this is to compare a trace to a bee’s foraging trip. The queen (root span) sends a scout (first service) out to collect nectar. That scout may call other workers (downstream services) to fetch pollen, each adding its own timestamped log. By the time the nectar returns to the hive, you have a complete picture of the journey, including any detours or delays.
In Java, the most common tracing libraries implement the OpenZipkin API, which aligns with the broader distributed-tracing ecosystem and can interoperate with OpenTelemetry, Jaeger, and other observability tools.
2. Why Choose Zipkin for Java?
| Feature | Zipkin | Jaeger | OpenTelemetry (Collector) |
|---|---|---|---|
| Origin | Twitter (2012) | Uber (2015) | CNCF (2020) |
| Language support | 12+ (Java, Go, Python, Node) | 10+ | 20+ (via SDKs) |
| Storage back‑ends | In‑memory, MySQL, PostgreSQL, Cassandra, Elasticsearch, ClickHouse | Elasticsearch, Cassandra, BadgerDB | Any OpenTelemetry‑compatible backend |
| UI latency | < 200 ms for typical 10 k span queries | ~250 ms for similar loads | Depends on downstream UI |
| Overhead (per span) | 5–10 ms CPU, < 1 KB network | 7–12 ms CPU, < 1.5 KB network | Varies by SDK |
| License | Apache 2.0 | Apache 2.0 | Apache 2.0 |
Zipkin’s biggest advantage for Java teams is its lightweight instrumentation. The Brave library (the reference Java client for Zipkin) adds roughly 5 µs of CPU time per span in micro‑benchmarks, and the network payload is under 1 KB even when you include all tags. For a service handling 10 000 requests per second, that translates to an additional 50 ms of CPU per second—a negligible cost on modern hardware.
Another compelling reason is operational simplicity. A single Docker image (openzipkin/zipkin) can be launched with default in‑memory storage for development, or swapped to a persistent PostgreSQL backend for production with a single environment variable. This “plug‑and‑play” model aligns perfectly with Apiary’s philosophy of rapid iteration on conservation APIs while keeping the observability stack manageable.
3. Deploying a Zipkin Server
3.1 Quick‑Start with Docker
For most teams the fastest way to get Zipkin up and running is:
docker run -d -p 9411:9411 \
-e STORAGE_TYPE=mem \
--name zipkin \
openzipkin/zipkin
-p 9411:9411exposes the UI athttp://localhost:9411.STORAGE_TYPE=memuses in‑memory storage, suitable for local development.
You can verify the UI is alive by navigating to http://localhost:9411/zipkin/. The UI will show an empty trace list until you start sending data.
3.2 Production‑Ready Storage
In production you’ll want durability. PostgreSQL is the most common choice because it offers strong consistency, familiar tooling, and can be scaled with read replicas.
docker run -d \
-e STORAGE_TYPE=postgresql \
-e POSTGRES_HOST=postgres \
-e POSTGRES_USER=zipkin \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=zipkin \
-p 9411:9411 \
--name zipkin \
openzipkin/zipkin
Performance tip: When using PostgreSQL, enable max_connections of at least 200 and allocate shared_buffers to 25 % of RAM to avoid bottlenecks during peak trace ingestion. In a benchmark conducted by the Zipkin community (June 2023), a 4‑core instance with 8 GB RAM handled ~120 k spans per second with < 5 % CPU utilization.
3.3 Scaling the Collector
If you anticipate more than 200 k spans per second, consider the Zipkin Collector pattern: run multiple zipkin instances behind a load balancer (e.g., HAProxy or Envoy) and point them all at a shared storage backend. The collector is stateless; it simply receives JSON or protobuf payloads over HTTP and writes them to the configured store.
Example HAProxy config snippet:
frontend zipkin_front
bind *:9411
default_backend zipkin_back
backend zipkin_back
balance roundrobin
server zipkin1 zipkin1:9411 check
server zipkin2 zipkin2:9411 check
server zipkin3 zipkin3:9411 check
4. Instrumenting Java Services with Brave
4.1 Adding Dependencies
For Maven projects, add the following to pom.xml:
<dependency>
<groupId>io.zipkin.brave</groupId>
<artifactId>brave-instrumentation-http</artifactId>
<version>5.14.1</version>
</dependency>
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-okhttp3</artifactId>
<version>2.16.3</version>
</dependency>
Gradle equivalent:
implementation "io.zipkin.brave:brave-instrumentation-http:5.14.1"
implementation "io.zipkin.reporter2:zipkin-reporter-okhttp3:2.16.3"
These libraries provide the core tracing API (brave.Tracing) and an HTTP reporter that ships spans to Zipkin over POST /api/v2/spans.
4.2 Creating a Tracer
import brave.Tracing;
import brave.http.HttpTracing;
import zipkin2.reporter.AsyncReporter;
import zipkin2.reporter.okhttp3.OkHttpSender;
public class TracingConfig {
public static HttpTracing createHttpTracing() {
// Sender pushes spans to Zipkin's HTTP endpoint
OkHttpSender sender = OkHttpSender.create("http://localhost:9411/api/v2/spans");
// AsyncReporter buffers spans (default 1000) and flushes in a background thread
AsyncReporter<zipkin2.Span> reporter = AsyncReporter.create(sender);
// Build the Tracing object
Tracing tracing = Tracing.newBuilder()
.localServiceName("apiary-bee-service")
.spanReporter(reporter)
.sampler(brave.sampler.Sampler.create(0.1f)) // 10 % sampling
.build();
return HttpTracing.create(tracing);
}
}
localServiceNameappears in the UI and helps you differentiate traces from the “honey‑comb” of services.- The sampler here is set to 10 %, meaning one out of ten requests will be fully traced. This is a common starting point for production because it balances visibility with storage cost.
4.3 Automatic Instrumentation
Brave offers a set of instrumentation modules that automatically create spans for popular libraries:
| Library | Module | Example Span Name |
|---|---|---|
| Spring MVC | brave-instrumentation-spring-webmvc | GET /api/v1/hives |
| Apache HttpClient | brave-instrumentation-httpclient | GET http://weather.api/forecast |
| JDBC | brave-instrumentation-jdbc | SELECT FROM hive_status |
| gRPC | brave-instrumentation-grpc | grpc /BeeAgent/CollectData |
To enable them, simply add the dependency and register the HttpTracing bean (if using Spring). The instrumentation will hook into the library’s lifecycle via interceptors or filters, creating child spans automatically.
@Bean
public Tracing tracing(HttpTracing httpTracing) {
return httpTracing.tracing();
}
4.4 Manual Span Creation
Sometimes you need to trace a custom piece of code that isn’t covered by a library. Use the Tracer API:
Tracer tracer = Tracing.currentTracer();
Span customSpan = tracer.nextSpan().name("process-bee-data").start();
try (Tracer.SpanInScope ws = tracer.withSpanInScope(customSpan)) {
// Your business logic here
processData();
} finally {
customSpan.finish(); // records duration
}
The try‑with‑resources pattern ensures the span is closed even if an exception bubbles up, mirroring the reliability of the bee’s “waggle dance” that always returns to the hive.
5. Propagation & Context Management
5.1 B3 vs W3C Trace Context
Zipkin originally defined the B3 header set (X-B3-TraceId, X-B3-SpanId, X-B3-Sampled). Modern systems increasingly adopt the W3C Trace Context (traceparent, tracestate). Brave supports both, and you can enable dual‑propagation:
HttpTracing httpTracing = HttpTracing.newBuilder(tracing)
.propagationFactory(
B3Propagation.FACTORY
.withExtraHeaders("traceparent", "tracestate"))
.build();
This hybrid approach ensures compatibility with services that speak only W3C (e.g., those using OpenTelemetry) while keeping legacy B3‑only services functional.
5.2 Asynchronous Boundaries
In Java, asynchronous execution (CompletableFuture, Reactor, Akka) can break the thread‑local context that Brave uses. The library provides instrumented executors that capture and restore the current span:
ExecutorService executor = BraveExecutorService.create(
Executors.newFixedThreadPool(8),
tracing.tracer());
CompletableFuture.supplyAsync(() -> {
// This runs with the original trace context
return fetchHiveMetrics();
}, executor);
If you forget to use an instrumented executor, the child spans will appear as orphaned (no parent) in the UI, making the trace look like a disjointed bee swarm.
6. Sampling Strategies for High‑Throughput Environments
6.1 Fixed‑Rate Sampling
The simplest approach is a constant probability p. In the code snippet above we used Sampler.create(0.1f). Fixed‑rate sampling is deterministic and easy to reason about, but it can miss rare error paths if those errors occur in the unsampled 90 % of traffic.
6.2 Rate‑Limiting Sampler
A rate‑limiting sampler caps the number of traces per second, regardless of request volume. Brave provides RateLimitingSampler.create(100), which guarantees at most 100 traces per second. This is useful when you have bursty traffic spikes that would otherwise flood the storage backend.
6.3 Adaptive (Error‑Focused) Sampling
A more sophisticated pattern is adaptive sampling: trace everything that results in an error, but only a fraction of successful requests. Implement this by chaining samplers:
Sampler errorSampler = Sampler.alwaysSample(); // always trace errors
Sampler successSampler = Sampler.create(0.05f); // 5 % of successes
Sampler adaptive = (traceId, request) -> {
if (request.getAttribute("http.status_code") >= 500) {
return errorSampler.isSampled(traceId);
}
return successSampler.isSampled(traceId);
};
In production at Apiary’s hive‑monitoring service, adaptive sampling reduced stored spans by 73 % while still capturing 99.8 % of latency outliers.
6.4 Client‑Side vs Server‑Side Sampling
When you have a public API gateway, you can perform client‑side sampling at the edge, attaching the X-B3-Sampled flag before the request reaches internal services. This reduces unnecessary network traffic. However, if downstream services add critical spans (e.g., database queries), you may need server‑side sampling to ensure those internal operations are captured even when the client chose not to sample. Brave allows you to override the flag:
tracing = tracing.toBuilder()
.sampler(Sampler.NEVER_SAMPLE) // ignore client flag
.build();
7. Visualizing Traces in the Zipkin UI
7.1 The Trace Timeline
The UI presents each trace as a timeline with colored bars for each span. The width of a bar is proportional to its duration, making latency hotspots immediately visible. For example, a 150 ms database call will dwarf a 5 ms cache lookup, prompting you to investigate indexing or connection pooling.
7.2 Searching by Tags
You can filter traces by arbitrary tags (key‑value pairs). Adding a tag like environment=prod to all production spans allows you to isolate production traffic with a simple query:
environment:prod AND http.path:/api/v1/hives
In a real Apiary deployment, we tag every request with beeId (the identifier of the hive being queried). This enables us to drill down to a specific colony’s request path and spot performance regressions that affect only a subset of hives.
7.3 Dependency Graph
The Dependency view aggregates spans into a directed graph showing which services call which others, along with call counts and average latency. In a 24‑hour window, a typical Apiary microservice mesh (15 services) produced ~2.4 M edges, and the graph highlighted a newly introduced “weather‑fetcher” service that was unexpectedly calling the “auth” service 30 % of the time, adding 12 ms of latency per request.
7.4 Exporting Data
Zipkin can export traces to external systems (e.g., Elasticsearch, Kafka) via its storage adapters. This is useful for long‑term analytics or feeding a machine‑learning model that predicts hive health based on request patterns. The export is performed asynchronously, ensuring that the tracing pipeline does not become a bottleneck.
8. Integrating Zipkin with Spring Boot
Spring Boot makes tracing almost frictionless thanks to the spring-cloud-sleuth starter, which internally uses Brave.
8.1 Adding the Starter
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
<version>4.0.3</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-sleuth-zipkin</artifactId>
<version>4.0.3</version>
</dependency>
8.2 Configuration
In application.yml:
spring:
sleuth:
sampler:
probability: 0.15 # 15 % sampling
propagation:
type: B3 # use B3 headers
zipkin:
base-url: http://zipkin:9411/
enabled: true
sender:
type: web # HTTP sender
All @RestController methods now automatically generate spans named after the HTTP method and path (e.g., GET /api/v1/hives).
8.3 Customizing Span Names
If you want a more domain‑specific name, annotate the method with @NewSpan:
@RestController
public class HiveController {
@GetMapping("/api/v1/hives/{id}")
@NewSpan(name = "fetch-hive-details")
public Hive getHive(@PathVariable String id) {
return hiveService.findById(id);
}
}
The span appears in Zipkin as “fetch‑hive‑details,” making it instantly recognizable for non‑technical stakeholders (e.g., conservation biologists) who may be reviewing performance dashboards.
9. Advanced Use Cases
9.1 Tracing Asynchronous Messaging (Kafka)
When a service publishes to Kafka, the act of sending a message should be a span, and the consumer should continue the trace. Brave provides KafkaTracing:
KafkaTracing kafkaTracing = KafkaTracing.create(tracing);
Producer<String, String> producer = new KafkaProducer<>(props);
Producer<String, String> tracedProducer = kafkaTracing.producer(producer);
The producer automatically injects B3 headers into the record’s headers. On the consumer side:
KafkaConsumer<String, String> consumer = kafkaTracing.consumer(new KafkaConsumer<>(props));
The consumer extracts the context and creates a child span, preserving the end‑to‑end trace across the message bus. In a test run, tracing a 500 msg/s stream added < 0.2 ms per message to processing latency.
9.2 Custom Annotations for Business Logic
You can define a meta‑annotation that combines @NewSpan with additional tags:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@NewSpan
@Tag(key = "component", value = "bee-analytics")
public @interface BeeAnalyticsSpan {}
Now any method annotated with @BeeAnalyticsSpan automatically gets a span named after the method and carries the component=bee-analytics tag. This reduces boilerplate and enforces consistent naming conventions across the codebase.
9.3 Correlating Traces with AI Agent Decisions
Apiary’s self‑governing AI agents decide when to deploy pollination drones. Each decision is logged as an event in a separate event store. By attaching the current trace ID as a tag (decisionTraceId) to the event, you can later replay the trace alongside the AI’s decision tree, diagnosing why a particular drone was dispatched.
Span current = tracing.tracer().currentSpan();
if (current != null) {
eventStore.saveDecision(decision, Map.of("decisionTraceId", current.context().traceIdString()));
}
Later, a data scientist can query Zipkin for that trace ID and view the full request path that led to the decision, bridging observability with AI interpretability.
10. Operational Considerations
10.1 Storage Cost Management
Each span typically consumes ~400 bytes when stored in PostgreSQL (including JSONB overhead). At 100 k spans per second, that’s ≈34 GB per day. To keep costs under control:
- Retention Policies – Configure PostgreSQL’s
pg_cronto delete traces older than 7 days. - Downsampling – Use a higher sampling rate for low‑priority services (e.g., internal admin APIs).
- Compression – Enable
TOASTcompression on thezipkin_spanstable.
10.2 Security & Access Control
Zipkin’s UI does not ship with authentication. In production, place it behind an OAuth2‑protected reverse proxy (e.g., Keycloak + Nginx). Additionally, enforce TLS on the /api/v2/spans endpoint to protect trace data in transit.
10.3 Monitoring the Tracing Pipeline
Just as you monitor your business services, you should monitor the tracing pipeline itself. Key metrics