OpenTelemetry y Jaeger: Trazabilidad Distribuida con Spring Web y WebFlux

Jorge SaavedraJorge Saavedra
·30 de enero, 2026·10 min de lectura
opentelemetryjaegerspring-bootkotlindockerobservabilidadmicroservicioswebflux
La observabilidad
observabilidad

Capacidad de entender el estado interno de un sistema a partir de sus salidas: trazas, métricas y logs.

es uno de los pilares fundamentales de una arquitectura de microservicios saludable. Cuando algo falla en producción, necesitas saber exactamente qué ocurrió, en qué servicio y en qué momento. OpenTelemetry combinado con Jaeger te da esa visibilidad a través de trazas distribuidas
trazas distribuidas

Registros que siguen el recorrido completo de una solicitud a través de múltiples servicios, permitiendo ver dónde ocurren problemas.

.
En este tutorial construimos un ejemplo práctico con dos microservicios en Kotlin y Spring Boot: uno usando Spring Web (bloqueante
bloqueante

Modelo de ejecución donde cada solicitud ocupa un hilo del servidor hasta completarse, esperando sin hacer nada más mientras tanto.

) y otro usando WebFlux (reactivo
reactivo

Modelo de programación que maneja solicitudes de forma asíncrona sin bloquear hilos, permitiendo mayor concurrencia con menos recursos.

). El objetivo es ver cómo el modelo de ejecución de cada framework se refleja directamente en la forma que adoptan los spans
spans

Unidad de trabajo individual dentro de una traza distribuida. Tiene nombre, tiempo de inicio, duración y atributos.

dentro de Jaeger.

Qué es OpenTelemetry y Jaeger

OpenTelemetry es un framework de observabilidad de código abierto y neutral respecto al proveedor. Define un estándar unificado para recopilar tres tipos de señales:
  • Trazas: el recorrido completo de una solicitud a través de múltiples servicios
  • Métricas: valores numéricos medidos en el tiempo (latencia, tasa de errores, etc.)
  • Logs: registros estructurados de eventos
Jaeger es un backend de trazabilidad distribuida originado en Uber y actualmente parte de la CNCF. Recibe trazas, las almacena y ofrece una interfaz visual para explorarlas. Los conceptos clave que debes conocer:
  • Traza: representa el flujo completo de una operación de extremo a extremo. Agrupa todos los spans relacionados con una solicitud.
  • Span: una unidad de trabajo individual dentro de una traza. Tiene un nombre, tiempo de inicio, duración y atributos.
  • Propagación de contexto: el mecanismo por el cual los spans de diferentes servicios se vinculan entre sí, normalmente a través de cabeceras HTTP como traceparent.

Arquitectura del proyecto

El sistema está compuesto por cuatro elementos:
  • order-service (Spring Web / bloqueante), puerto 8081. Recibe solicitudes de creación de órdenes y llama al inventory-service para verificar el stock.
  • inventory-service (Spring WebFlux / reactivo), puerto 8082. Consulta el inventario y responde con la disponibilidad del producto.
  • OpenTelemetry Collector, puerto 4317 (gRPC
    gRPC

    Protocolo de comunicación de alto rendimiento desarrollado por Google, basado en HTTP/2 y Protocol Buffers.

    ). Recibe la telemetría
    telemetría

    Datos recopilados automáticamente sobre el comportamiento, rendimiento y estado de un sistema en ejecución.

    de ambos servicios y la reenvía a Jaeger.
  • Jaeger all-in-one, UI en el puerto 16686. Almacena y visualiza las trazas.
El flujo de una solicitud es el siguiente: el cliente envía un POST al order-service → order-service llama de forma sincrónica al inventory-service para verificar el stock → si hay stock suficiente, responde con la orden creada. Ambos servicios reportan sus spans al OTel Collector, que los reenvía a Jaeger. Gracias a la propagación de contexto, Jaeger puede unir todos los spans en una sola traza coherente.

Docker Compose

El archivo docker-compose.yml levanta los cuatro servicios. Es importante que order-service dependa de inventory-service para garantizar que el servicio de inventario esté disponible antes de que llegue la primera solicitud.
yaml
version: '3.8'
services:
  jaeger:
    image: jaegertracing/all-in-one:1.53
    ports:
      - "16686:16686"  # Jaeger UI
      - "4317"         # OTLP gRPC (solo interno, lo accede el collector)
    environment:
      - COLLECTOR_OTLP_ENABLED=true

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.91.0
    volumes:
      - ./otel-collector-config.yml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4317:4317"
    depends_on:
      - jaeger

  order-service:
    build: ./order-service
    ports:
      - "8081:8081"
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
      - OTEL_SERVICE_NAME=order-service
      - INVENTORY_SERVICE_URL=http://inventory-service:8082
    depends_on:
      - otel-collector
      - inventory-service

  inventory-service:
    build: ./inventory-service
    ports:
      - "8082:8082"
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
      - OTEL_SERVICE_NAME=inventory-service
    depends_on:
      - otel-collector
El OTel Collector necesita su propio archivo de configuración para saber de dónde recibir los datos y adónde enviarlos. Crea otel-collector-config.yml en la raíz del proyecto con el siguiente contenido:
yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  batch:

exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp/jaeger]
El pipeline es simple: el receptor otlp escucha en el puerto 4317, el procesador batch agrupa los spans antes de enviarlos para reducir la carga de red, y el exportador otlp/jaeger los reenvía a Jaeger.

order-service: Spring Web (bloqueante)

El orden de configuración comienza por las dependencias. En el build.gradle.ktssolo necesitas Spring Web y el agente de Java de OpenTelemetry. La instrumentación es completamente automática: no se escribe ningún código adicional relacionado con trazas.
kotlin
plugins {
    kotlin("jvm") version "1.9.22"
    kotlin("plugin.spring") version "1.9.22"
    id("org.springframework.boot") version "3.2.2"
    id("io.spring.dependency-management") version "1.1.4"
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
}
El agente de Java de OpenTelemetry se adjunta en tiempo de ejecución a través del Dockerfile, no como una dependencia de Gradle. Esto permite instrumentar automáticamente las llamadas HTTP entrantes y salientes, JDBC, logs y muchas otras bibliotecas sin modificar el código fuente.
dockerfile
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY . .
RUN ./gradlew bootJar

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
ADD https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.1.0/opentelemetry-javaagent.jar /app/opentelemetry-javaagent.jar
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-javaagent:/app/opentelemetry-javaagent.jar", "-jar", "app.jar"]
La clave está en el flag -javaagent del ENTRYPOINT. El agente se carga antes que la aplicación y aplica instrumentación bytecode en tiempo de ejecución. Las variables de entorno OTEL_EXPORTER_OTLP_ENDPOINT y OTEL_SERVICE_NAMEdel docker-compose configuran dónde enviar los datos y con qué nombre aparecerá el servicio en Jaeger.
El application.yml del order-service es minimalista. Solo declara el puerto y la URL del inventory-service como propiedad configurable:
yaml
server:
  port: 8081

inventory:
  service:
    url: ${INVENTORY_SERVICE_URL:http://localhost:8082}
El controlador principal implementa la lógica de creación de órdenes. Nota cómo la llamada al inventory-service es completamente sincrónica: el hilo se bloquea hasta recibir la respuesta.
kotlin
@RestController
@RequestMapping("/api/orders")
class OrderController(
    private val restTemplate: RestTemplate,
    @Value("${'$'}{inventory.service.url}") private val inventoryUrl: String
) {
    private val logger = LoggerFactory.getLogger(OrderController::class.java)

    @PostMapping
    fun createOrder(@RequestBody request: OrderRequest): ResponseEntity<OrderResponse> {
        logger.info("Recibiendo orden para producto: ${request.productId}")

        // Llamada sincrónica (blocking) al inventory-service
        val stock = restTemplate.getForObject(
            "$inventoryUrl/api/inventory/${request.productId}",
            StockResponse::class.java
        )

        if (stock == null || stock.quantity < request.quantity) {
            logger.warn("Stock insuficiente para producto: ${request.productId}")
            return ResponseEntity.badRequest().body(
                OrderResponse(status = "REJECTED", reason = "Stock insuficiente")
            )
        }

        logger.info("Orden creada exitosamente para producto: ${request.productId}")
        return ResponseEntity.ok(
            OrderResponse(
                status = "CREATED",
                orderId = UUID.randomUUID().toString(),
                productId = request.productId,
                quantity = request.quantity
            )
        )
    }
}
Las clases de datos y la configuración del RestTemplate:
kotlin
data class OrderRequest(val productId: String, val quantity: Int)

data class OrderResponse(
    val status: String,
    val orderId: String? = null,
    val productId: String? = null,
    val quantity: Int? = null,
    val reason: String? = null
)

data class StockResponse(val productId: String, val quantity: Int)

@Configuration
class RestTemplateConfig {
    @Bean
    fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build()
}
Es importante usar RestTemplateBuilder en lugar de instanciar RestTemplatedirectamente. El agente de OpenTelemetry puede instrumentar las instancias creadas a través del builder, asegurando que las cabeceras de propagación de contexto (traceparent) se inyecten automáticamente en cada llamada HTTP saliente.

inventory-service: Spring WebFlux (reactivo)

La estructura del inventory-service es análoga, pero usa WebFlux en lugar de Web. El build.gradle.kts cambia una sola dependencia:
kotlin
plugins {
    kotlin("jvm") version "1.9.22"
    kotlin("plugin.spring") version "1.9.22"
    id("org.springframework.boot") version "3.2.2"
    id("io.spring.dependency-management") version "1.1.4"
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webflux")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
}
El Dockerfile es idéntico al del order-service. El mismo agente de Java instrumenta WebFlux con el mismo mecanismo.
yaml
server:
  port: 8082
El controlador reactivo retorna un Mono en lugar de un valor directo. La simulación de latencia de base de datos usa Schedulers.boundedElastic() para no bloquear el event loop de Netty:
kotlin
@RestController
@RequestMapping("/api/inventory")
class InventoryController {
    private val logger = LoggerFactory.getLogger(InventoryController::class.java)

    private val inventory = mapOf(
        "PROD-001" to 50,
        "PROD-002" to 0,
        "PROD-003" to 100
    )

    @GetMapping("/{productId}")
    fun getStock(@PathVariable productId: String): Mono<ResponseEntity<StockResponse>> {
        logger.info("Consultando stock para producto: $productId")

        return Mono.fromCallable {
            // Simular latencia de base de datos (operación bloqueante)
            Thread.sleep(150)
            inventory[productId]
        }
        .subscribeOn(Schedulers.boundedElastic())
        .map { quantity ->
            if (quantity != null) {
                logger.info("Stock encontrado: $quantity unidades")
                ResponseEntity.ok(StockResponse(productId, quantity))
            } else {
                logger.warn("Producto no encontrado: $productId")
                ResponseEntity.notFound().build<StockResponse>()
            }
        }
    }
}

data class StockResponse(val productId: String, val quantity: Int)

Bloqueante vs Reactivo: entendiendo la diferencia

Antes de ver los spans en Jaeger, es fundamental entender la diferencia de fondo entre ambos modelos de ejecución. No es un detalle menor: es lo que determina la forma que van a tener tus trazas.

El modelo bloqueante (Spring Web)

Spring Web usa el modelo clásico de un hilo por solicitud. Cuando llega un request, Tomcat le asigna un hilo del thread pool. Ese hilo ejecuta todo el procesamiento de principio a fin: lee el body, ejecuta la lógica del controlador, hace llamadas HTTP a otros servicios, y escribe la respuesta. Si en algún punto tiene que esperar (por ejemplo, la respuesta del inventory-service), el hilo se queda bloqueado sin hacer nada hasta que llega la respuesta.
Imagina que eres un mesero en un restaurante. Tomas el pedido de la mesa 1, vas a la cocina, y te quedas parado esperando a que el plato esté listo. Mientras tanto, la mesa 2, la 3 y la 4 están levantando la mano para pedir, pero tú no puedes atenderlas porque estás esperando. Eso es un modelo bloqueante: un hilo atado a una tarea hasta que termina.
Esto funciona bien con pocas solicitudes concurrentes. Pero si tienes 200 hilos en el pool y llegan 300 requests simultáneos, los 100 extra se quedan en cola esperando que se libere un hilo. Si cada request tarda 500ms porque espera a otro servicio, estás desperdiciando CPU en hilos que no hacen nada útil.

El modelo reactivo (Spring WebFlux)

WebFlux usa un modelo completamente diferente basado en un event loop. En lugar de un hilo por solicitud, tiene un número reducido de hilos (típicamente uno por núcleo de CPU) que nunca se bloquean. Cuando llega un request, el event loop lo toma, ejecuta lo que puede de forma inmediata, y si necesita esperar algo (una respuesta HTTP, una consulta a base de datos), registra un callback y libera el hilo para que atienda otras solicitudes. Cuando la respuesta llega, el callback se activa y el procesamiento continúa, posiblemente en otro hilo.
Volviendo a la analogía del restaurante: ahora eres un mesero eficiente. Tomas el pedido de la mesa 1, lo dejas en la cocina, e inmediatamente vas a atender la mesa 2. Cuando la cocina te avisa que el plato de la mesa 1 está listo, lo llevas. No te quedas esperando: siempre estás haciendo algo útil.
El precio de esta eficiencia es la complejidad del código. Ya no puedes escribir código secuencial simple: necesitas trabajar con tipos reactivos como Mono y Flux, encadenar operadores, y ser muy cuidadoso de no bloquear accidentalmente el event loop (por eso usamos Schedulers.boundedElastic() para la simulación de latencia con Thread.sleep).

Cómo se refleja esto en los Spans de Jaeger

Ahora que entiendes la diferencia, veamos cómo se manifiesta en las trazas. Los spans de Jaeger son una radiografía de la ejecución: te muestran exactamente qué hizo cada hilo, cuánto tiempo duró cada operación, y cuánto tiempo se pasó esperando.
Spring Web (order-service): spans lineales y secuenciales
En el modelo bloqueante, los spans forman una cascada limpia y estrictamente secuencial:
  • Un span raíz del servidor HTTP (POST /api/orders) que envuelve toda la operación.
  • Dentro de él, un span de cliente HTTP (GET /api/inventory/PROD-001) que representa la llamada al inventory-service.
  • Ambos spans están anidados y son secuenciales: el span hijo empieza dentro del padre y el padre no termina hasta que el hijo termine.
  • Si el span raíz dura 200ms y la llamada HTTP dura 160ms, puedes concluir que el order-service pasó 160ms de esos 200ms con el hilo bloqueado esperando la respuesta.
La traza se lee como una receta paso a paso: haz A, luego B, luego C. Cada paso bloquea hasta completarse. Es fácil de leer pero revela un uso ineficiente de los recursos cuando hay llamadas externas que introducen latencia.

Traza del order-service en Jaeger: los spans son estrictamente secuenciales. El hilo estuvo bloqueado 165ms de los 215ms totales.

Spring WebFlux (inventory-service): spans con contexto reactivo
En el modelo reactivo, la historia es diferente:
  • El span raíz proviene del handler de Netty, no de Tomcat. Esto ya te indica que el servidor subyacente es distinto.
  • Puedes ver spans adicionales que corresponden a los operadores reactivos: el Mono.fromCallable, el cambio de scheduler, el map.
  • El subscribeOn(Schedulers.boundedElastic()) puede generar un span que muestra el cambio de hilo: el event loop delegó la operación bloqueante a otro pool de hilos.
  • Los spans pueden solaparse o tener gaps que no verías en un modelo bloqueante, porque el event loop estuvo libre para atender otras solicitudes en esos intervalos.
La traza se lee más como un diagrama de concurrencia: hay cosas que ocurren en paralelo, hay saltos entre hilos, hay momentos donde el event loop estuvo haciendo otra cosa. Puede parecer más compleja, pero contiene más información real sobre el comportamiento del sistema.

Traza del inventory-service en Jaeger: se observa el cambio de thread del event loop (reactor-http-nio) al pool elástico (boundedElastic) para la operación bloqueante.

En resumen

  • Bloqueante: pocos spans, apilados limpiamente, pero el hilo estuvo ocioso esperando I/O. La traza es simple porque el modelo es simple.
  • Reactivo: más spans, estructura más rica, pero cada span representa trabajo real. Los gaps entre spans significan que el hilo estaba atendiendo otras solicitudes, no esperando.
Lo valioso de esta comparación es que los spans no mienten: te muestran exactamente cómo se ejecutó tu código. En Spring Web, el hilo estuvo bloqueado durante toda la llamada al inventory-service. En WebFlux, el event loop siguió procesando otras solicitudes mientras esperaba la respuesta del scheduler de IO. Jaeger te hace visible esa diferencia que de otro modo sería completamente opaca.

Probando el sistema

Construye y levanta todos los servicios con un único comando:
bash
docker-compose up --build
La primera vez tarda varios minutos porque descarga las imágenes y compila los proyectos Kotlin. Una vez que todo esté en marcha, ejecuta las siguientes pruebas:
bash
# Crear una orden para un producto con stock disponible
curl -X POST http://localhost:8081/api/orders \
  -H "Content-Type: application/json" \
  -d '{"productId": "PROD-001", "quantity": 5}'

# Crear una orden para un producto sin stock (será rechazada)
curl -X POST http://localhost:8081/api/orders \
  -H "Content-Type: application/json" \
  -d '{"productId": "PROD-002", "quantity": 1}'

# Consultar stock directamente en el inventory-service
curl http://localhost:8082/api/inventory/PROD-001

# Consultar un producto que no existe
curl http://localhost:8082/api/inventory/PROD-999
Respuesta esperada para una orden aprobada:
json
{
  "status": "CREATED",
  "orderId": "a3f9e12b-4c01-4f5d-9a7e-123456789abc",
  "productId": "PROD-001",
  "quantity": 5,
  "reason": null
}
Respuesta esperada para una orden rechazada por stock insuficiente:
json
{
  "status": "REJECTED",
  "orderId": null,
  "productId": null,
  "quantity": null,
  "reason": "Stock insuficiente"
}
Luego de ejecutar algunas solicitudes, abre la interfaz de Jaeger en http://localhost:16686. En el panel lateral izquierdo encontrarás los servicios order-service e inventory-service. Seleccionaorder-service y haz clic en Find Traces para ver las trazas recientes. Cada traza representa una solicitud completa de extremo a extremo, incluyendo los spans de ambos microservicios.

Vista de búsqueda de trazas en Jaeger: cada fila es una solicitud completa con su duración y número de spans.

Conclusión

OpenTelemetry con Jaeger es una combinación poderosa y sorprendentemente sencilla de configurar. El agente de Java elimina la necesidad de instrumentar el código manualmente: basta con adjuntarlo como javaagent y configurar unas pocas variables de entorno para tener trazabilidad completa de las operaciones HTTP, incluyendo la propagación del contexto entre servicios.
La comparación entre Spring Web y WebFlux en Jaeger ilustra algo importante: las trazas no solo te dicen cuánto tardó una operación, sino cómo se ejecutó internamente. En un sistema bloqueante, los spans son lineales y predecibles. En un sistema reactivo, los spans reflejan el pipeline de operadores y los cambios de scheduler, lo que puede parecer más complejo pero también te da más información sobre el comportamiento real del sistema.
La observabilidad no es un lujo: es una necesidad en arquitecturas distribuidas. Incorporar OpenTelemetry desde el inicio de un proyecto tiene un costo casi nulo y un retorno muy alto cuando algo falla en producción y necesitas entender exactamente qué ocurrió.

Posts que podrian interesarte