The previous lesson closed with two limits that neither of the earlier signals can overcome. The first: the identifier the 03-06 TraceFilter generates exists only inside CicloUrbana; when the request goes out to the payment gateway from 07-06, the provider does not know it, and if tomorrow the monolith is split up (07-05), each service will generate its own and the correlation will break exactly at the boundary where it is needed most.
The second is deeper and does not depend on splitting anything. A POST /api/v1/rentals request takes 2.1 seconds. The metrics from 09-04 say so: the p99 has gone up. The logs from 09-05 confirm it: there is a line on the way in and another on the way out, with 2.1 seconds between them. And that is where the information stops. Where did those two seconds go? In the validation, in the three queries, in the cache, in the charge, in sending the email? Neither a numerical aggregate nor a succession of timestamped lines answers that precisely, because neither of them knows the structure of the operation.
Distributed tracing does. It breaks each request down into a tree of timed, nested operations, and it lets you look at one specific request and point a finger at the piece that ate 80 % of the time. This lesson builds the whole thing in CicloUrbana: the concepts, context propagation between processes, Micrometer Tracing —the replacement for the discontinued Spring Cloud Sleuth—, custom spans with the Observation API you already know from 09-03, the correlation of the three signals, a backend to read the waterfall in, and sampling, which is the decision that makes all of this economically viable.
Contents
- The problem: where did the time go
- Concepts: trace, span and context
- A rental trace, broken down
- Context propagation between processes
- Sleuth is dead: Micrometer Tracing
- Instrumenting CicloUrbana
- What is instrumented without writing code
- Custom spans with the Observation API
- Correlating the three signals
- Exemplars: from the graph to the trace
- The backend: Grafana Tempo
- Reading a span waterfall
- Sampling: head, tail and errors
- The OpenTelemetry Collector
- Tracing and microservices
- The OpenTelemetry agent versus Micrometer
- Cost, overhead and what not to trace
- Worked example: the rentals p99
- Common Mistakes and Tips
- Exercises
- The problem: where did the time go
The real flow of a rental in Ribalta crosses five components:
sequenceDiagram
participant M as Mobile app
participant A as CicloUrbana API
participant C as Cache · 09-02
participant B as PostgreSQL
participant P as Payment gateway · 07-06
M->>A: POST /api/v1/rentals
A->>C: look up fare
C-->>A: cache miss
A->>B: select fare
A->>B: select user, bike, station
A->>B: insert rental
A->>P: authorise charge
P-->>A: authorised (1,740 ms)
A->>B: update rental
A-->>M: 201 Created (2,100 ms)
With metrics you know the whole operation took 2.1 s. With logs you know it started and finished. Neither tells you that 1,740 of those 2,100 milliseconds were taken by the gateway, nor that there was a cache miss that added a query, nor how many queries there actually were.
You could instrument each step with a Timer from 09-03, and many teams do. The result is dozens of loose metrics that lose the relationship: you cannot tell which gateway call belongs to which request, or whether the three queries were sequential or parallel, or why this specific request —the one from the citizen who has phoned the council— took as long as it did. The trace preserves that relationship: it is the structure of an operation, timed.
- Concepts: trace, span and context
| Concept | What it is | In CicloUrbana |
|---|---|---|
| Trace | The complete tree of an operation from start to finish | A whole POST /api/v1/rentals |
traceId |
The trace's unique identifier, 128 bits | Shared by all its spans and by every service |
| Span | A unit of work with a start, an end and a name | "insert rental", "authorise charge" |
spanId |
The span's identifier, 64 bits | Unique within the trace |
| Parent span | The span that gave rise to this one | The HTTP span is the parent of the query span |
| Trace context | traceId + spanId + flags, which travel |
What gets propagated in the headers |
| Attributes | The span's key-value pairs | station=2, fare=STUDENT |
| Events | Point-in-time marks inside a span | "circuit breaker opened" |
| Status | Whether the span ended well or with an error | The recorded exception |
| Sampling | Deciding which traces are kept | 10 % in production |
Two ideas that clarify the model. A span is a Timer with a family tree: it measures the same thing, but it also knows who called it and what it called, and that relationship is the whole difference. And the traceId is the same throughout the operation, even across processes: it is what lets you see on one screen the work done by CicloUrbana and the work done by another service.
- A rental trace, broken down
gantt
title Trace of POST /api/v1/rentals (traceId a3f19c2e...) - 2,100 ms
dateFormat X
axisFormat %L
section HTTP
POST /api/v1/rentals :0, 2100
section Service
RentalService.start :12, 2080
section Cache
stations cache (miss) :20, 22
section Database
select fare :24, 31
select user and bike :33, 48
insert rental :50, 62
update rental :1880, 1894
section External
POST gateway /authorisations :140, 1880
Read from top to bottom, the trace tells the complete story: the root span lasts 2,100 ms; inside it, the service lasts 2,080; inside that there is a cache miss, three fast queries adding up to 45 ms and a span of 1,740 ms out to the gateway. The diagnosis is immediate and needs no interpretation: 83 % of the time is in a system we do not control.
Compare that with what we had: a metric saying "2.1 s" and two log lines. The difference is not one of quantity of information, it is one of shape: the trace has structure, and structure is what allows time to be attributed.
- Context propagation between processes
For the span created by the gateway to belong to the same trace as ours, the context has to travel in the HTTP request. There are two formats and one has won:
W3C Trace Context (traceparent) |
B3 (Zipkin) | |
|---|---|---|
| Standard | A W3C recommendation | De facto, from Zipkin |
| Headers | traceparent, tracestate |
X-B3-TraceId, X-B3-SpanId, X-B3-Sampled, X-B3-ParentSpanId |
| Format | One compact header | Several headers, or a condensed b3 |
| Interoperability | Universal: OTel, commercial vendors, service meshes | Good within the Zipkin/Brave ecosystem |
| Status | The one to use today | Compatibility with existing systems |
A real traceparent header:
traceparent: 00-a3f19c2e8b7d4f6a9c1e2d3b4a5f6e7d-b7d4f6a9c1e2d3b4-01
│ │ │ │
│ │ │ └─ flags: 01 = sampled
│ │ └─ parent span (16 hex)
│ └─ traceId (32 hex)
└─ versionThe four hyphen-separated fields are the whole mechanism. The final flag is more important than it looks: it carries the sampling decision, so that if we decide to keep this trace, the next service keeps its part too; without that agreement, traces would come out incomplete. tracestate is the complementary header where each vendor adds information of its own without breaking the standard.
The practical rule: use W3C by default, and additionally enable B3 only if you have to talk to an older system that only understands Zipkin. Micrometer can emit and accept both at once.
- Sleuth is dead: Micrometer Tracing
Anyone who has seen Spring Boot 2 projects will know Spring Cloud Sleuth, which did exactly this. It is worth saying plainly: Sleuth is discontinued and does not support Spring Boot 3. Its functionality moved to the Micrometer project, as part of the unified observability model we already used in 09-03.
| Option | What it is | Status | When to choose it |
|---|---|---|---|
| Spring Cloud Sleuth | The Boot 2 solution | Discontinued | Never in a new project |
| Micrometer Tracing + OTel bridge | Micrometer's facade over OpenTelemetry | Recommended | CicloUrbana: the standard everything converges on |
| Micrometer Tracing + Brave bridge | A facade over Brave (Zipkin) | Supported | A Zipkin already exists in the organisation |
OpenTelemetry agent (-javaagent) |
Instrumentation without touching the code | Valid | Applications that cannot be modified (section 16) |
The architecture reproduces the idea behind SLF4J and Micrometer Metrics: Micrometer Tracing is the facade, and behind it goes a bridge to a real implementation. Your code talks to Observation and Tracer; the bridge decides whether that ends up in OpenTelemetry or in Brave. Switching from one to the other means switching two dependencies.
CicloUrbana's choice is the bridge to OpenTelemetry, for the reason we anticipated at the end of 09-04: OTLP is the protocol accepted by Tempo, Jaeger, Grafana Cloud, Datadog, New Relic and Elastic APM, so the instrumentation is not tied to any destination.
- Instrumenting CicloUrbana
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>The first provides the facade and the bridge; the second, the exporter that sends the spans over OTLP. The versions are governed by the spring-boot-starter-parent from 01-04, so they are not declared.
spring:
application.name: ciclourbana # becomes service.name in the traces
management:
tracing:
enabled: true
sampling.probability: 0.1 # 10 % in production (section 13)
propagation:
type: w3c # w3c, b3 or both: [w3c, b3]
otlp.tracing:
endpoint: ${OTLP_ENDPOINT:http://otel-collector:4318/v1/traces}
timeout: 10s
logging.pattern.level: "%5p [${spring.application.name},%X{traceId:-},%X{spanId:-}]"Four decisions worth understanding. spring.application.name is mandatory in practice: without it, every trace appears under a service called unknown_service and they cannot be told apart. sampling.probability: 0.1 keeps one trace in ten, and section 13 explains why 1.0 is no good in production. The endpoint points at the Collector from section 14 and not directly at the backend, deliberately. And the log pattern is the piece that joins this lesson to the previous one: traceId and spanId appear in the MDC automatically and are written on every line.
In dev, a profile with sampling.probability: 1.0 and the endpoint pointing at a local Tempo lets you see every trace while developing, which is when they teach you the most.
- What is instrumented without writing code
This is the part that surprises people: with the two dependencies and the configuration above, most of the work is already done. Micrometer Tracing builds on the observations Spring Boot produces out of the box:
| Component | Span it generates | Automatic attributes |
|---|---|---|
| Incoming HTTP requests | A root span per request | http.method, http.route (the template), http.status_code |
Outgoing RestClient / WebClient |
A child span per call, with header propagation | URL, method, status |
@Scheduled (07-03) |
A root span per execution | The task name |
@Async and ThreadPoolTaskExecutor |
Continuation of the trace on the other thread | — |
| Spring Data / JDBC | A span per query, with the agent or datasource-micrometer |
The SQL statement (without parameters) |
| Spring Security | A span for the filter chain | — |
| Messaging (Kafka, RabbitMQ) (07-05) | A produce and a consume span, with the context in the message headers | Topic, partition |
| Resilience4j (07-06) | Events on the call's span | Circuit breaker state |
Two honest caveats. SQL queries are not instrumented on their own with the minimal configuration: you need datasource-micrometer-spring-boot or the OTel agent from section 16, and it is worth it because, as we saw in 09-01, the database is usually where the time is. And propagation in RestClient works on its own only if the client is built from the RestClient.Builder Spring injects —the bean in section 2 of 07-06 does that—; a RestClient created by hand with RestClient.create() carries no instrumentation and breaks the trace at the outer boundary, which is exactly where it hurts most.
- Custom spans with the Observation API
The automatic side covers the infrastructure. The business spans —"fare calculation", "availability validation"— have to be declared, and here the investment made in 09-03 pays off: the same code that already produced metrics starts producing spans, without changing a line.
The declarative version, over the fare calculation:
@Observed(name = "ciclourbana.fare.calculation", contextualName = "fare-calculation")
public BigDecimal calculate(FareType type, Duration duration) { ... }And the programmatic one, to instrument a specific block of RentalService with business attributes:
@Service
public class RentalService {
private final ObservationRegistry observations;
@Transactional
public RentalResponse start(StartRentalRequest request) {
return Observation.createNotStarted("ciclourbana.rental.start", observations)
.contextualName("start-rental")
.lowCardinalityKeyValue("fare", request.fareType().name())
.lowCardinalityKeyValue("station", String.valueOf(request.originStationId()))
.highCardinalityKeyValue("userId", String.valueOf(currentUser()))
.observe(() -> {
Bike bike = selectAvailable(request.originStationId());
Rental rental = register(bike, request);
return mapper.toResponse(rental);
});
}
}What this produces exactly: a Timer called ciclourbana.rental.start with fare and station tags —the low-cardinality ones, the same rules as 09-03— and a span called start-rental with those attributes plus userId, which goes only to the span. One instrumentation, two signals, and the cardinality rule turned into an API.
The important warning, which links back to 09-05: a span's attributes are stored and queried just like a log, so the policy from section 10 of the previous lesson applies in full. userId as an internal identifier is acceptable; the email, the phone number, the ID number or the citizen's GPS coordinates are not. A span is not a private place: it travels to a backend, sometimes a third party's, and it is consulted by whoever has access to it.
When fine-grained control is needed —adding an event halfway through, marking the span as failed without throwing an exception— you use the Tracer directly:
Span span = tracer.currentSpan();
if (span != null) {
span.event("circuit-breaker-open");
span.tag("fallback", "deferred-charge");
}
- Correlating the three signals
Now there are two identifiers in play under the same name: the one CicloUrbana's own TraceFilter from 03-06 generates and puts in the MDC, and the standard one Micrometer Tracing puts there too. Two producers writing the same field with different values is worse than having none, so they have to be unified, and the right answer is to keep the standard.
The unification plan, in three steps:
- Change the
TraceFilterso it generates nothing of its own. Micrometer already placestraceIdandspanIdin the MDC on every request and, unlike ours, it respects the incomingtraceparent: if the mobile app or a proxy already started a trace, it is continued instead of a new one being invented. - Keep the responsibility that genuinely was its own: returning the identifier to the client. The filter now reads
tracer.currentSpan().context().traceId()and writes it into the response header and into theGlobalExceptionHandler'sProblemDetail(03-06), so the citizen who phones the council can still read it off their screen. - Update the log pattern and the Loki queries. The field name does not change, but its format does: the value is now a 32-hex-character W3C identifier instead of the short one the filter used to generate, so any pattern, dashboard variable or saved query that assumed the old shape has to be revisited.
@Component
public class TraceFilter extends OncePerRequestFilter {
private final Tracer tracer;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
Span span = tracer.currentSpan(); // already created by the HTTP instrumentation
if (span != null) {
res.setHeader("X-Trace-Id", span.context().traceId());
}
chain.doFilter(req, res); // no MDC.put or MDC.remove: Micrometer sets them
}
}The result is the complete correlation of the three signals, which is the goal of the whole module:
| From | To | How |
|---|---|---|
| Alert (09-04) | Dashboard | The panel of the metric that fired |
| Metric | A specific trace | Exemplars (section 10) |
| Trace | That request's logs | {app="ciclourbana"} | json | traceId = "..." |
| Log | Trace | The line's traceId, in Tempo |
| A citizen phoning in | All of the above | The X-Trace-Id header in their response |
- Exemplars: from the graph to the trace
An exemplar is a link Prometheus stores alongside a histogram sample: "this observation of 2.1 seconds belongs to trace a3f19c2e...". It turns a point on a graph into a concrete case you can open.
In the /actuator/prometheus format they appear after a hash:
http_server_requests_seconds_bucket{uri="/api/v1/rentals",le="2.5"} 12801 # {trace_id="a3f19c2e8b7d4f6a9c1e2d3b4a5f6e7d"} 2.1 1756628062.481The requirements, three of them and worth keeping together: histograms enabled on that metric (percentiles-histogram, 09-03), tracing active so a traceId exists, and Prometheus started with --enable-feature=exemplar-storage, plus exemplarTraceIdDestination: trace_id in the Grafana data source.
The result in practice: on the p99 latency panel from 09-04, points appear over the curve; clicking one opens the exact trace of a request that took that long. It is the jump that was missing between "the p99 has gone up" and "look at this request". And it has a virtue that offsets the sampling: exemplars tend to point at observations in the high buckets, that is, at the slow requests, which are precisely the interesting ones.
- The backend: Grafana Tempo
The spans have to be sent somewhere that stores them and knows how to display them:
| Backend | Model | Strong at | When |
|---|---|---|---|
| Grafana Tempo | Object storage, indexes only the traceId |
Very cheap; integrated with Grafana, Loki and Prometheus | CicloUrbana |
| Jaeger | Cassandra, Elasticsearch or memory | A very good analysis interface, trace comparison | Without Grafana |
| Zipkin | Simple, long-established | Light and easy to start | Existing systems using B3 |
| Commercial | Managed | Automatic correlation and anomaly detection | Budget available |
It is added to the 09-04 observability stack:
# docker-compose.observability.yml
tempo:
image: grafana/tempo:2.5.0
command: ["-config.file=/etc/tempo/tempo.yml"]
volumes:
- ./observability/tempo.yml:/etc/tempo/tempo.yml:ro
- tempo-data:/var/tempo
ports: ["3200:3200"] # queries
networks: [ciclourbana-network]
otel-collector:
image: otel/opentelemetry-collector-contrib:0.104.0
command: ["--config=/etc/otel/config.yml"]
volumes: ["./observability/otel-collector.yml:/etc/otel/config.yml:ro"]
ports: ["4317:4317", "4318:4318"] # OTLP gRPC and HTTP
depends_on: [tempo]
networks: [ciclourbana-network]And the Grafana data source, with the links that make jumping between signals possible:
- name: Tempo
type: tempo
url: http://tempo:3200
jsonData:
tracesToLogsV2:
datasourceUid: loki
filterByTraceID: true # from a span to its log lines
lokiSearch:
datasourceUid: loki
tracesToMetrics:
datasourceUid: prometheus
- Reading a span waterfall
In the Grafana interface, a trace is shown as a waterfall: each span a horizontal bar, its length its duration and its indentation its depth in the tree.
POST /api/v1/rentals ─────────────────────────────────────────── 2,100 ms
└─ RentalService.start ─────────────────────────────────────── 2,080 ms
├─ stations cache (miss) ─ 2 ms
├─ select fare ── 7 ms
├─ select user, bike ──── 15 ms
├─ insert rental ─── 12 ms
├─ POST gateway /authorisations ───────────────────────── 1,740 ms ← 83 %
└─ update rental ─── 14 msHow it is read, in the order that works:
- The longest bar that is not the parent. It is the immediate suspect: here, the gateway with 83 % of the time.
- The gaps. If the sum of the children is much less than the parent, there is uninstrumented time: waiting for a thread, a lock, a GC pause or simply code with no span. A large gap is as informative as a long bar.
- Repetition. Fifty identical, consecutive
selectspans are an N+1 from 09-01 seen with your own eyes, and it is the fastest way there is of spotting one. - Parallelism. Bars that overlap indicate concurrent work; strictly sequential bars that could overlap are an opportunity.
- Spans in error, marked in red, with their exception as an attribute.
- Sampling: head, tail and errors
Keeping every trace is unfeasible: each one is dozens of spans of a few hundred bytes, and at 100 requests per second that comes to millions of spans a day. Sampling decides which are kept.
| Strategy | When it decides | Advantage | Drawback |
|---|---|---|---|
| Head, probabilistic | When the trace starts | Simple, cheap, consistent across services | Interesting traces are lost by chance |
| Head, rate-limited | At the start, with a per-second cap | Bounded, predictable cost | Just as blind to content |
| Tail | At the end, seeing the complete trace | Keeps every error and every slow one | Requires a Collector with memory and latency |
| Always | — | Everything available | Prohibitive cost except in dev |
Why probability: 1.0 is no good in production. Three cumulative reasons: the storage cost grows linearly with traffic; the overhead in the application —creating spans, serialising them, sending them— stops being negligible; and the network to the backend carries a constant stream. At 10 % you keep the diagnostic capability —systematic problems show up just the same in a sample— at a tenth of the cost.
But a blind 10 % has a serious flaw: the request that failed and that the citizen is phoning about has a 90 % chance of not having been kept. The solution is tail sampling in the Collector: the spans are held in memory for a few seconds and, when the trace finishes, the decision is made with the whole trace in view.
# fragment of otel-collector.yml
processors:
tail_sampling:
decision_wait: 10s
policies:
- name: all-errors
type: status_code
status_code: { status_codes: [ERROR] } # 100 % of the errors
- name: slow-ones
type: latency
latency: { threshold_ms: 1000 } # 100 % of those over 1 s
- name: sample-of-the-rest
type: probabilistic
probabilistic: { sampling_percentage: 5 } # 5 % of the normal onesThis is the policy CicloUrbana uses in production, and it answers exactly what is needed: every error, every slow one and a sample of the normal ones for reference. It requires the application to send 100 % to the Collector (sampling.probability: 1.0 in the application and the filtering in the Collector), which moves the cost of the decision to the place where it can be taken properly.
- The OpenTelemetry Collector
Sending the spans straight from the application to the backend works and is what most people do to begin with. Putting a Collector in between brings five things you soon come to appreciate:
- Decoupling: switching from Tempo to Jaeger or to a commercial service is done in the Collector, without deploying the application.
- Tail sampling, which can only be done where the complete trace converges (section 13).
- Transformation: stripping sensitive attributes before they leave —a second safety net for the 09-05 policy—, renaming, adding environment metadata.
- Buffering: if the backend goes down or slows, the Collector queues and retries instead of the application suffering it.
- A single outlet: metrics, logs and traces over the same OTLP channel.
# otel-collector.yml
receivers:
otlp:
protocols: { grpc: { endpoint: 0.0.0.0:4317 }, http: { endpoint: 0.0.0.0:4318 } }
processors:
batch: { timeout: 5s, send_batch_size: 512 }
memory_limiter: { check_interval: 1s, limit_mib: 512 }
attributes:
actions:
- key: user.email # safety net: it should never have got here
action: delete
- key: environment
value: prod
action: upsert
exporters:
otlp/tempo:
endpoint: tempo:4317
tls: { insecure: true }
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, tail_sampling, attributes, batch]
exporters: [otlp/tempo]The order of the processors matters: memory_limiter first to protect yourself, the sampling before the transformation —so as not to spend CPU transforming what is going to be discarded— and batch at the end, always, because grouping before exporting greatly reduces the number of connections.
- Tracing and microservices
In 07-05 we left a warning about splitting the monolith, and this lesson supplies the argument that was missing: distributed tracing is a prerequisite, not a later improvement.
The reason is that splitting a system destroys the diagnostic capability you had. In a monolith, a stack trace runs through the whole operation and a debugger follows it end to end. As soon as there are three services, that request becomes three processes, three logs, three deployments and no joint view: when the citizen reports that something is slow, nobody knows which of the three is the slow one, and the game of cross-accusations between teams begins. Tracing restores the single view, and that is why the correct order is to instrument first and split afterwards.
What has to be guaranteed at the boundary is simply that the context travels. With a RestClient built from Spring's Builder, it happens on its own: the traceparent header goes out on every request and the receiving service —if it is instrumented— continues the trace instead of starting another. With messaging (07-05), the context travels in the message headers, and there is a conceptual nuance: because the consumer processes later, its span is not exactly a synchronous child but a linked span, and in the interface it appears separated in time but joined by the traceId. With the payment gateway from 07-06, which is a third party, our trace ends at the outgoing call's span: we do not see inside it, but we know exactly how long it took, which is just what we needed in section 3.
- The OpenTelemetry agent versus Micrometer
There is an alternative that does not touch the code: attaching the OpenTelemetry agent to the JVM.
java -javaagent:/opt/opentelemetry-javaagent.jar \
-Dotel.service.name=ciclourbana \
-Dotel.traces.exporter=otlp \
-Dotel.exporter.otlp.endpoint=http://otel-collector:4318 \
-Dotel.traces.sampler=parentbased_traceidratio \
-Dotel.traces.sampler.arg=0.1 \
-jar app.jar-javaagent agent |
Micrometer Tracing | |
|---|---|---|
| Changes to the code | None | Two dependencies and configuration |
| Automatic coverage | Very broad: JDBC, JMS, Redis, over 100 libraries | Whatever Spring instruments |
| Business spans | Requires OTel annotations | @Observed and the Observation API |
| Unified metrics and traces | Separate | One instrumentation, two signals |
| Startup | Somewhat slower (it rewrites bytecode) | No impact |
| Debugging | More opaque | Explicit in the code |
| When | Legacy applications, or ones that cannot be touched | Your own Spring Boot 3 project |
CicloUrbana's choice is Micrometer Tracing, for consistency: we already use the Observation API for the 09-03 metrics, and that same instrumentation produces the spans. That said, there is a very practical combination: the agent for the low-level automatic coverage —especially JDBC, which is where the time is— and Micrometer for the business spans. Both write into the same OpenTelemetry context and the traces come out unified.
- Cost, overhead and what not to trace
The instrumentation is not free, even though it is cheap: creating a span, holding the context in a ThreadLocal, serialising it and sending it costs on the order of microseconds per span. With reasonable sampling, the CPU overhead usually sits between 1 % and 3 %, plus the network traffic to the Collector. That is acceptable; what is not is excess.
What not to trace. Trivial methods: a span per getter multiplies the volume a hundredfold and contributes nothing; the useful rule is one span per operation with meaning or with I/O waiting. Actuator endpoints: /actuator/** scraped every 15 seconds generates a constant stream of useless traces, and is excluded with an ObservationPredicate. Kubernetes health probes, for the same reason. And very hot loops: instrumenting inside a thousand-iteration loop produces a thousand spans in a trace nobody can then read.
@Bean
ObservationPredicate ignoreActuator() {
return (name, context) -> !(context instanceof ServerRequestObservationContext ctx)
|| !ctx.getCarrier().getRequestURI().startsWith("/actuator");
}And what not to put in a span: exactly the same as what you do not put in a log (09-05). A span's attributes are stored, indexed and queried; often in a third-party service. Passwords, tokens, cards, emails, phone numbers, ID numbers and Ribalta citizens' GPS coordinates stay out, and the Collector's attributes processor acts as the last net.
- Worked example: the rentals p99
08:14. Alertmanager warns: P99LatencyDegraded, the p99 of POST /api/v1/rentals above 1 s for 10 minutes.
Step 1 — the dashboard (09-04). The RED row confirms the rise: p99 at 2.2 s, error rate normal, traffic normal. The resources row is clean: no pending on the pool, no long GC pauses, CPU at 30 %. Provisional conclusion: it is not us that are slow, we are waiting for somebody.
Step 2 — from the metric to the trace. There are exemplars on the p99 panel. You click one of the high points and Grafana opens the trace in Tempo. That jump —impossible three lessons ago— costs one click.
Step 3 — the waterfall. The trace is exactly the one from section 12: 2,100 ms in total, of which 1,740 ms in the POST gateway /authorisations span. Everything else is in the tens of milliseconds. The diagnosis is done, and it has taken two minutes.
Step 4 — confirm that it is general. One trace could be an isolated case, so you go back to Prometheus:
histogram_quantile(0.95, sum by (le) (rate(http_client_requests_seconds_bucket{client_name="payment-gateway"}[5m])))
rate(resilience4j_retry_calls_total{kind="successful_with_retry"}[5m])The client's p95 towards the gateway has gone from 180 ms to 1.8 s, and the retries have multiplied. The 07-06 circuit breaker is not open yet, because the calls are not failing: they are slow. That nuance is important, and it is the reason 07-06 recommended alerting on the retry rate as well.
Step 5 — the logs (09-05). With the trace's traceId:
It returns the complete story of that request, including two retry WARNs with the gateway's message: 504 Gateway Timeout upstream. The diagnosis is closed: the gateway is degraded, not down.
Step 6 — decide. The service works, more slowly. The options: lower the RestClient timeout to fail sooner and activate the deferred-charge fallback; open the circuit breaker manually; or wait and notify the provider. The decision —a business one— is to lower the timeout to 800 ms, so that most charges are deferred and the p99 goes back to 300 ms while the provider sorts it out.
What makes this journey possible is the three signals connected: the metric detected, the trace located, the log explained. Each did what it knows how to do, and the traceId joined them. Without traces, step 3 would have been hours of investigation comparing timestamps across the logs of three instances.
Common Mistakes and Tips
Trying to use Spring Cloud Sleuth on Spring Boot 3. It is discontinued and does not work. Its replacement is Micrometer Tracing with a bridge.
Leaving sampling.probability: 1.0 in production. Storage cost, overhead and network, to keep millions of traces nobody will look at. Tail sampling in the Collector, or 10 % at the head.
Sampling at 10 % in the application and expecting to keep every error. They are incompatible: the head decision is taken before you know whether there will be an error. For that you have to send everything to the Collector and filter there.
Forgetting spring.application.name. Every trace appears as unknown_service and they cannot be separated by service.
Creating the RestClient by hand with RestClient.create(). It carries no instrumentation: it does not propagate traceparent and generates no span. The trace is cut exactly at the outer boundary. Always from the injected RestClient.Builder.
Putting personal data in a span's attributes. Spans are stored, indexed and often go out to a third party. The 09-05 policy applies in full.
Keeping two trace identifiers. The value the 03-06 filter generates itself and the standard one living side by side duplicate the work and confuse people. Unify on the standard and leave the filter with the sole responsibility of returning it to the client.
Tracing /actuator/** and the probes. They generate a constant stream of useless traces and pollute the statistics. Exclude them with an ObservationPredicate.
Tip: instrument with the Observation API and not with a hand-written Timer. It costs the same and produces both signals.
Tip: enable exemplars. The jump from a point on a graph to the specific trace is the best effort-to-benefit ratio in the whole module.
Tip: put the Collector in from the start. Switching backend, tail sampling or scrubbing attributes stops requiring an application deployment.
Exercises
Exercise 1: diagnosing from the waterfall
This trace corresponds to GET /api/v1/stations in production. Say what is wrong, how many distinct problems you can see, how you would fix them and what you would expect to happen to the total duration after each correction.
GET /api/v1/stations ─────────────────────────────────────────── 1,850 ms
└─ StationService.list ─────────────────────────────────────── 1,840 ms
├─ select stations ── 8 ms
├─ select bikes where station_id = ? ─ 6 ms
├─ select bikes where station_id = ? ─ 6 ms
├─ ... (18 more identical spans) ... 108 ms
├─ (gap with no spans) 900 ms
└─ GET map-service /geocode ────────────────── 700 msExercise 2: designing the sampling strategy
CicloUrbana serves 120 requests per second at peak and around 15 outside it. Each trace has an average of 12 spans of 400 bytes. The council sets three requirements: to be able to investigate any failed request from the last seven days, to be able to investigate any request that took more than a second, and not to spend more than 50 GB of storage. Work out the volume with no sampling, design the strategy that meets all three requirements and write the application and Collector configuration.
Exercise 3: unifying the trace identifier
CicloUrbana has had its own TraceFilter since 03-06, and now Micrometer Tracing adds traceId and spanId. There are historical logs in Loki carrying the old, filter-generated values, dashboards that filter by that field, the X-Trace-Id header documented in OpenAPI (03-07) and the ProblemDetail with the trace property. Design the complete migration —code, configuration, queries, documentation and compatibility— stating the order of the steps and what you would do with the old material.
Solutions
Solution 1.
Three distinct problems can be seen.
Problem 1 — a textbook N+1 (04-04, 09-01). Twenty identical, consecutive select bikes where station_id = ? spans: one query per station. It is the N+1 seen, with no need to count statements in the log. Fix: @EntityGraph/JOIN FETCH, or better the projection with a subquery from 09-01, which leaves a single query. Expected effect: the 120 ms of queries drop to around 10.
Problem 2 — a 900 ms gap with no spans. This is the most interesting finding, because uninstrumented time is information too. Almost half the request is in no child span, so it went on something that is not instrumented: heavy Java code —a sort or a mapping over thousands of objects—, waiting for a pool connection (hikaricp.connections.pending from 09-03 would confirm it), a lock, or a GC pause (jvm.gc.pause). How to investigate it: add an @Observed on the candidate methods to break the gap up, check the pool and GC metrics at that instant, and if nothing explains it, profile with async-profiler (09-01). It is a case where the trace does not give the answer but narrows the question down to a specific stretch.
Problem 3 — a 700 ms synchronous remote call in a listing endpoint. The map service is invoked for geocoding inside a heavily used read request. Possible fixes, in order of preference: cache the result (09-02), since a station's coordinates do not change; precompute it and store it in the table, which is better still because the data is stable; or, if it really has to be inline, take it off the synchronous path. Besides, that call must have a timeout and a circuit breaker (07-06), and it probably has neither.
Expected cumulative effect: removing the N+1 (−110 ms), resolving the gap (−900 ms) and caching the geocoding (−700 ms), the request goes from 1,850 ms to around 130 ms. The order of work is set by 09-01: whatever consumes the most time first, and here that means starting with the gap and the remote call, not with the N+1, even though the N+1 is the most eye-catching.
Solution 2.
Volume with no sampling. At peak, 120 req/s × 12 spans × 400 B = 576 KB/s. Assuming 4 peak hours and 20 hours at 15 req/s: 576 KB/s × 14,400 s ≈ 8.3 GB plus 72 KB/s × 72,000 s ≈ 5.2 GB, that is, some 13.5 GB a day and 94 GB over seven days. Almost twice the budget, and that is without compression.
Strategy: tail sampling. It is the only one that meets the first two requirements, because "every failed one" and "every slow one" are decisions that can only be taken once the trace has finished. Head sampling at 10 % would lose 90 % of the errors.
Application configuration —it sends 100 % to the Collector, which is the one that decides—:
management:
tracing.sampling.probability: 1.0 # the decision is taken in the Collector
otlp.tracing.endpoint: http://otel-collector:4318/v1/tracesCollector configuration:
processors:
tail_sampling:
decision_wait: 15s # longer than the slowest request you can expect
num_traces: 100000
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow
type: latency
latency: { threshold_ms: 1000 }
- name: normal-sample
type: probabilistic
probabilistic: { sampling_percentage: 3 }Resulting volume. Assuming 0.5 % errors and 1 % of requests above 1 s, you keep 0.5 % + 1 % + 3 % ≈ 4.5 % of the total: around 0.6 GB a day, 4.3 GB over seven days. Well below the limit, with room to raise the 3 % to 10 % if more reference is wanted. Retention in Tempo: --storage.trace.retention=168h.
Trade-offs that have to be declared. The Collector needs memory to hold the incomplete traces for decision_wait —hence memory_limiter and num_traces—; traces take an extra 15 seconds to appear in Tempo; and if the Collector goes down, everything is lost, so it should be deployed with more than one replica and, in that case, you have to guarantee that every span of a trace reaches the same Collector (load balancing by traceId), or tail sampling will see split traces.
Solution 3.
Principle: migrate to the standard and keep temporary compatibility, with no big bang. Six steps in this order:
1. Emit both, breaking nothing. The tracing dependencies and configuration are added. Micrometer puts traceId and spanId in the MDC; the TraceFilter goes on writing its own value under a temporary key. The log pattern is updated so all three come out. During this phase, every new log line is queryable by either field and nothing breaks.
2. Align the values. So that both identifiers coincide during the transition, the TraceFilter stops generating a UUID and copies the current span's traceId into its own key. With that, both fields carry the same value and the old queries go on working over new data.
3. Migrate queries and dashboards. The Loki panels and saved queries are updated to use the standard field. Since after step 2 both coincide, this can be done without haste, checking panel by panel.
4. Public API compatibility. The X-Trace-Id header is documented in OpenAPI and there may be clients reading it. Its name stays, but its value format changes, so the change is documented (03-07) with an effective date and announced to consumers. The ProblemDetail does likewise: the traceId property is added and trace is kept for the grace period. A public API is not changed in one go, even when the change looks cosmetic.
5. Retire the old. Once the period is over —whatever Loki's retention dictates, 30 days, plus the margin agreed with the clients—, the TraceFilter stops writing its own key into the MDC and is reduced to reading the traceId from the Tracer and returning it in X-Trace-Id. The historical logs with the old values stay readable until they expire on their own: there is no data to migrate.
6. Take advantage of what you gain. Once the migration is finished, there are two new capabilities that did not exist before: requests arriving with a traceparent from the mobile app or a proxy continue the trace instead of starting another, and the logs' identifier is the same as the traces' and the exemplars', so the three signals are joined by a single value.
What to do with the old material: nothing special. The historical logs are not rewritten —they expire in 30 days— and no data is migrated. The only element demanding care and a calendar is the public API, because it is the only part with external consumers we do not control.
Conclusion
This is where the module closes, and it is worth looking at the whole journey. We began with an application in production that deployed itself and about which nobody knew anything: not its real latency, not where its time went, not what was happening inside the JVM. We end with CicloUrbana instrumented end to end.
From this lesson you take the following. The problem neither metrics nor logs solve —attributing time inside an operation— and its solution: breaking each request down into a tree of timed spans. The concepts of trace, span, traceId, context, attributes and sampling, with the idea that sums them up: a span is a Timer with a family tree. Propagation between processes with W3C's traceparent —including the sampling flag that keeps traces complete— against the older B3. The real state of the ecosystem: Sleuth is discontinued and its place is taken by Micrometer Tracing with a bridge to OpenTelemetry, which is the option that ties you to no backend.
You have instrumented the Ribalta network with two dependencies and five properties, knowing what is instrumented on its own —incoming and outgoing HTTP, @Scheduled, @Async, messaging, JDBC with the right add-on— and what has to be declared. And you write the custom spans with the Observation API from 09-03, so that the same code produces both a metric and a trace, with lowCardinalityKeyValue and highCardinalityKeyValue deciding what goes to each signal. You know how to unify the 03-06 filter's own identifier with the standard traceId and leave the filter with the sole responsibility of returning it to the citizen; you know how to enable exemplars to jump from a point on a graph to the specific trace; and you know how to read a span waterfall looking for the long bar, the uninstrumented gaps, the repetition that gives away an N+1 and the spans in red. You have Tempo and the Collector in the observability docker-compose, the sampling strategy that keeps every error and every slow one with 3 % of the rest, and the reasons for putting an intermediary between the application and the backend. And you have the argument that was missing in 07-05: tracing is a prerequisite to splitting a monolith, not a later improvement.
The balance of the whole module is this. 09-01 taught the method —measure before you touch, p95 and p99 instead of the average, the baseline with k6, and the prioritised tour that always starts with the database— and demonstrated it by taking an endpoint from 2.4 s to 91 ms without adding a single machine. 09-02 added caching with its golden rule —fix the query first— and with its hard part, invalidating in AFTER_COMMIT. 09-03 turned the Actuator from 07-01 into real instrumentation with Micrometer, the cardinality rule and Ribalta's business metrics. 09-04 got them out of the process and into Prometheus and Grafana, with PromQL, dashboards and alerts that warn about symptoms and not causes. 09-05 turned the log into queryable, structured, correlated data, clean of personal information. And 09-06 has closed the circle by joining the three signals: the metric detects, the trace locates, the log explains, and the traceId joins them. The worked example in section 18 —from an alert at 08:14 to a business decision in under ten minutes— is the proof that the whole thing works.
CicloUrbana is built, tested, deployed and observed. We know how to make it, deliver it and watch it run. One last question remains, and it is of another kind: is it well made? Over nine modules we have taken dozens of decisions —where to put the logic, how to name things, when to use an annotation and when not, what to expose and what to hide— and patterns have emerged that repeat and traps we have fallen into more than once: the proxy one, three times; the cardinality one, twice; not measuring before optimising, in every single module. Module 10 gathers all of that: the best practices that have been emerging, the common mistakes and how to avoid them before making them, the clean code principles applied to a real Spring Boot project, a final tour of the whole of CicloUrbana that joins the pieces of all ten modules into a single view, and the resources for carrying on learning once this course ends. From building the Ribalta network we move on to understanding why we built it the way we did.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
