La lliçó anterior va tancar amb dos límits que cap de les dues senyals anteriors no pot superar. El primer: el rastreId del FiltreRastreig de 03-06 existeix només dins de CicloUrbana; quan la petició surt cap a la passarel·la de pagaments de 07-06, el proveïdor no el coneix, i si demà el monòlit es divideix (07-05), cada servei generarà el seu i la correlació es trencarà justament a la frontera on més falta fa.

El segon és més profund i no depèn de dividir res. Una petició POST /api/v1/lloguers triga 2,1 segons. Les mètriques de 09-04 ho diuen: el p99 ha pujat. Els logs de 09-05 ho confirmen: hi ha una línia en entrar i una altra en sortir, amb 2,1 segons entre totes dues. I aquí s'acaba la informació. On van anar aquests dos segons? A la validació, a les tres consultes, a la memòria cau, al cobrament, a l'enviament del correu? Ni un agregat numèric ni una successió de línies amb marques de temps ho responen amb precisió, perquè cap de les dues no coneix l'estructura de l'operació.

La traçabilitat distribuïda sí. Descompon cada petició en un arbre d'operacions cronometrades i imbricades, i permet mirar una petició concreta i assenyalar amb el dit el tros que es va menjar el 80 % del temps. Aquesta lliçó la munta sencera a CicloUrbana: els conceptes, la propagació del context entre processos, Micrometer Tracing —el substitut del descontinuat Spring Cloud Sleuth—, spans propis amb l'Observation API que ja coneixes de 09-03, la correlació de les tres senyals, un backend on llegir la cascada, i el mostreig, que és la decisió que fa tot això viable econòmicament.

Contingut

  1. El problema: on se'n va anar el temps
  2. Conceptes: traça, span i context
  3. Una traça de lloguer, descomposta
  4. Propagació del context entre processos
  5. Sleuth ha mort: Micrometer Tracing
  6. Instrumentar CicloUrbana
  7. Què s'instrumenta sense escriure codi
  8. Spans propis amb l'Observation API
  9. Correlacionar les tres senyals
  10. Exemplars: del gràfic a la traça
  11. El backend: Grafana Tempo
  12. Llegir una cascada de spans
  13. Mostreig: al cap, a la cua i errors
  14. L'OpenTelemetry Collector
  15. Traçabilitat i microserveis
  16. L'agent d'OpenTelemetry davant de Micrometer
  17. Cost, sobrecàrrega i què no traçar
  18. Cas pràctic: el p99 dels lloguers
  19. Errors Comuns i Consells
  20. Exercicis

  1. El problema: on se'n va anar el temps

El flux real d'un lloguer a Ribalta travessa cinc components:

sequenceDiagram
    participant M as App mobil
    participant A as CicloUrbana API
    participant C as Cache · 09-02
    participant B as PostgreSQL
    participant P as Passarela de pagaments · 07-06
    M->>A: POST /api/v1/lloguers
    A->>C: cercar tarifa
    C-->>A: fallada de cache
    A->>B: select tarifa
    A->>B: select usuari, bicicleta, estacio
    A->>B: insert lloguer
    A->>P: autoritzar cobrament
    P-->>A: autoritzat (1.740 ms)
    A->>B: update lloguer
    A-->>M: 201 Created (2.100 ms)

Amb mètriques saps que l'operació sencera va trigar 2,1 s. Amb logs saps que va començar i va acabar. Cap de les dues no et diu que 1.740 d'aquells 2.100 mil·lisegons se'ls va endur la passarel·la, ni que hi va haver una fallada de memòria cau que va afegir una consulta, ni quantes consultes hi va haver en realitat.

Podries instrumentar cada pas amb un Timer de 09-03, i molts equips ho fan. El resultat són desenes de mètriques soltes de les quals es perd la relació: no se sap quina invocació de la passarel·la pertany a quina petició, ni si les tres consultes van ser seqüencials o paral·leles, ni per què aquesta petició concreta —la del ciutadà que ha trucat a l'ajuntament— va trigar el que va trigar. La traça conserva aquesta relació: és l'estructura d'una operació, cronometrada.

  1. Conceptes: traça, span i context

Concepte Què és A CicloUrbana
Traça (trace) L'arbre complet d'una operació de principi a fi Un POST /api/v1/lloguers sencer
traceId Identificador únic de la traça, 128 bits Compartit per tots els seus spans i per tots els serveis
Span Una unitat de treball amb inici, fi i nom «insert lloguer», «autoritzar cobrament»
spanId Identificador del span, 64 bits Únic dins de la traça
Span pare El span que va originar aquest El span HTTP és pare del span de la consulta
Context de traça traceId + spanId + banderes, que viatgen El que es propaga a les capçaleres
Atributs Parells clau-valor del span estacio=2, tarifa=ESTUDIANT
Esdeveniments Marques puntuals dins d'un span «tallacircuits obert»
Estat Si el span va acabar bé o amb error L'excepció registrada
Mostreig Decidir quines traces es desen 10 % en producció

Dues idees que aclareixen el model. Un span és un Timer amb genealogia: mesura el mateix, però a més sap qui el va cridar i a qui va cridar, i aquesta relació és tota la diferència. I el traceId és el mateix al llarg de tota l'operació, fins i tot creuant processos: és el que permet veure en una sola pantalla la feina feta per CicloUrbana i la feta per un altre servei.

  1. Una traça de lloguer, descomposta

gantt
    title Traca de POST /api/v1/lloguers (traceId a3f19c2e...) - 2.100 ms
    dateFormat X
    axisFormat %L
    section HTTP
    POST /api/v1/lloguers            :0, 2100
    section Servei
    LloguerService.iniciar           :12, 2080
    section Cache
    cache estacions (fallada)        :20, 22
    section Base de dades
    select tarifa                    :24, 31
    select usuari i bicicleta        :33, 48
    insert lloguer                   :50, 62
    update lloguer                   :1880, 1894
    section Extern
    POST passarela /autoritzacions   :140, 1880

Llegida de dalt a baix, la traça explica la història completa: el span arrel dura 2.100 ms; a dins, el servei dura 2.080; dins seu hi ha una fallada de memòria cau, tres consultes ràpides que sumen 45 ms i un span de 1.740 ms cap a la passarel·la. El diagnòstic és immediat i no requereix interpretació: el 83 % del temps és en un sistema que no controlem.

Compara-ho amb el que teníem: una mètrica que deia «2,1 s» i dues línies de log. La diferència no és de quantitat d'informació, és de forma: la traça té estructura, i l'estructura és el que permet atribuir el temps.

  1. Propagació del context entre processos

Perquè el span creat per la passarel·la pertanyi a la mateixa traça que el nostre, el context ha de viatjar a la petició HTTP. Hi ha dos formats i un ha guanyat:

W3C Trace Context (traceparent) B3 (Zipkin)
Estàndard Recomanació del W3C De facto, de Zipkin
Capçaleres traceparent, tracestate X-B3-TraceId, X-B3-SpanId, X-B3-Sampled, X-B3-ParentSpanId
Format Una capçalera compacta Diverses capçaleres, o b3 condensada
Interoperabilitat Universal: OTel, proveïdors comercials, malles de serveis Bona a l'ecosistema Zipkin/Brave
Estat El que cal fer servir avui Compatibilitat amb sistemes existents

Una capçalera traceparent real:

traceparent: 00-a3f19c2e8b7d4f6a9c1e2d3b4a5f6e7d-b7d4f6a9c1e2d3b4-01
             │  │                                │                │
             │  │                                │                └─ banderes: 01 = mostrejada
             │  │                                └─ span pare (16 hex)
             │  └─ traceId (32 hex)
             └─ versio

Els quatre camps separats per guions són tot el mecanisme. La bandera final és més important del que sembla: transporta la decisió de mostreig, de manera que si nosaltres decidim desar aquesta traça, el servei següent desa la seva part també; sense aquest acord, les traces sortirien incompletes. tracestate és la capçalera complementària on cada proveïdor afegeix informació pròpia sense trencar l'estàndard.

La regla pràctica: fes servir W3C per defecte, i activa a més B3 només si has de parlar amb un sistema antic que només entén Zipkin. Micrometer permet emetre i acceptar tots dos alhora.

  1. Sleuth ha mort: Micrometer Tracing

Qui hagi vist projectes Spring Boot 2 coneixerà Spring Cloud Sleuth, que feia exactament això. Convé dir-ho sense embuts: Sleuth està descontinuat i no suporta Spring Boot 3. La seva funcionalitat es va traslladar al projecte Micrometer, com a part del model d'observabilitat unificat que ja fem servir a 09-03.

Opció Què és Estat Quan triar-la
Spring Cloud Sleuth La solució de Boot 2 Descontinuat Mai en un projecte nou
Micrometer Tracing + pont OTel Façana de Micrometer sobre OpenTelemetry Recomanat CicloUrbana: l'estàndard cap al qual convergeix tot
Micrometer Tracing + pont Brave Façana sobre Brave (Zipkin) Suportat Ja existeix un Zipkin a l'organització
Agent d'OpenTelemetry (-javaagent) Instrumentació sense tocar el codi Vàlid Aplicacions que no es poden modificar (apartat 16)

L'arquitectura reprodueix la idea d'SLF4J i de Micrometer Metrics: Micrometer Tracing és la façana, i al darrere hi va un pont cap a una implementació real. El teu codi parla amb Observation i Tracer; el pont decideix si això acaba a OpenTelemetry o a Brave. Canviar d'un a l'altre és canviar dues dependències.

L'elecció de CicloUrbana és el pont a OpenTelemetry, per la raó que vam anticipar al final de 09-04: OTLP és el protocol que accepten Tempo, Jaeger, Grafana Cloud, Datadog, New Relic i Elastic APM, així que la instrumentació no queda lligada a cap destinació.

  1. Instrumentar CicloUrbana

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>

La primera aporta la façana i el pont; la segona, l'exportador que envia els spans per OTLP. Les versions les governa l'spring-boot-starter-parent de 01-04, així que no es declaren.

spring:
  application.name: ciclourbana        # es converteix en service.name a les traces

management:
  tracing:
    enabled: true
    sampling.probability: 0.1          # 10 % en produccio (apartat 13)
    propagation:
      type: w3c                        # w3c, b3 o totes dues: [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:-}]"

Quatre decisions que convé entendre. spring.application.name és obligatori de facto: sense ell, totes les traces apareixen sota un servei anomenat unknown_service i no es poden separar. sampling.probability: 0.1 desa una traça de cada deu, i l'apartat 13 explica per què 1.0 no val en producció. L'endpoint apunta al Collector de l'apartat 14 i no directament al backend, expressament. I el patró de log és la peça que uneix aquesta lliçó amb l'anterior: traceId i spanId apareixen al MDC automàticament i s'escriuen a cada línia.

A dev, un perfil amb sampling.probability: 1.0 i l'endpoint apuntant a un Tempo local permet veure totes les traces mentre es desenvolupa, que és quan més ensenyen.

  1. Què s'instrumenta sense escriure codi

Aquesta és la part que sorprèn: amb les dues dependències i la configuració anterior, la major part de la feina ja està feta. Micrometer Tracing es recolza en les observacions que Spring Boot produeix de sèrie:

Component Span que genera Atributs automàtics
Peticions HTTP entrants Span arrel per petició http.method, http.route (plantilla), http.status_code
RestClient / WebClient sortints Span fill per crida, amb propagació de capçaleres URL, mètode, estat
@Scheduled (07-03) Span arrel per execució Nom de la tasca
@Async i ThreadPoolTaskExecutor Continuació de la traça a l'altre fil —
Spring Data / JDBC Span per consulta, amb l'agent o datasource-micrometer Sentència SQL (sense paràmetres)
Spring Security Span de la cadena de filtres —
Missatgeria (Kafka, RabbitMQ) (07-05) Span de producció i de consum, amb el context a les capçaleres del missatge Tema, partició
Resilience4j (07-06) Esdeveniments al span de la crida Estat del tallacircuits

Dos matisos honestos. Les consultes SQL no s'instrumenten soles amb la configuració mínima: calen datasource-micrometer-spring-boot o l'agent d'OTel de l'apartat 16, i val la pena perquè, com vam veure a 09-01, la base de dades és on sol ser el temps. I la propagació a RestClient funciona sola només si el client es construeix des del RestClient.Builder que injecta Spring —el bean de l'apartat 2 de 07-06 ho fa—; un RestClient creat amb RestClient.create() a mà no porta instrumentació i trenca la traça a la frontera exterior, que és justament on més fa mal.

  1. Spans propis amb l'Observation API

L'automàtic cobreix la infraestructura. Els spans de negoci —«càlcul de tarifa», «validació de disponibilitat»— cal declarar-los, i aquí es cobra la inversió de 09-03: el mateix codi que ja produïa mètriques comença a produir spans, sense canviar una línia.

La versió declarativa, sobre el càlcul de tarifa:

@Observed(name = "ciclourbana.tarifa.calcul", contextualName = "calcul-tarifa")
public BigDecimal calcular(TipusTarifa tipus, Duration durada) { ... }

I la programàtica, per instrumentar un bloc concret de LloguerService amb atributs de negoci:

@Service
public class LloguerService {

    private final ObservationRegistry observacions;

    @Transactional
    public LloguerResponse iniciar(IniciarLloguerRequest peticio) {
        return Observation.createNotStarted("ciclourbana.lloguer.iniciar", observacions)
                .contextualName("iniciar-lloguer")
                .lowCardinalityKeyValue("tarifa", peticio.tipusTarifa().name())
                .lowCardinalityKeyValue("estacio", String.valueOf(peticio.estacioOrigenId()))
                .highCardinalityKeyValue("idUsuari", String.valueOf(usuariActual()))
                .observe(() -> {
                    Bicicleta bicicleta = seleccionarDisponible(peticio.estacioOrigenId());
                    Lloguer lloguer = registrar(bicicleta, peticio);
                    return mapejador.aResposta(lloguer);
                });
    }
}

Què produeix això exactament: un Timer anomenat ciclourbana.lloguer.iniciar amb etiquetes tarifa i estacio —les de baixa cardinalitat, les mateixes regles de 09-03— i un span anomenat iniciar-lloguer amb aquests atributs més idUsuari, que va només al span. Una instrumentació, dues senyals, i la regla de cardinalitat convertida en API.

L'advertiment important, que enllaça amb 09-05: els atributs d'un span s'emmagatzemen i es consulten igual que un log, així que la política de l'apartat 10 de la lliçó anterior s'aplica íntegra. idUsuari com a identificador intern és acceptable; el correu, el telèfon, el DNI o les coordenades GPS del ciutadà no ho són. Un span no és un lloc privat: viatja a un backend, de vegades d'un tercer, i el consulta qui hi tingui accés.

Quan cal control fi —afegir un esdeveniment a mitges, marcar el span com a fallit sense llançar excepció— es fa servir el Tracer directament:

Span span = tracer.currentSpan();
if (span != null) {
    span.event("tallacircuits-obert");
    span.tag("respatller", "cobrament-diferit");
}

  1. Correlacionar les tres senyals

Ara hi ha dos identificadors en joc: el rastreId que el FiltreRastreig de 03-06 posa al MDC i el traceId que Micrometer Tracing també posa al MDC. Tenir-ne dos és pitjor que tenir-ne un, així que cal unificar-los, i la resposta correcta és quedar-se amb l'estàndard.

El pla d'unificació, en tres passos:

  1. Canviar el FiltreRastreig perquè no generi res pel seu compte. Micrometer ja col·loca traceId i spanId al MDC de cada petició, i a més —a diferència del nostre— respecta el traceparent entrant: si l'app mòbil o un proxy ja va començar una traça, es continua en lloc d'inventar-ne una altra.
  2. Mantenir la responsabilitat que sí que era seva: retornar l'identificador al client. El filtre passa a llegir tracer.currentSpan().context().traceId() i a escriure'l a la capçalera de resposta i al ProblemDetail del GestorGlobalExcepcions (03-06), perquè el ciutadà que truca a l'ajuntament continuï podent llegir-lo a la seva pantalla.
  3. Actualitzar el patró de log i les consultes de Loki, substituint %X{rastreId} per %X{traceId} i | json | rastreId = "..." per | json | traceId = "...".
@Component
public class FiltreRastreig extends OncePerRequestFilter {

    private final Tracer tracer;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain cadena) throws ServletException, IOException {
        Span span = tracer.currentSpan();          // ja creat per la instrumentacio HTTP
        if (span != null) {
            res.setHeader("X-Trace-Id", span.context().traceId());
        }
        cadena.doFilter(req, res);                 // sense MDC.put ni MDC.remove: els posa Micrometer
    }
}

El resultat és la correlació completa de les tres senyals, que és l'objectiu de tot el mòdul:

Des de Cap a Com
Alerta (09-04) Quadre de comandament El panell de la mètrica que va disparar
Mètrica Traça concreta Exemplars (apartat 10)
Traça Logs d'aquella petició {app="ciclourbana"} | json | traceId = "..."
Log Traça El traceId de la línia, a Tempo
Ciutadà que truca Tot l'anterior La capçalera X-Trace-Id de la seva resposta

  1. Exemplars: del gràfic a la traça

Un exemplar és un enllaç que Prometheus desa al costat d'una mostra d'histograma: «aquesta observació de 2,1 segons pertany a la traça a3f19c2e...». Converteix un punt d'un gràfic en un cas concret que es pot obrir.

Al format de /actuator/prometheus apareixen després d'un coixinet:

http_server_requests_seconds_bucket{uri="/api/v1/lloguers",le="2.5"} 12801 # {trace_id="a3f19c2e8b7d4f6a9c1e2d3b4a5f6e7d"} 2.1 1756628062.481

Requisits, que són tres i convé tenir-los junts: histogrames activats en aquesta mètrica (percentiles-histogram, 09-03), traçabilitat activa perquè existeixi un traceId, i Prometheus arrencat amb --enable-feature=exemplar-storage, més exemplarTraceIdDestination: trace_id a l'origen de dades de Grafana.

El resultat a la pràctica: al panell de latència p99 de 09-04 apareixen punts sobre la corba; en prémer-ne un, Grafana obre la traça exacta d'una petició que va trigar això. És el salt que faltava entre «el p99 ha pujat» i «mira aquesta petició». I té una virtut que compensa el mostreig: els exemplars tendeixen a apuntar a les observacions de les cubetes altes, és a dir, a les peticions lentes, que són justament les que interessen.

  1. El backend: Grafana Tempo

Els spans cal enviar-los a algun lloc que els desi i els sàpiga mostrar:

Backend Model Fort en Quan
Grafana Tempo Emmagatzematge d'objectes, indexa només el traceId Molt barat; integrat amb Grafana, Loki i Prometheus CicloUrbana
Jaeger Cassandra, Elasticsearch o memòria Interfície d'anàlisi molt bona, comparació de traces Sense Grafana
Zipkin Senzill, veterà Lleuger i fàcil d'arrencar Sistemes existents amb B3
Comercials Gestionat Correlació automàtica i detecció d'anomalies Pressupost disponible

S'afegeix a la pila d'observabilitat de 09-04:

# docker-compose.observabilitat.yml
  tempo:
    image: grafana/tempo:2.5.0
    command: ["-config.file=/etc/tempo/tempo.yml"]
    volumes:
      - ./observabilitat/tempo.yml:/etc/tempo/tempo.yml:ro
      - tempo-dades:/var/tempo
    ports: ["3200:3200"]           # consultes
    networks: [xarxa-ciclourbana]

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.104.0
    command: ["--config=/etc/otel/config.yml"]
    volumes: ["./observabilitat/otel-collector.yml:/etc/otel/config.yml:ro"]
    ports: ["4317:4317", "4318:4318"]     # OTLP gRPC i HTTP
    depends_on: [tempo]
    networks: [xarxa-ciclourbana]

I l'origen de dades de Grafana, amb els enllaços que fan possible saltar entre senyals:

  - name: Tempo
    type: tempo
    url: http://tempo:3200
    jsonData:
      tracesToLogsV2:
        datasourceUid: loki
        filterByTraceID: true          # d'un span a les seves linies de log
      lokiSearch:
        datasourceUid: loki
      tracesToMetrics:
        datasourceUid: prometheus

  1. Llegir una cascada de spans

A la interfície de Grafana, una traça es mostra com una cascada: cada span una barra horitzontal, la longitud és la seva durada i el sagnat, la seva profunditat a l'arbre.

POST /api/v1/lloguers ───────────────────────────────────────────── 2.100 ms
 └─ LloguerService.iniciar ───────────────────────────────────────  2.080 ms
     ├─ cache estacions (miss) ─                                        2 ms
     ├─ select tarifa ──                                                7 ms
     ├─ select usuari, bicicleta ────                                  15 ms
     ├─ insert lloguer ───                                             12 ms
     ├─ POST passarela /autoritzacions ───────────────────────────  1.740 ms  ← 83 %
     └─ update lloguer ───                                             14 ms

Com es llegeix, en l'ordre que funciona:

  1. La barra més llarga que no sigui el pare. És el sospitós immediat: aquí, la passarel·la amb el 83 % del temps.
  2. Els buits. Si la suma dels fills és molt menor que el pare, hi ha temps no instrumentat: esperant un fil, un bloqueig, una pausa de GC o simplement codi sense span. Un buit gran és tan informatiu com una barra llarga.
  3. La repetició. Cinquanta spans select idèntics i consecutius són un N+1 de 09-01 vist amb els ulls, i és la manera més ràpida que existeix de detectar-lo.
  4. El paral·lelisme. Barres que se solapen indiquen feina concurrent; barres estrictament seqüencials que podrien solapar-se són una oportunitat.
  5. Els spans en error, marcats en vermell, amb la seva excepció com a atribut.

  1. Mostreig: al cap, a la cua i errors

Desar totes les traces és inviable: cadascuna són desenes de spans d'uns centenars de bytes, i amb 100 peticions per segon surten milions de spans al dia. El mostreig decideix quines es desen.

Estratègia Quan decideix Avantatge Inconvenient
Al cap, probabilística En començar la traça Simple, barata, coherent entre serveis Es perden traces interessants per atzar
Al cap, per taxa En començar, amb un límit per segon Cost acotat i previsible Igual de cega davant del contingut
A la cua En acabar, veient la traça completa Desa tots els errors i les lentes Requereix Collector amb memòria i latència
Sempre — Tot disponible Cost prohibitiu llevat de dev

Per què probability: 1.0 no val en producció. Tres raons acumulatives: el cost d'emmagatzematge creix linealment amb el trànsit; la sobrecàrrega a l'aplicació —crear spans, serialitzar-los, enviar-los— deixa de ser menyspreable; i la xarxa cap al backend transporta un cabal constant. Amb un 10 % es conserva la capacitat de diagnòstic —els problemes sistemàtics apareixen igual en una mostra— a la desena part del cost.

Però el 10 % cec té un defecte greu: la petició que va fallar i per la qual truca el ciutadà té un 90 % de probabilitats de no haver-se desat. La solució és el mostreig de cua al Collector: es retenen els spans en memòria uns segons i, quan la traça acaba, es decideix amb la traça sencera a la vista.

# fragment d'otel-collector.yml
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: tots-els-errors
        type: status_code
        status_code: { status_codes: [ERROR] }        # 100 % dels errors
      - name: les-lentes
        type: latency
        latency: { threshold_ms: 1000 }               # 100 % de les que passen d'1 s
      - name: mostra-de-la-resta
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }     # 5 % del que es normal

Aquesta política és la que CicloUrbana fa servir en producció, i respon exactament al que es necessita: tots els errors, totes les lentes i una mostra del que és normal per tenir referència. Requereix que l'aplicació enviï el 100 % al Collector (sampling.probability: 1.0 a l'aplicació i el filtratge al Collector), cosa que trasllada el cost de la decisió al lloc on es pot prendre bé.

  1. L'OpenTelemetry Collector

Enviar els spans directament de l'aplicació al backend funciona i és el que fa la majoria en començar. Posar un Collector al mig aporta cinc coses que s'agraeixen aviat:

  • Desacoblament: canviar de Tempo a Jaeger o a un servei comercial es fa al Collector, sense desplegar l'aplicació.
  • Mostreig de cua, que només es pot fer on convergeix la traça completa (apartat 13).
  • Transformació: eliminar atributs sensibles abans que surtin —una segona xarxa de seguretat per a la política de 09-05—, reanomenar, afegir metadades de l'entorn.
  • Amortiment: si el backend cau o s'alenteix, el Collector encua i reintenta en lloc que ho pateixi l'aplicació.
  • Una sola sortida: mètriques, logs i traces pel mateix canal OTLP.
# 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: usuari.correu           # xarxa de seguretat: mai hauria d'arribar aqui
        action: delete
      - key: entorn
        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]

L'ordre dels processadors importa: memory_limiter primer per protegir-se, el mostreig abans que la transformació —per no gastar CPU transformant el que es descartarà— i batch al final, sempre, perquè agrupar abans d'exportar redueix molt el nombre de connexions.

  1. Traçabilitat i microserveis

A 07-05 vam deixar un advertiment sobre dividir el monòlit, i aquesta lliçó aporta l'argument que faltava: la traçabilitat distribuïda és requisit previ, no una millora posterior.

El motiu és que dividir un sistema destrueix la capacitat de diagnòstic que es tenia. En un monòlit, una traça de pila recorre tota l'operació i un depurador la segueix sencera. Tan bon punt hi ha tres serveis, aquella petició es converteix en tres processos, tres logs, tres desplegaments i cap visió conjunta: quan el ciutadà informa que alguna cosa va lenta, ningú no sap quin dels tres és el lent, i comença el joc d'acusacions creuades entre equips. La traçabilitat retorna la visió única, i per això l'ordre correcte és instrumentar primer i dividir després.

El que cal garantir a la frontera és simplement que el context viatgi. Amb RestClient construït des del Builder de Spring, passa sol: la capçalera traceparent surt a cada petició i el servei receptor —si està instrumentat— continua la traça en lloc de començar-ne una altra. Amb missatgeria (07-05), el context viatja a les capçaleres del missatge, i hi ha un matís conceptual: com que el consumidor processa després, el seu span no és exactament un fill síncron sinó un span enllaçat, i a la interfície apareix separat en el temps però unit pel traceId. Amb la passarel·la de pagaments de 07-06, que és un tercer, la nostra traça acaba al span de la crida sortint: no en veiem l'interior, però sabem exactament quant va trigar, que és justament el que necessitàvem a l'apartat 3.

  1. L'agent d'OpenTelemetry davant de Micrometer

Hi ha una alternativa que no toca el codi: adjuntar l'agent d'OpenTelemetry a la 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
Agent -javaagent Micrometer Tracing
Canvis al codi Cap Dues dependències i configuració
Cobertura automàtica Molt àmplia: JDBC, JMS, Redis, més de 100 biblioteques La que Spring instrumenta
Spans de negoci Requereix anotacions d'OTel @Observed i l'Observation API
Mètriques i traces unificades Separades Una instrumentació, dues senyals
Arrencada Una mica més lenta (reescriu bytecode) Sense impacte
Depuració Més opac Explícit al codi
Quan Aplicacions heretades o que no es poden tocar Projecte Spring Boot 3 propi

L'elecció de CicloUrbana és Micrometer Tracing, per coherència: ja fem servir l'Observation API per a les mètriques de 09-03, i aquesta mateixa instrumentació produeix els spans. Dit això, hi ha una combinació molt pràctica: agent per a la cobertura automàtica de baix nivell —sobretot JDBC, que és on és el temps— i Micrometer per als spans de negoci. Tots dos escriuen al mateix context d'OpenTelemetry i les traces surten unificades.

  1. Cost, sobrecàrrega i què no traçar

La instrumentació no és gratis, encara que sigui barata: crear un span, mantenir el context en un ThreadLocal, serialitzar-lo i enviar-lo té un cost de l'ordre de microsegons per span. Amb mostreig raonable, la sobrecàrrega en CPU se situa habitualment entre l'1 % i el 3 %, més el trànsit de xarxa cap al Collector. És assumible; el que no ho és són els excessos.

Què no traçar. Mètodes trivials: un span per cada getter multiplica el volum per cent i no aporta res; la regla útil és un span per operació amb significat o amb espera d'E/S. Els endpoints d'Actuator: /actuator/** recollit cada 15 segons genera un flux constant de traces inútils, i s'exclou amb un ObservationPredicate. Les sondes de salut de Kubernetes, pel mateix. I els bucles molt calents: instrumentar dins d'un bucle de mil iteracions produeix mil spans en una traça que després ningú no pot llegir.

@Bean
ObservationPredicate ignorarActuator() {
    return (nom, context) -> !(context instanceof ServerRequestObservationContext ctx)
            || !ctx.getCarrier().getRequestURI().startsWith("/actuator");
}

I què no posar en un span: exactament el mateix que no es posa en un log (09-05). Els atributs d'un span s'emmagatzemen, s'indexen i es consulten; sovint en un servei de tercers. Contrasenyes, tokens, targetes, correus, telèfons, DNI i coordenades GPS dels ciutadans de Ribalta queden fora, i el processador attributes del Collector actua com a última xarxa.

  1. Cas pràctic: el p99 dels lloguers

08:14. Alertmanager avisa: LatenciaP99Degradada, p99 de POST /api/v1/lloguers per sobre d'1 s durant 10 minuts.

Pas 1 — el quadre de comandament (09-04). La fila RED confirma la pujada: p99 a 2,2 s, taxa d'error normal, trànsit normal. La fila de recursos està neta: pool sense pending, GC sense pauses llargues, CPU al 30 %. Conclusió provisional: no som nosaltres els que estem lents, estem esperant algú.

Pas 2 — de la mètrica a la traça. Al panell de p99 hi ha exemplars. Es prem un dels punts alts i Grafana obre la traça a Tempo. Aquest salt —que era impossible fa tres lliçons— costa un clic.

Pas 3 — la cascada. La traça és exactament la de l'apartat 12: 2.100 ms totals, dels quals 1.740 ms al span POST passarela /autoritzacions. Tota la resta està en desenes de mil·lisegons. El diagnòstic ja està fet, i ha portat dos minuts.

Pas 4 — confirmar que és general. Una traça podria ser un cas aïllat, així que es torna a Prometheus:

histogram_quantile(0.95, sum by (le) (rate(http_client_requests_seconds_bucket{client_name="passarela"}[5m])))
rate(resilience4j_retry_calls_total{kind="successful_with_retry"}[5m])

El p95 del client cap a la passarel·la ha passat de 180 ms a 1,8 s, i els reintents s'han multiplicat. El tallacircuits de 07-06 encara no està obert, perquè les crides no fallen: són lentes. Aquest matís és important, i és el motiu que a 07-06 es recomanés alertar també sobre la taxa de reintents.

Pas 5 — els logs (09-05). Amb el traceId de la traça:

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

Retorna la història completa d'aquella petició, inclosos dos WARN de reintent amb el missatge de la passarel·la: 504 Gateway Timeout upstream. El diagnòstic queda tancat: la passarel·la està degradada, no caiguda.

Pas 6 — decidir. El servei funciona, més lent. Les opcions: baixar el temps d'espera del RestClient per fallar abans i activar el respatller de cobrament diferit; obrir el tallacircuits manualment; o esperar i avisar el proveïdor. La decisió —de negoci— és baixar l'espera a 800 ms, amb la qual cosa la majoria dels cobraments passen a diferir-se i el p99 torna a 300 ms mentre el proveïdor ho resol.

El que fa possible aquest recorregut són les tres senyals connectades: la mètrica va detectar, la traça va localitzar, el log va explicar. Cadascuna va fer el que sap fer, i el traceId les va unir. Sense traces, el pas 3 hauria estat una investigació d'hores comparant marques de temps entre logs de tres instàncies.

Errors Comuns i Consells

Intentar fer servir Spring Cloud Sleuth a Spring Boot 3. Està descontinuat i no funciona. El seu substitut és Micrometer Tracing amb un pont.

Deixar sampling.probability: 1.0 en producció. Cost d'emmagatzematge, sobrecàrrega i xarxa, per desar milions de traces que ningú no mirarà. Mostreig de cua al Collector, o un 10 % al cap.

Mostrejar al 10 % a l'aplicació i pretendre desar tots els errors. Són incompatibles: la decisió al cap es pren abans de saber si hi haurà error. Per a això cal enviar-ho tot al Collector i filtrar-hi.

Oblidar spring.application.name. Totes les traces apareixen com a unknown_service i no es poden separar per servei.

Crear el RestClient a mà amb RestClient.create(). No porta instrumentació: no propaga traceparent i no genera span. La traça es talla justament a la frontera exterior. Sempre des del RestClient.Builder injectat.

Posar dades personals als atributs d'un span. Els spans s'emmagatzemen, s'indexen i sovint surten a un tercer. S'aplica íntegra la política de 09-05.

Mantenir dos identificadors de traça. El rastreId propi de 03-06 i el traceId estàndard convivint dupliquen la feina i confonen. Unifica en l'estàndard i deixa al filtre només la responsabilitat de retornar-lo al client.

Traçar /actuator/** i les sondes. Generen un flux constant de traces inútils i embruten les estadístiques. S'exclouen amb un ObservationPredicate.

Consell: instrumenta amb l'Observation API i no amb Timer a mà. Costa el mateix i produeix les dues senyals.

Consell: activa els exemplars. El salt d'un punt del gràfic a la traça concreta és la millor relació entre esforç i benefici de tot el mòdul.

Consell: posa el Collector des del principi. Canviar de backend, mostrejar per cua o netejar atributs deixa de requerir un desplegament de l'aplicació.

Exercicis

Exercici 1: diagnosticar des de la cascada

Aquesta traça correspon a GET /api/v1/estacions en producció. Digues què està malament, quants problemes diferents hi veus, com ho arreglaries i què esperaries que passés amb la durada total després de cada correcció.

GET /api/v1/estacions ────────────────────────────────────────── 1.850 ms
 └─ EstacioService.llistar ─────────────────────────────────── 1.840 ms
     ├─ select estacions ──                                         8 ms
     ├─ select bicicletes where estacio_id = ? ─                    6 ms
     ├─ select bicicletes where estacio_id = ? ─                    6 ms
     ├─ ... (18 spans identics mes) ...                           108 ms
     ├─ (buit sense spans)                                        900 ms
     └─ GET servei-mapes /geocodificar ──────────────             700 ms

Exercici 2: dissenyar l'estratègia de mostreig

CicloUrbana atén 120 peticions per segon en hora punta i unes 15 fora d'ella. Cada traça té una mitjana de 12 spans de 400 bytes. L'ajuntament fixa tres requisits: poder investigar qualsevol petició fallida dels últims set dies, poder investigar qualsevol petició que hagi trigat més d'un segon, i no gastar més de 50 GB d'emmagatzematge. Calcula el volum sense mostreig, dissenya l'estratègia que compleix els tres requisits i escriu la configuració de l'aplicació i del Collector.

Exercici 3: unificar el rastreId

CicloUrbana fa des de 03-06 amb el seu FiltreRastreig propi, i ara Micrometer Tracing hi afegeix traceId i spanId. Hi ha logs històrics amb rastreId a Loki, quadres de comandament que filtren per aquest camp, la capçalera X-Rastre-Id documentada a OpenAPI (03-07) i el ProblemDetail amb la propietat rastre. Dissenya la migració completa —codi, configuració, consultes, documentació i compatibilitat— indicant l'ordre dels passos i què faries amb el que és antic.

Solucions

Solució 1.

Es veuen tres problemes diferents.

Problema 1 — un N+1 de manual (04-04, 09-01). Vint spans select bicicletes where estacio_id = ? idèntics i consecutius: una consulta per estació. És l'N+1 vist, sense necessitat de comptar sentències al log. Correcció: @EntityGraph/JOIN FETCH, o millor la projecció amb subconsulta de 09-01, que deixa una sola consulta. Efecte esperat: els 120 ms de consultes baixen a uns 10.

Problema 2 — un buit de 900 ms sense spans. És la troballa més interessant, perquè el temps no instrumentat també és informació. Gairebé la meitat de la petició no és en cap span fill, així que se'n va anar en alguna cosa que no està instrumentada: codi Java pesat —una ordenació o un mapatge sobre milers d'objectes—, espera d'una connexió del pool (hikaricp.connections.pending de 09-03 ho confirmaria), un bloqueig, o una pausa de GC (jvm.gc.pause). Com s'investiga: s'afegeix un @Observed als mètodes candidats per partir el buit, es comproven les mètriques de pool i GC en aquell instant, i si res no ho explica, es perfila amb async-profiler (09-01). És un cas on la traça no dóna la resposta però acota la pregunta a un tram concret.

Problema 3 — una crida remota síncrona de 700 ms en un endpoint de llistat. El servei de mapes s'invoca per geocodificar dins d'una petició de lectura molt usada. Correccions possibles, en ordre de preferència: posar a la memòria cau el resultat (09-02), ja que les coordenades d'una estació no canvien; precalcular-lo i desar-lo a la taula, que encara és millor perquè són dades estables; o, si de debò ha de ser en línia, treure'l del camí síncron. A més, aquesta crida ha de tenir temps d'espera i tallacircuits (07-06), i probablement no els té.

Efecte acumulat esperat: eliminant l'N+1 (−110 ms), resolent el buit (−900 ms) i posant a la memòria cau la geocodificació (−700 ms), la petició passa de 1.850 ms a uns 130 ms. L'ordre de treball el marca 09-01: primer el que més temps consumeix, i aquí això significa començar pel buit i la crida remota, no per l'N+1, encara que l'N+1 sigui el més cridaner.

Solució 2.

Volum sense mostreig. En hora punta, 120 pet/s × 12 spans × 400 B = 576 KB/s. Suposant 4 hores punta i 20 hores de 15 pet/s: 576 KB/s × 14.400 s ≈ 8,3 GB més 72 KB/s × 72.000 s ≈ 5,2 GB, és a dir, uns 13,5 GB al dia i 94 GB en set dies. Gairebé el doble del pressupost, i això sense compressió.

Estratègia: mostreig de cua. És l'única que compleix els dos primers requisits, perquè «totes les fallides» i «totes les lentes» són decisions que només es poden prendre quan la traça ha acabat. Un mostreig al cap del 10 % perdria el 90 % dels errors.

Configuració de l'aplicació —envia el 100 % al Collector, que és qui decideix—:

management:
  tracing.sampling.probability: 1.0        # la decisio es pren al Collector
  otlp.tracing.endpoint: http://otel-collector:4318/v1/traces

Configuració del Collector:

processors:
  tail_sampling:
    decision_wait: 15s                      # major que la peticio mes lenta esperable
    num_traces: 100000
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: lentes
        type: latency
        latency: { threshold_ms: 1000 }
      - name: mostra-normal
        type: probabilistic
        probabilistic: { sampling_percentage: 3 }

Volum resultant. Suposant un 0,5 % d'errors i un 1 % de peticions per sobre d'1 s, es desa 0,5 % + 1 % + 3 % ≈ 4,5 % del total: uns 0,6 GB al dia, 4,3 GB en set dies. Molt per sota del límit, amb marge per pujar el 3 % al 10 % si es vol més referència. Retenció a Tempo: --storage.trace.retention=168h.

Contrapartides que cal declarar. El Collector necessita memòria per retenir les traces incompletes durant decision_wait —d'aquí memory_limiter i num_traces—; les traces triguen 15 segons extra a aparèixer a Tempo; i si el Collector cau, es perd tot, així que convé desplegar-lo amb més d'una rèplica i, en aquest cas, garantir que tots els spans d'una traça arriben al mateix Collector (un balanceig per traceId), o el mostreig de cua veurà traces partides.

Solució 3.

Principi: migrar a l'estàndard i mantenir compatibilitat temporal, sense big bang. Sis passos en aquest ordre:

1. Emetre tots dos, sense trencar res. S'afegeixen les dependències i la configuració de tracing. Micrometer posa traceId i spanId al MDC; el FiltreRastreig continua posant el seu rastreId. S'actualitza el patró de log perquè surtin els tres. Durant aquesta fase, tots els logs nous són consultables pels dos camps i res no es trenca.

2. Alinear els valors. Perquè tots dos identificadors coincideixin durant la transició, el FiltreRastreig deixa de generar un UUID i copia el traceId del span actual a la clau rastreId. Amb això, els dos camps tenen el mateix valor i les consultes antigues continuen funcionant sobre dades noves.

3. Migrar consultes i quadres de comandament. S'actualitzen els panells de Loki i les consultes desades per fer servir traceId. Com que al pas 2 tots dos coincideixen, es pot fer sense pressa i comprovant panell a panell.

4. Compatibilitat de l'API pública. La capçalera X-Rastre-Id està documentada a OpenAPI i pot haver-hi clients que la llegeixin. S'emeten les dues capçaleres amb el mateix valor, es marca X-Rastre-Id com a obsoleta a la documentació (03-07) amb una data de retirada, i s'anuncia als consumidors. El ProblemDetail fa el mateix: s'hi afegeix la propietat traceId i es manté rastre durant el període de gràcia. Una API pública no es canvia de cop, encara que el canvi sembli cosmètic.

5. Retirar el que és antic. Passat el període —el que dicti la retenció de Loki, 30 dies, més el marge acordat amb els clients—, el FiltreRastreig deixa d'escriure rastreId al MDC i d'emetre la capçalera antiga, i queda reduït a llegir el traceId del Tracer i retornar-lo a X-Trace-Id. Els logs històrics amb rastreId continuen sent llegibles fins que caduquen sols: no cal migrar dades.

6. Aprofitar el que es guanya. Acabada la migració, hi ha dues capacitats noves que abans no existien: les peticions que arriben amb un traceparent de l'app mòbil o d'un proxy continuen la traça en lloc de començar-ne una altra, i l'identificador dels logs és el mateix que el de les traces i el dels exemplars, de manera que les tres senyals queden unides per un únic valor.

Què es fa amb el que és antic: res d'especial. No es reescriuen els logs històrics —caduquen en 30 dies— ni es migren dades. L'únic element que exigeix cura i calendari és l'API pública, perquè és l'única part amb consumidors externs que no controlem.

Conclusió

Aquí es tanca el mòdul, i convé mirar el trajecte complet. Vam començar amb una aplicació en producció que es desplegava sola i de la qual ningú no sabia res: ni la seva latència real, ni on se n'anava el seu temps, ni què passava dins de la JVM. Acabem amb CicloUrbana instrumentada d'extrem a extrem.

D'aquesta lliçó t'endús el següent. El problema que ni les mètriques ni els logs resolen —atribuir el temps dins d'una operació— i la seva solució: descompondre cada petició en un arbre de spans cronometrats. Els conceptes de traça, span, traceId, context, atributs i mostreig, amb la idea que els resumeix: un span és un Timer amb genealogia. La propagació entre processos amb traceparent de W3C —inclosa la bandera de mostreig que manté les traces completes— davant de l'antic B3. L'estat real de l'ecosistema: Sleuth està descontinuat i el seu lloc l'ocupa Micrometer Tracing amb pont a OpenTelemetry, que és l'opció que no lliga a cap backend.

Has instrumentat la xarxa de Ribalta amb dues dependències i cinc propietats, sabent què s'instrumenta sol —HTTP entrant i sortint, @Scheduled, @Async, missatgeria, JDBC amb el complement adequat— i què cal declarar. I els spans propis els escrius amb l'Observation API de 09-03, de manera que el mateix codi produeix mètrica i traça, amb lowCardinalityKeyValue i highCardinalityKeyValue decidint què va a cada senyal. Saps unificar el rastreId propi de 03-06 amb el traceId estàndard i deixar al filtre només la responsabilitat de retornar-lo al ciutadà; saps activar exemplars per saltar d'un punt d'un gràfic a la traça concreta; i saps llegir una cascada de spans buscant la barra llarga, els buits sense instrumentar, la repetició que delata un N+1 i els spans en vermell. Tens Tempo i el Collector al docker-compose d'observabilitat, l'estratègia de mostreig que desa tots els errors i totes les lentes amb un 3 % de la resta, i les raons per posar un intermediari entre l'aplicació i el backend. I tens l'argument que faltava a 07-05: la traçabilitat és requisit previ a dividir un monòlit, no una millora posterior.

El balanç del mòdul sencer és aquest. 09-01 va ensenyar el mètode —mesurar abans de tocar, p95 i p99 en lloc de la mitjana, la línia base amb k6, i el recorregut prioritzat que comença sempre per la base de dades— i ho va demostrar portant un endpoint de 2,4 s a 91 ms sense afegir ni una sola màquina. 09-02 hi va afegir la memòria cau amb la seva regla d'or —primer arregla la consulta— i amb la seva part difícil, invalidar a AFTER_COMMIT. 09-03 va convertir l'Actuator de 07-01 en instrumentació real amb Micrometer, la regla de la cardinalitat i les mètriques de negoci de Ribalta. 09-04 les va treure del procés cap a Prometheus i Grafana, amb PromQL, quadres de comandament i alertes que avisen de símptomes i no de causes. 09-05 va convertir el log en una dada consultable, estructurada, correlacionada i neta de dades personals. I 09-06 ha tancat el cercle unint les tres senyals: la mètrica detecta, la traça localitza, el log explica, i el traceId les uneix. El cas pràctic de l'apartat 18 —d'una alerta a les 08:14 a una decisió de negoci en menys de deu minuts— és la prova que el conjunt funciona.

CicloUrbana està construïda, provada, desplegada i observada. Sabem fer-la, lliurar-la i veure-la funcionar. Queda una última pregunta, i és d'una altra naturalesa: està ben feta? Al llarg de nou mòduls hem pres desenes de decisions —on posar la lògica, com anomenar les coses, quan fer servir una anotació i quan no, què exposar i què amagar— i han aparegut patrons que es repeteixen i paranys que hem trepitjat diverses vegades: el del proxy, tres vegades; el de la cardinalitat, dues; el de no mesurar abans d'optimitzar, a totes. El mòdul 10 recull tot això: les millors pràctiques que han anat emergint, els errors comuns i com evitar-los abans de cometre'ls, els principis de codi net aplicats a un projecte Spring Boot real, un recorregut final per CicloUrbana sencera que uneix les peces dels deu mòduls en una sola visió, i els recursos per continuar aprenent quan aquest curs s'acabi. De construir la xarxa de Ribalta passem a entendre per què l'hem construïda així.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats