La lección anterior cerró con dos límites que ninguna de las dos señales anteriores puede superar. El primero: el trazaId del FiltroTraza de 03-06 existe solo dentro de CicloUrbana; cuando la petición sale hacia la pasarela de pagos de 07-06, el proveedor no lo conoce, y si mañana el monolito se divide (07-05), cada servicio generará el suyo y la correlación se romperá justo en la frontera donde más falta hace.

El segundo es más profundo y no depende de dividir nada. Una petición POST /api/v1/alquileres tarda 2,1 segundos. Las métricas de 09-04 lo dicen: el p99 ha subido. Los logs de 09-05 lo confirman: hay una línea al entrar y otra al salir, con 2,1 segundos entre ambas. Y ahí se acaba la información. ¿Dónde se fueron esos dos segundos? ¿En la validación, en las tres consultas, en la caché, en el cobro, en el envío del correo? Ni un agregado numérico ni una sucesión de líneas con marcas de tiempo lo responden con precisión, porque ninguna de las dos conoce la estructura de la operación.

La trazabilidad distribuida sí. Descompone cada petición en un árbol de operaciones cronometradas y anidadas, y permite mirar una petición concreta y señalar con el dedo el trozo que se comió el 80 % del tiempo. Esta lección la monta entera en CicloUrbana: los conceptos, la propagación del contexto entre procesos, Micrometer Tracing —el sustituto del descontinuado Spring Cloud Sleuth—, spans propios con la Observation API que ya conoces de 09-03, la correlación de las tres señales, un backend donde leer la cascada, y el muestreo, que es la decisión que hace todo esto viable económicamente.

Contenido

  1. El problema: dónde se fue el tiempo
  2. Conceptos: traza, span y contexto
  3. Una traza de alquiler, descompuesta
  4. Propagación del contexto entre procesos
  5. Sleuth ha muerto: Micrometer Tracing
  6. Instrumentar CicloUrbana
  7. Qué se instrumenta sin escribir código
  8. Spans propios con la Observation API
  9. Correlacionar las tres señales
  10. Exemplars: de la gráfica a la traza
  11. El backend: Grafana Tempo
  12. Leer una cascada de spans
  13. Muestreo: cabeza, cola y errores
  14. El OpenTelemetry Collector
  15. Trazabilidad y microservicios
  16. El agente de OpenTelemetry frente a Micrometer
  17. Coste, sobrecarga y qué no trazar
  18. Caso práctico: el p99 de los alquileres
  19. Errores Comunes y Consejos
  20. Ejercicios

  1. El problema: dónde se fue el tiempo

El flujo real de un alquiler en Ribalta atraviesa cinco componentes:

sequenceDiagram
    participant M as App movil
    participant A as CicloUrbana API
    participant C as Cache · 09-02
    participant B as PostgreSQL
    participant P as Pasarela de pagos · 07-06
    M->>A: POST /api/v1/alquileres
    A->>C: buscar tarifa
    C-->>A: fallo de cache
    A->>B: select tarifa
    A->>B: select usuario, bicicleta, estacion
    A->>B: insert alquiler
    A->>P: autorizar cobro
    P-->>A: autorizado (1.740 ms)
    A->>B: update alquiler
    A-->>M: 201 Created (2.100 ms)

Con métricas sabes que la operación entera tardó 2,1 s. Con logs sabes que empezó y terminó. Ninguna de las dos te dice que 1.740 de esos 2.100 milisegundos se los llevó la pasarela, ni que hubo un fallo de caché que añadió una consulta, ni cuántas consultas hubo en realidad.

Podrías instrumentar cada paso con un Timer de 09-03, y muchos equipos lo hacen. El resultado son decenas de métricas sueltas de las que se pierde la relación: no se sabe qué invocación de la pasarela pertenece a qué petición, ni si las tres consultas fueron secuenciales o paralelas, ni por qué esta petición concreta —la del ciudadano que ha llamado al ayuntamiento— tardó lo que tardó. La traza conserva esa relación: es la estructura de una operación, cronometrada.

  1. Conceptos: traza, span y contexto

Concepto Qué es En CicloUrbana
Traza (trace) El árbol completo de una operación de principio a fin Un POST /api/v1/alquileres entero
traceId Identificador único de la traza, 128 bits Compartido por todos sus spans y por todos los servicios
Span Una unidad de trabajo con inicio, fin y nombre «insert alquiler», «autorizar cobro»
spanId Identificador del span, 64 bits Único dentro de la traza
Span padre El span que originó a este El span HTTP es padre del span de la consulta
Contexto de traza traceId + spanId + banderas, que viajan Lo que se propaga en las cabeceras
Atributos Pares clave-valor del span estacion=2, tarifa=ESTUDIANTE
Eventos Marcas puntuales dentro de un span «cortacircuitos abierto»
Estado Si el span terminó bien o con error La excepción registrada
Muestreo Decidir qué trazas se guardan 10 % en producción

Dos ideas que aclaran el modelo. Un span es un Timer con genealogía: mide lo mismo, pero además sabe quién lo llamó y a quién llamó, y esa relación es toda la diferencia. Y el traceId es el mismo a lo largo de toda la operación, incluso cruzando procesos: es lo que permite ver en una sola pantalla el trabajo hecho por CicloUrbana y el hecho por otro servicio.

  1. Una traza de alquiler, descompuesta

gantt
    title Traza de POST /api/v1/alquileres (traceId a3f19c2e...) - 2.100 ms
    dateFormat X
    axisFormat %L
    section HTTP
    POST /api/v1/alquileres          :0, 2100
    section Servicio
    AlquilerService.iniciar          :12, 2080
    section Cache
    cache estaciones (fallo)         :20, 22
    section Base de datos
    select tarifa                    :24, 31
    select usuario y bicicleta       :33, 48
    insert alquiler                  :50, 62
    update alquiler                  :1880, 1894
    section Externo
    POST pasarela /autorizaciones    :140, 1880

Leída de arriba abajo, la traza cuenta la historia completa: el span raíz dura 2.100 ms; dentro, el servicio dura 2.080; dentro de él hay un fallo de caché, tres consultas rápidas que suman 45 ms y un span de 1.740 ms hacia la pasarela. El diagnóstico es inmediato y no requiere interpretación: el 83 % del tiempo está en un sistema que no controlamos.

Compáralo con lo que teníamos: una métrica que decía «2,1 s» y dos líneas de log. La diferencia no es de cantidad de información, es de forma: la traza tiene estructura, y la estructura es lo que permite atribuir el tiempo.

  1. Propagación del contexto entre procesos

Para que el span creado por la pasarela pertenezca a la misma traza que el nuestro, el contexto tiene que viajar en la petición HTTP. Hay dos formatos y uno ha ganado:

W3C Trace Context (traceparent) B3 (Zipkin)
Estándar Recomendación del W3C De facto, de Zipkin
Cabeceras traceparent, tracestate X-B3-TraceId, X-B3-SpanId, X-B3-Sampled, X-B3-ParentSpanId
Formato Una cabecera compacta Varias cabeceras, o b3 condensada
Interoperabilidad Universal: OTel, proveedores comerciales, mallas de servicio Buena en el ecosistema Zipkin/Brave
Estado El que hay que usar hoy Compatibilidad con sistemas existentes

Una cabecera traceparent real:

traceparent: 00-a3f19c2e8b7d4f6a9c1e2d3b4a5f6e7d-b7d4f6a9c1e2d3b4-01
             │  │                                │                │
             │  │                                │                └─ banderas: 01 = muestreada
             │  │                                └─ span padre (16 hex)
             │  └─ traceId (32 hex)
             └─ version

Los cuatro campos separados por guiones son todo el mecanismo. La bandera final es más importante de lo que parece: transporta la decisión de muestreo, de modo que si nosotros decidimos guardar esta traza, el servicio siguiente guarda su parte también; sin ese acuerdo, las trazas saldrían incompletas. tracestate es la cabecera complementaria donde cada proveedor añade información propia sin romper el estándar.

La regla práctica: usa W3C por defecto, y activa además B3 solo si tienes que hablar con un sistema antiguo que solo entiende Zipkin. Micrometer permite emitir y aceptar ambos a la vez.

  1. Sleuth ha muerto: Micrometer Tracing

Quien haya visto proyectos Spring Boot 2 conocerá Spring Cloud Sleuth, que hacía exactamente esto. Conviene decirlo sin rodeos: Sleuth está descontinuado y no soporta Spring Boot 3. Su funcionalidad se trasladó al proyecto Micrometer, como parte del modelo de observabilidad unificado que ya usamos en 09-03.

Opción Qué es Estado Cuándo elegirla
Spring Cloud Sleuth La solución de Boot 2 Descontinuado Nunca en un proyecto nuevo
Micrometer Tracing + puente OTel Fachada de Micrometer sobre OpenTelemetry Recomendado CicloUrbana: el estándar hacia el que converge todo
Micrometer Tracing + puente Brave Fachada sobre Brave (Zipkin) Soportado Ya existe un Zipkin en la organización
Agente de OpenTelemetry (-javaagent) Instrumentación sin tocar el código Válido Aplicaciones que no se pueden modificar (apartado 16)

La arquitectura reproduce la idea de SLF4J y de Micrometer Metrics: Micrometer Tracing es la fachada, y detrás va un puente hacia una implementación real. Tu código habla con Observation y Tracer; el puente decide si eso acaba en OpenTelemetry o en Brave. Cambiar de uno a otro es cambiar dos dependencias.

La elección de CicloUrbana es el puente a OpenTelemetry, por la razón que anticipamos al final de 09-04: OTLP es el protocolo que aceptan Tempo, Jaeger, Grafana Cloud, Datadog, New Relic y Elastic APM, así que la instrumentación no queda atada a ningún destino.

  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 fachada y el puente; la segunda, el exportador que envía los spans por OTLP. Las versiones las gobierna el spring-boot-starter-parent de 01-04, así que no se declaran.

spring:
  application.name: ciclourbana        # se convierte en service.name en las trazas

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

Cuatro decisiones que conviene entender. spring.application.name es obligatorio de facto: sin él, todas las trazas aparecen bajo un servicio llamado unknown_service y no se pueden separar. sampling.probability: 0.1 guarda una traza de cada diez, y el apartado 13 explica por qué 1.0 no vale en producción. El endpoint apunta al Collector del apartado 14 y no directamente al backend, a propósito. Y el patrón de log es la pieza que une esta lección con la anterior: traceId y spanId aparecen en el MDC automáticamente y se escriben en cada línea.

En dev, un perfil con sampling.probability: 1.0 y el endpoint apuntando a un Tempo local permite ver todas las trazas mientras se desarrolla, que es cuando más enseñan.

  1. Qué se instrumenta sin escribir código

Esta es la parte que sorprende: con las dos dependencias y la configuración anterior, la mayor parte del trabajo ya está hecha. Micrometer Tracing se apoya en las observaciones que Spring Boot produce de serie:

Componente Span que genera Atributos automáticos
Peticiones HTTP entrantes Span raíz por petición http.method, http.route (plantilla), http.status_code
RestClient / WebClient salientes Span hijo por llamada, con propagación de cabeceras URL, método, estado
@Scheduled (07-03) Span raíz por ejecución Nombre de la tarea
@Async y ThreadPoolTaskExecutor Continuación de la traza en el otro hilo —
Spring Data / JDBC Span por consulta, con el agente o datasource-micrometer Sentencia SQL (sin parámetros)
Spring Security Span de la cadena de filtros —
Mensajería (Kafka, RabbitMQ) (07-05) Span de producción y de consumo, con el contexto en las cabeceras del mensaje Tema, partición
Resilience4j (07-06) Eventos en el span de la llamada Estado del cortacircuitos

Dos matices honestos. Las consultas SQL no se instrumentan solas con la configuración mínima: hacen falta datasource-micrometer-spring-boot o el agente de OTel del apartado 16, y merece la pena porque, como vimos en 09-01, la base de datos es donde suele estar el tiempo. Y la propagación en RestClient funciona sola solo si el cliente se construye desde el RestClient.Builder que inyecta Spring —el bean del apartado 2 de 07-06 lo hace—; un RestClient creado con RestClient.create() a mano no lleva instrumentación y rompe la traza en la frontera exterior, que es justo donde más duele.

  1. Spans propios con la Observation API

Lo automático cubre la infraestructura. Los spans de negocio —«cálculo de tarifa», «validación de disponibilidad»— hay que declararlos, y aquí se cobra la inversión de 09-03: el mismo código que ya producía métricas empieza a producir spans, sin cambiar una línea.

La versión declarativa, sobre el cálculo de tarifa:

@Observed(name = "ciclourbana.tarifa.calculo", contextualName = "calculo-tarifa")
public BigDecimal calcular(TipoTarifa tipo, Duration duracion) { ... }

Y la programática, para instrumentar un bloque concreto de AlquilerService con atributos de negocio:

@Service
public class AlquilerService {

    private final ObservationRegistry observaciones;

    @Transactional
    public AlquilerResponse iniciar(IniciarAlquilerRequest peticion) {
        return Observation.createNotStarted("ciclourbana.alquiler.iniciar", observaciones)
                .contextualName("iniciar-alquiler")
                .lowCardinalityKeyValue("tarifa", peticion.tipoTarifa().name())
                .lowCardinalityKeyValue("estacion", String.valueOf(peticion.estacionOrigenId()))
                .highCardinalityKeyValue("idUsuario", String.valueOf(usuarioActual()))
                .observe(() -> {
                    Bicicleta bicicleta = seleccionarDisponible(peticion.estacionOrigenId());
                    Alquiler alquiler = registrar(bicicleta, peticion);
                    return mapeador.aRespuesta(alquiler);
                });
    }
}

Qué produce esto exactamente: un Timer llamado ciclourbana.alquiler.iniciar con etiquetas tarifa y estacion —las de baja cardinalidad, las mismas reglas de 09-03— y un span llamado iniciar-alquiler con esos atributos más idUsuario, que va solo al span. Una instrumentación, dos señales, y la regla de cardinalidad convertida en API.

La advertencia importante, que enlaza con 09-05: los atributos de un span se almacenan y se consultan igual que un log, así que la política del apartado 10 de la lección anterior se aplica íntegra. idUsuario como identificador interno es aceptable; el correo, el teléfono, el DNI o las coordenadas GPS del ciudadano no lo son. Un span no es un lugar privado: viaja a un backend, a veces de un tercero, y lo consulta quien tenga acceso a él.

Cuando hace falta control fino —añadir un evento a mitad, marcar el span como fallido sin lanzar excepción— se usa el Tracer directamente:

Span span = tracer.currentSpan();
if (span != null) {
    span.event("cortacircuitos-abierto");
    span.tag("respaldo", "cobro-diferido");
}

  1. Correlacionar las tres señales

Ahora hay dos identificadores en juego: el trazaId que el FiltroTraza de 03-06 pone en el MDC y el traceId que Micrometer Tracing pone también en el MDC. Tener dos es peor que tener uno, así que hay que unificarlos, y la respuesta correcta es quedarse con el estándar.

El plan de unificación, en tres pasos:

  1. Cambiar el FiltroTraza para que no genere nada por su cuenta. Micrometer ya coloca traceId y spanId en el MDC de cada petición, y además —a diferencia del nuestro— respeta el traceparent entrante: si la app móvil o un proxy ya empezó una traza, se continúa en lugar de inventar otra.
  2. Mantener la responsabilidad que sí era suya: devolver el identificador al cliente. El filtro pasa a leer tracer.currentSpan().context().traceId() y a escribirlo en la cabecera de respuesta y en el ProblemDetail del ManejadorGlobalExcepciones (03-06), para que el ciudadano que llama al ayuntamiento siga pudiendo leerlo en su pantalla.
  3. Actualizar el patrón de log y las consultas de Loki, sustituyendo %X{trazaId} por %X{traceId} y | json | trazaId = "..." por | json | traceId = "...".
@Component
public class FiltroTraza extends OncePerRequestFilter {

    private final Tracer tracer;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain cadena) throws ServletException, IOException {
        Span span = tracer.currentSpan();          // ya creado por la instrumentacion HTTP
        if (span != null) {
            res.setHeader("X-Trace-Id", span.context().traceId());
        }
        cadena.doFilter(req, res);                 // sin MDC.put ni MDC.remove: los pone Micrometer
    }
}

El resultado es la correlación completa de las tres señales, que es el objetivo de todo el módulo:

Desde Hacia Cómo
Alerta (09-04) Cuadro de mando El panel de la métrica que disparó
Métrica Traza concreta Exemplars (apartado 10)
Traza Logs de esa petición {app="ciclourbana"} | json | traceId = "..."
Log Traza El traceId de la línea, en Tempo
Ciudadano que llama Todo lo anterior La cabecera X-Trace-Id de su respuesta

  1. Exemplars: de la gráfica a la traza

Un exemplar es un enlace que Prometheus guarda junto a una muestra de histograma: «esta observación de 2,1 segundos pertenece a la traza a3f19c2e...». Convierte un punto de una gráfica en un caso concreto que se puede abrir.

En el formato de /actuator/prometheus aparecen tras una almohadilla:

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

Requisitos, que son tres y conviene tenerlos juntos: histogramas activados en esa métrica (percentiles-histogram, 09-03), trazabilidad activa para que exista un traceId, y Prometheus arrancado con --enable-feature=exemplar-storage, más exemplarTraceIdDestination: trace_id en el origen de datos de Grafana.

El resultado en la práctica: en el panel de latencia p99 de 09-04 aparecen puntos sobre la curva; al pulsar uno, Grafana abre la traza exacta de una petición que tardó eso. Es el salto que faltaba entre «el p99 ha subido» y «mira esta petición». Y tiene una virtud que compensa el muestreo: los exemplars tienden a apuntar a las observaciones de las cubetas altas, es decir, a las peticiones lentas, que son justo las que interesan.

  1. El backend: Grafana Tempo

Los spans hay que enviarlos a algún sitio que los guarde y los sepa mostrar:

Backend Modelo Fuerte en Cuándo
Grafana Tempo Almacenamiento de objetos, indexa solo el traceId Muy barato; integrado con Grafana, Loki y Prometheus CicloUrbana
Jaeger Cassandra, Elasticsearch o memoria Interfaz de análisis muy buena, comparación de trazas Sin Grafana
Zipkin Sencillo, veterano Ligero y fácil de arrancar Sistemas existentes con B3
Comerciales Gestionado Correlación automática y detección de anomalías Presupuesto disponible

Se añade a la pila de observabilidad de 09-04:

# docker-compose.observabilidad.yml
  tempo:
    image: grafana/tempo:2.5.0
    command: ["-config.file=/etc/tempo/tempo.yml"]
    volumes:
      - ./observabilidad/tempo.yml:/etc/tempo/tempo.yml:ro
      - tempo-datos:/var/tempo
    ports: ["3200:3200"]           # consultas
    networks: [red-ciclourbana]

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

Y el origen de datos de Grafana, con los enlaces que hacen posible saltar entre señales:

  - name: Tempo
    type: tempo
    url: http://tempo:3200
    jsonData:
      tracesToLogsV2:
        datasourceUid: loki
        filterByTraceID: true          # de un span a sus lineas de log
      lokiSearch:
        datasourceUid: loki
      tracesToMetrics:
        datasourceUid: prometheus

  1. Leer una cascada de spans

En la interfaz de Grafana, una traza se muestra como una cascada: cada span una barra horizontal, la longitud es su duración y la sangría, su profundidad en el árbol.

POST /api/v1/alquileres ─────────────────────────────────────────── 2.100 ms
 └─ AlquilerService.iniciar ──────────────────────────────────────  2.080 ms
     ├─ cache estaciones (miss) ─                                       2 ms
     ├─ select tarifa ──                                                7 ms
     ├─ select usuario, bicicleta ────                                 15 ms
     ├─ insert alquiler ───                                            12 ms
     ├─ POST pasarela /autorizaciones ────────────────────────────  1.740 ms  ← 83 %
     └─ update alquiler ───                                            14 ms

Cómo se lee, en el orden que funciona:

  1. La barra más larga que no sea el padre. Es el sospechoso inmediato: aquí, la pasarela con el 83 % del tiempo.
  2. Los huecos. Si la suma de los hijos es mucho menor que el padre, hay tiempo no instrumentado: esperando un hilo, un bloqueo, una pausa de GC o simplemente código sin span. Un hueco grande es tan informativo como una barra larga.
  3. La repetición. Cincuenta spans select idénticos y consecutivos son un N+1 de 09-01 visto con los ojos, y es la forma más rápida que existe de detectarlo.
  4. El paralelismo. Barras que se solapan indican trabajo concurrente; barras estrictamente secuenciales que podrían solaparse son una oportunidad.
  5. Los spans en error, marcados en rojo, con su excepción como atributo.

  1. Muestreo: cabeza, cola y errores

Guardar todas las trazas es inviable: cada una son decenas de spans de unos cientos de bytes, y con 100 peticiones por segundo salen millones de spans al día. El muestreo decide cuáles se guardan.

Estrategia Cuándo decide Ventaja Inconveniente
Cabeza, probabilística Al empezar la traza Simple, barata, coherente entre servicios Se pierden trazas interesantes por azar
Cabeza, por tasa Al empezar, con un límite por segundo Coste acotado y previsible Igual de ciega ante el contenido
Cola Al terminar, viendo la traza completa Guarda todos los errores y las lentas Requiere Collector con memoria y latencia
Siempre — Todo disponible Coste prohibitivo salvo en dev

Por qué probability: 1.0 no vale en producción. Tres razones acumulativas: el coste de almacenamiento crece linealmente con el tráfico; la sobrecarga en la aplicación —crear spans, serializarlos, enviarlos— deja de ser despreciable; y la red hacia el backend transporta un caudal constante. Con un 10 % se conserva la capacidad de diagnóstico —los problemas sistemáticos aparecen igual en una muestra— a la décima parte del coste.

Pero el 10 % ciego tiene un defecto grave: la petición que falló y por la que llama el ciudadano tiene un 90 % de probabilidades de no haberse guardado. La solución es el muestreo por cola en el Collector: se retienen los spans en memoria unos segundos y, cuando la traza termina, se decide con la traza entera a la vista.

# fragmento de otel-collector.yml
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: todos-los-errores
        type: status_code
        status_code: { status_codes: [ERROR] }        # 100 % de los errores
      - name: las-lentas
        type: latency
        latency: { threshold_ms: 1000 }               # 100 % de las que pasan de 1 s
      - name: muestra-del-resto
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }     # 5 % de lo normal

Esta política es la que CicloUrbana usa en producción, y responde exactamente a lo que se necesita: todos los errores, todas las lentas y una muestra de lo normal para tener referencia. Requiere que la aplicación envíe el 100 % al Collector (sampling.probability: 1.0 en la aplicación y el filtrado en el Collector), lo que traslada el coste de la decisión al sitio donde puede tomarse bien.

  1. El OpenTelemetry Collector

Enviar los spans directamente de la aplicación al backend funciona y es lo que hace la mayoría al empezar. Poner un Collector en medio aporta cinco cosas que se agradecen pronto:

  • Desacoplamiento: cambiar de Tempo a Jaeger o a un servicio comercial se hace en el Collector, sin desplegar la aplicación.
  • Muestreo por cola, que solo puede hacerse donde converge la traza completa (apartado 13).
  • Transformación: eliminar atributos sensibles antes de que salgan —una segunda red de seguridad para la política de 09-05—, renombrar, añadir metadatos del entorno.
  • Amortiguación: si el backend cae o se ralentiza, el Collector encola y reintenta en lugar de que lo sufra la aplicación.
  • Una sola salida: métricas, logs y trazas por el mismo 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: usuario.correo          # red de seguridad: nunca debio llegar aqui
        action: delete
      - key: entorno
        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]

El orden de los procesadores importa: memory_limiter primero para protegerse, el muestreo antes que la transformación —para no gastar CPU transformando lo que se va a descartar— y batch al final, siempre, porque agrupar antes de exportar reduce mucho el número de conexiones.

  1. Trazabilidad y microservicios

En 07-05 dejamos una advertencia sobre dividir el monolito, y esta lección aporta el argumento que faltaba: la trazabilidad distribuida es requisito previo, no una mejora posterior.

El motivo es que dividir un sistema destruye la capacidad de diagnóstico que se tenía. En un monolito, una traza de pila recorre toda la operación y un depurador la sigue entera. En cuanto hay tres servicios, esa petición se convierte en tres procesos, tres logs, tres despliegues y ninguna visión conjunta: cuando el ciudadano informa de que algo va lento, nadie sabe cuál de los tres es el lento, y empieza el juego de acusaciones cruzadas entre equipos. La trazabilidad devuelve la visión única, y por eso el orden correcto es instrumentar primero y dividir después.

Lo que hay que garantizar en la frontera es simplemente que el contexto viaje. Con RestClient construido desde el Builder de Spring, sucede solo: la cabecera traceparent sale en cada petición y el servicio receptor —si está instrumentado— continúa la traza en lugar de empezar otra. Con mensajería (07-05), el contexto viaja en las cabeceras del mensaje, y hay un matiz conceptual: como el consumidor procesa después, su span no es exactamente un hijo síncrono sino un span enlazado, y en la interfaz aparece separado en el tiempo pero unido por el traceId. Con la pasarela de pagos de 07-06, que es un tercero, nuestra traza termina en el span de la llamada saliente: no vemos su interior, pero sabemos exactamente cuánto tardó, que es justo lo que necesitábamos en el apartado 3.

  1. El agente de OpenTelemetry frente a Micrometer

Existe una alternativa que no toca el código: adjuntar el agente de 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
Agente -javaagent Micrometer Tracing
Cambios en el código Ninguno Dos dependencias y configuración
Cobertura automática Muy amplia: JDBC, JMS, Redis, más de 100 bibliotecas La que Spring instrumenta
Spans de negocio Requiere anotaciones de OTel @Observed y la Observation API
Métricas y trazas unificadas Separadas Una instrumentación, dos señales
Arranque Algo más lento (reescribe bytecode) Sin impacto
Depuración Más opaco Explícito en el código
Cuándo Aplicaciones heredadas o que no se pueden tocar Proyecto Spring Boot 3 propio

La elección de CicloUrbana es Micrometer Tracing, por coherencia: ya usamos la Observation API para las métricas de 09-03, y esa misma instrumentación produce los spans. Dicho eso, hay una combinación muy práctica: agente para la cobertura automática de bajo nivel —sobre todo JDBC, que es donde está el tiempo— y Micrometer para los spans de negocio. Ambos escriben en el mismo contexto de OpenTelemetry y las trazas salen unificadas.

  1. Coste, sobrecarga y qué no trazar

La instrumentación no es gratis, aunque sea barata: crear un span, mantener el contexto en un ThreadLocal, serializarlo y enviarlo tiene un coste del orden de microsegundos por span. Con muestreo razonable, la sobrecarga en CPU se sitúa habitualmente entre el 1 % y el 3 %, más el tráfico de red hacia el Collector. Es asumible; lo que no lo es son los excesos.

Qué no trazar. Métodos triviales: un span por cada getter multiplica el volumen por cien y no aporta nada; la regla útil es un span por operación con significado o con espera de E/S. Los endpoints de Actuator: /actuator/** recogido cada 15 segundos genera un flujo constante de trazas inútiles, y se excluye con un ObservationPredicate. Las sondas de salud de Kubernetes, por lo mismo. Y los bucles muy calientes: instrumentar dentro de un bucle de mil iteraciones produce mil spans en una traza que después nadie puede leer.

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

Y qué no meter en un span: exactamente lo mismo que no se mete en un log (09-05). Los atributos de un span se almacenan, se indexan y se consultan; a menudo en un servicio de terceros. Contraseñas, tokens, tarjetas, correos, teléfonos, DNI y coordenadas GPS de los ciudadanos de Ribalta quedan fuera, y el procesador attributes del Collector actúa como última red.

  1. Caso práctico: el p99 de los alquileres

08:14. Alertmanager avisa: LatenciaP99Degradada, p99 de POST /api/v1/alquileres por encima de 1 s durante 10 minutos.

Paso 1 — el cuadro de mando (09-04). La fila RED confirma la subida: p99 en 2,2 s, tasa de error normal, tráfico normal. La fila de recursos está limpia: pool sin pending, GC sin pausas largas, CPU al 30 %. Conclusión provisional: no somos nosotros los que estamos lentos, estamos esperando a alguien.

Paso 2 — de la métrica a la traza. En el panel de p99 hay exemplars. Se pulsa uno de los puntos altos y Grafana abre la traza en Tempo. Ese salto —que era imposible hace tres lecciones— cuesta un clic.

Paso 3 — la cascada. La traza es exactamente la del apartado 12: 2.100 ms totales, de los cuales 1.740 ms en el span POST pasarela /autorizaciones. Todo lo demás está en decenas de milisegundos. El diagnóstico ya está hecho, y ha llevado dos minutos.

Paso 4 — confirmar que es general. Una traza podría ser un caso aislado, así que se vuelve a Prometheus:

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

El p95 del cliente hacia la pasarela ha pasado de 180 ms a 1,8 s, y los reintentos se han multiplicado. El cortacircuitos de 07-06 no está abierto todavía, porque las llamadas no fallan: son lentas. Ese matiz es importante, y es el motivo de que en 07-06 se recomendara alertar también sobre la tasa de reintentos.

Paso 5 — los logs (09-05). Con el traceId de la traza:

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

Devuelve la historia completa de esa petición, incluidos dos WARN de reintento con el mensaje de la pasarela: 504 Gateway Timeout upstream. El diagnóstico queda cerrado: la pasarela está degradada, no caída.

Paso 6 — decidir. El servicio funciona, más lento. Las opciones: bajar el tiempo de espera del RestClient para fallar antes y activar el respaldo de cobro diferido; abrir el cortacircuitos manualmente; o esperar y avisar al proveedor. La decisión —de negocio— es bajar la espera a 800 ms, con lo que la mayoría de los cobros pasan a diferirse y el p99 vuelve a 300 ms mientras el proveedor lo resuelve.

Lo que hace posible este recorrido son las tres señales conectadas: la métrica detectó, la traza localizó, el log explicó. Cada una hizo lo que sabe hacer, y el traceId las unió. Sin trazas, el paso 3 habría sido una investigación de horas comparando marcas de tiempo entre logs de tres instancias.

Errores Comunes y Consejos

Intentar usar Spring Cloud Sleuth en Spring Boot 3. Está descontinuado y no funciona. Su sustituto es Micrometer Tracing con un puente.

Dejar sampling.probability: 1.0 en producción. Coste de almacenamiento, sobrecarga y red, para guardar millones de trazas que nadie mirará. Muestreo por cola en el Collector, o un 10 % de cabeza.

Muestrear al 10 % en la aplicación y pretender guardar todos los errores. Son incompatibles: la decisión de cabeza se toma antes de saber si habrá error. Para eso hay que enviar todo al Collector y filtrar allí.

Olvidar spring.application.name. Todas las trazas aparecen como unknown_service y no se pueden separar por servicio.

Crear el RestClient a mano con RestClient.create(). No lleva instrumentación: no propaga traceparent y no genera span. La traza se corta justo en la frontera exterior. Siempre desde el RestClient.Builder inyectado.

Meter datos personales en los atributos de un span. Los spans se almacenan, se indexan y a menudo salen a un tercero. Se aplica íntegra la política de 09-05.

Mantener dos identificadores de traza. El trazaId propio de 03-06 y el traceId estándar conviviendo duplican el trabajo y confunden. Unifica en el estándar y deja al filtro solo la responsabilidad de devolverlo al cliente.

Trazar /actuator/** y las sondas. Generan un flujo constante de trazas inútiles y ensucian las estadísticas. Se excluyen con un ObservationPredicate.

Consejo: instrumenta con la Observation API y no con Timer a mano. Cuesta lo mismo y produce las dos señales.

Consejo: activa los exemplars. El salto de un punto de la gráfica a la traza concreta es la mejor relación entre esfuerzo y beneficio de todo el módulo.

Consejo: pon el Collector desde el principio. Cambiar de backend, muestrear por cola o limpiar atributos deja de requerir un despliegue de la aplicación.

Ejercicios

Ejercicio 1: diagnosticar desde la cascada

Esta traza corresponde a GET /api/v1/estaciones en producción. Di qué está mal, cuántos problemas distintos ves, cómo lo arreglarías y qué esperarías que ocurriera con la duración total después de cada corrección.

GET /api/v1/estaciones ──────────────────────────────────────── 1.850 ms
 └─ EstacionService.listar ─────────────────────────────────── 1.840 ms
     ├─ select estaciones ──                                        8 ms
     ├─ select bicicletas where estacion_id = ? ─                   6 ms
     ├─ select bicicletas where estacion_id = ? ─                   6 ms
     ├─ ... (18 spans identicos mas) ...                          108 ms
     ├─ (hueco sin spans)                                         900 ms
     └─ GET servicio-mapas /geocodificar ──────────────           700 ms

Ejercicio 2: diseñar la estrategia de muestreo

CicloUrbana atiende 120 peticiones por segundo en hora punta y unas 15 fuera de ella. Cada traza tiene una media de 12 spans de 400 bytes. El ayuntamiento fija tres requisitos: poder investigar cualquier petición fallida de los últimos siete días, poder investigar cualquier petición que haya tardado más de un segundo, y no gastar más de 50 GB de almacenamiento. Calcula el volumen sin muestreo, diseña la estrategia que cumple los tres requisitos y escribe la configuración de la aplicación y del Collector.

Ejercicio 3: unificar el trazaId

CicloUrbana lleva desde 03-06 con su FiltroTraza propio, y ahora Micrometer Tracing añade traceId y spanId. Hay logs históricos con trazaId en Loki, cuadros de mando que filtran por ese campo, la cabecera X-Traza-Id documentada en OpenAPI (03-07) y el ProblemDetail con la propiedad traza. Diseña la migración completa —código, configuración, consultas, documentación y compatibilidad— indicando el orden de los pasos y qué harías con lo antiguo.

Soluciones

Solución 1.

Se ven tres problemas distintos.

Problema 1 — un N+1 de manual (04-04, 09-01). Veinte spans select bicicletas where estacion_id = ? idénticos y consecutivos: una consulta por estación. Es el N+1 visto, sin necesidad de contar sentencias en el log. Corrección: @EntityGraph/JOIN FETCH, o mejor la proyección con subconsulta de 09-01, que deja una sola consulta. Efecto esperado: los 120 ms de consultas bajan a unos 10.

Problema 2 — un hueco de 900 ms sin spans. Es el hallazgo más interesante, porque el tiempo no instrumentado también es información. Casi la mitad de la petición no está en ningún span hijo, así que se fue en algo que no está instrumentado: código Java pesado —un ordenamiento o un mapeo sobre miles de objetos—, espera de una conexión del pool (hikaricp.connections.pending de 09-03 lo confirmaría), un bloqueo, o una pausa de GC (jvm.gc.pause). Cómo se investiga: se añade un @Observed en los métodos candidatos para partir el hueco, se comprueban las métricas de pool y GC en ese instante, y si nada explica, se perfila con async-profiler (09-01). Es un caso donde la traza no da la respuesta pero acota la pregunta a un tramo concreto.

Problema 3 — una llamada remota síncrona de 700 ms en un endpoint de listado. El servicio de mapas se invoca para geocodificar dentro de una petición de lectura muy usada. Correcciones posibles, en orden de preferencia: cachear el resultado (09-02), ya que las coordenadas de una estación no cambian; precalcularlo y guardarlo en la tabla, que es aún mejor porque son datos estables; o, si de verdad tiene que ser en línea, sacarlo del camino síncrono. Además, esa llamada debe tener tiempo de espera y cortacircuitos (07-06), y probablemente no los tiene.

Efecto acumulado esperado: eliminando el N+1 (−110 ms), resolviendo el hueco (−900 ms) y cacheando la geocodificación (−700 ms), la petición pasa de 1.850 ms a unos 130 ms. El orden de trabajo lo marca 09-01: primero lo que más tiempo consume, y aquí eso significa empezar por el hueco y la llamada remota, no por el N+1, aunque el N+1 sea el más llamativo.

Solución 2.

Volumen sin muestreo. En hora punta, 120 pet/s × 12 spans × 400 B = 576 KB/s. Suponiendo 4 horas punta y 20 horas de 15 pet/s: 576 KB/s × 14.400 s ≈ 8,3 GB más 72 KB/s × 72.000 s ≈ 5,2 GB, es decir, unos 13,5 GB al día y 94 GB en siete días. Casi el doble del presupuesto, y eso sin compresión.

Estrategia: muestreo por cola. Es la única que cumple los dos primeros requisitos, porque «todas las fallidas» y «todas las lentas» son decisiones que solo pueden tomarse cuando la traza ha terminado. Un muestreo de cabeza al 10 % perdería el 90 % de los errores.

Configuración de la aplicación —envía el 100 % al Collector, que es quien decide—:

management:
  tracing.sampling.probability: 1.0        # la decision se toma en el Collector
  otlp.tracing.endpoint: http://otel-collector:4318/v1/traces

Configuración del Collector:

processors:
  tail_sampling:
    decision_wait: 15s                      # mayor que la peticion mas lenta esperable
    num_traces: 100000
    policies:
      - name: errores
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: lentas
        type: latency
        latency: { threshold_ms: 1000 }
      - name: muestra-normal
        type: probabilistic
        probabilistic: { sampling_percentage: 3 }

Volumen resultante. Suponiendo un 0,5 % de errores y un 1 % de peticiones por encima de 1 s, se guarda 0,5 % + 1 % + 3 % ≈ 4,5 % del total: unos 0,6 GB al día, 4,3 GB en siete días. Muy por debajo del límite, con margen para subir el 3 % al 10 % si se quiere más referencia. Retención en Tempo: --storage.trace.retention=168h.

Contrapartidas que hay que declarar. El Collector necesita memoria para retener las trazas incompletas durante decision_wait —de ahí memory_limiter y num_traces—; las trazas tardan 15 segundos extra en aparecer en Tempo; y si el Collector cae, se pierde todo, así que conviene desplegarlo con más de una réplica y, en ese caso, garantizar que todos los spans de una traza llegan al mismo Collector (un balanceo por traceId), o el muestreo por cola verá trazas partidas.

Solución 3.

Principio: migrar al estándar y mantener compatibilidad temporal, sin big bang. Seis pasos en este orden:

1. Emitir ambos, sin romper nada. Se añaden las dependencias y la configuración de tracing. Micrometer pone traceId y spanId en el MDC; el FiltroTraza sigue poniendo su trazaId. Se actualiza el patrón de log para que salgan los tres. Durante esta fase, todos los logs nuevos son consultables por ambos campos y nada se rompe.

2. Alinear los valores. Para que ambos identificadores coincidan durante la transición, el FiltroTraza deja de generar un UUID y copia el traceId del span actual en la clave trazaId. Con eso, los dos campos tienen el mismo valor y las consultas antiguas siguen funcionando sobre datos nuevos.

3. Migrar consultas y cuadros de mando. Se actualizan los paneles de Loki y las consultas guardadas para usar traceId. Como en el paso 2 ambos coinciden, se puede hacer sin prisa y comprobando panel a panel.

4. Compatibilidad de la API pública. La cabecera X-Traza-Id está documentada en OpenAPI y puede haber clientes que la lean. Se emiten las dos cabeceras con el mismo valor, se marca X-Traza-Id como obsoleta en la documentación (03-07) con una fecha de retirada, y se anuncia a los consumidores. El ProblemDetail hace lo propio: se añade la propiedad traceId y se mantiene traza durante el periodo de gracia. Una API pública no se cambia de golpe, aunque el cambio parezca cosmético.

5. Retirar lo antiguo. Pasado el periodo —el que dicte la retención de Loki, 30 días, más el margen acordado con los clientes—, el FiltroTraza deja de escribir trazaId en el MDC y de emitir la cabecera antigua, y queda reducido a leer el traceId del Tracer y devolverlo en X-Trace-Id. Los logs históricos con trazaId siguen siendo legibles hasta que caducan por sí solos: no hay que migrar datos.

6. Aprovechar lo que se gana. Terminada la migración, hay dos capacidades nuevas que antes no existían: las peticiones que llegan con un traceparent de la app móvil o de un proxy continúan la traza en lugar de empezar otra, y el identificador de los logs es el mismo que el de las trazas y el de los exemplars, de modo que las tres señales quedan unidas por un único valor.

Qué se hace con lo antiguo: nada especial. No se reescriben los logs históricos —caducan en 30 días— ni se migran datos. El único elemento que exige cuidado y calendario es la API pública, porque es la única parte con consumidores externos que no controlamos.

Conclusión

Aquí se cierra el módulo, y conviene mirar el trayecto completo. Empezamos con una aplicación en producción que se desplegaba sola y de la que nadie sabía nada: ni su latencia real, ni dónde se iba su tiempo, ni qué pasaba dentro de la JVM. Terminamos con CicloUrbana instrumentada de extremo a extremo.

De esta lección te llevas lo siguiente. El problema que ni las métricas ni los logs resuelven —atribuir el tiempo dentro de una operación— y su solución: descomponer cada petición en un árbol de spans cronometrados. Los conceptos de traza, span, traceId, contexto, atributos y muestreo, con la idea que los resume: un span es un Timer con genealogía. La propagación entre procesos con traceparent de W3C —incluida la bandera de muestreo que mantiene las trazas completas— frente al antiguo B3. El estado real del ecosistema: Sleuth está descontinuado y su lugar lo ocupa Micrometer Tracing con puente a OpenTelemetry, que es la opción que no ata a ningún backend.

Has instrumentado la red de Ribalta con dos dependencias y cinco propiedades, sabiendo qué se instrumenta solo —HTTP entrante y saliente, @Scheduled, @Async, mensajería, JDBC con el complemento adecuado— y qué hay que declarar. Y los spans propios los escribes con la Observation API de 09-03, de modo que el mismo código produce métrica y traza, con lowCardinalityKeyValue y highCardinalityKeyValue decidiendo qué va a cada señal. Sabes unificar el trazaId propio de 03-06 con el traceId estándar y dejar al filtro solo la responsabilidad de devolverlo al ciudadano; sabes activar exemplars para saltar de un punto de una gráfica a la traza concreta; y sabes leer una cascada de spans buscando la barra larga, los huecos sin instrumentar, la repetición que delata un N+1 y los spans en rojo. Tienes Tempo y el Collector en el docker-compose de observabilidad, la estrategia de muestreo que guarda todos los errores y todas las lentas con un 3 % del resto, y las razones para poner un intermediario entre la aplicación y el backend. Y tienes el argumento que faltaba en 07-05: la trazabilidad es requisito previo a dividir un monolito, no una mejora posterior.

El balance del módulo entero es este. 09-01 enseñó el método —medir antes de tocar, p95 y p99 en lugar de la media, la línea base con k6, y el recorrido priorizado que empieza siempre por la base de datos— y lo demostró llevando un endpoint de 2,4 s a 91 ms sin añadir una sola máquina. 09-02 añadió la caché con su regla de oro —primero arregla la consulta— y con su parte difícil, invalidar en AFTER_COMMIT. 09-03 convirtió el Actuator de 07-01 en instrumentación real con Micrometer, la regla de la cardinalidad y las métricas de negocio de Ribalta. 09-04 las sacó del proceso hacia Prometheus y Grafana, con PromQL, cuadros de mando y alertas que avisan de síntomas y no de causas. 09-05 convirtió el log en un dato consultable, estructurado, correlacionado y limpio de datos personales. Y 09-06 ha cerrado el círculo uniendo las tres señales: la métrica detecta, la traza localiza, el log explica, y el traceId las une. El caso práctico del apartado 18 —de una alerta a las 08:14 a una decisión de negocio en menos de diez minutos— es la prueba de que el conjunto funciona.

CicloUrbana está construida, probada, desplegada y observada. Sabemos hacerla, entregarla y verla funcionar. Queda una última pregunta, y es de otra naturaleza: ¿está bien hecha? A lo largo de nueve módulos hemos tomado decenas de decisiones —dónde poner la lógica, cómo nombrar las cosas, cuándo usar una anotación y cuándo no, qué exponer y qué esconder— y han aparecido patrones que se repiten y trampas que hemos pisado varias veces: la del proxy, tres veces; la de la cardinalidad, dos; la de no medir antes de optimizar, en todas. El módulo 10 recoge todo eso: las mejores prácticas que han ido emergiendo, los errores comunes y cómo evitarlos antes de cometerlos, los principios de código limpio aplicados a un proyecto Spring Boot real, un recorrido final por CicloUrbana entera que une las piezas de los diez módulos en una sola visión, y los recursos para seguir aprendiendo cuando este curso termine. De construir la red de Ribalta pasamos a entender por qué la hemos construido así.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados