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

  1. The problem: where did the time go
  2. Concepts: trace, span and context
  3. A rental trace, broken down
  4. Context propagation between processes
  5. Sleuth is dead: Micrometer Tracing
  6. Instrumenting CicloUrbana
  7. What is instrumented without writing code
  8. Custom spans with the Observation API
  9. Correlating the three signals
  10. Exemplars: from the graph to the trace
  11. The backend: Grafana Tempo
  12. Reading a span waterfall
  13. Sampling: head, tail and errors
  14. The OpenTelemetry Collector
  15. Tracing and microservices
  16. The OpenTelemetry agent versus Micrometer
  17. Cost, overhead and what not to trace
  18. Worked example: the rentals p99
  19. Common Mistakes and Tips
  20. Exercises

  1. 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.

  1. 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.

  1. 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.

  1. 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)
             └─ version

The 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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");
}

  1. 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:

  1. Change the TraceFilter so it generates nothing of its own. Micrometer already places traceId and spanId in the MDC on every request and, unlike ours, it respects the incoming traceparent: if the mobile app or a proxy already started a trace, it is continued instead of a new one being invented.
  2. 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 the GlobalExceptionHandler's ProblemDetail (03-06), so the citizen who phones the council can still read it off their screen.
  3. 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

  1. 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.481

The 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.

  1. 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

  1. 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 ms

How it is read, in the order that works:

  1. The longest bar that is not the parent. It is the immediate suspect: here, the gateway with 83 % of the time.
  2. 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.
  3. Repetition. Fifty identical, consecutive select spans are an N+1 from 09-01 seen with your own eyes, and it is the fastest way there is of spotting one.
  4. Parallelism. Bars that overlap indicate concurrent work; strictly sequential bars that could overlap are an opportunity.
  5. Spans in error, marked in red, with their exception as an attribute.

  1. 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 ones

This 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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:

{app="ciclourbana", environment="prod"} | json | traceId = "a3f19c2e8b7d4f6a9c1e2d3b4a5f6e7d"

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 ms

Exercise 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/traces

Collector 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

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved