Fecha de publicación: 2026-02-27
La observabilidad 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.
En este tutorial construimos un ejemplo práctico con dos microservicios en Kotlin y Spring Boot: uno usando Spring Web (bloqueante) y otro usando WebFlux (reactivo). El objetivo es ver cómo el modelo de ejecución de cada framework se refleja directamente en la forma que adoptan los spans dentro de Jaeger.
Que 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). Recibe la telemetría 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
1version: '3.8'
2services:
3 jaeger:
4 image: jaegertracing/all-in-one:1.53
5 ports:
6 - "16686:16686" # Jaeger UI
7 - "4317" # OTLP gRPC (solo interno, lo accede el collector)
8 environment:
9 - COLLECTOR_OTLP_ENABLED=true
10
11 otel-collector:
12 image: otel/opentelemetry-collector-contrib:0.91.0
13 volumes:
14 - ./otel-collector-config.yml:/etc/otelcol-contrib/config.yaml
15 ports:
16 - "4317:4317"
17 depends_on:
18 - jaeger
19
20 order-service:
21 build: ./order-service
22 ports:
23 - "8081:8081"
24 environment:
25 - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
26 - OTEL_SERVICE_NAME=order-service
27 - INVENTORY_SERVICE_URL=http://inventory-service:8082
28 depends_on:
29 - otel-collector
30 - inventory-service
31
32 inventory-service:
33 build: ./inventory-service
34 ports:
35 - "8082:8082"
36 environment:
37 - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
38 - OTEL_SERVICE_NAME=inventory-service
39 depends_on:
40 - otel-collectorEl 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
1receivers:
2 otlp:
3 protocols:
4 grpc:
5 endpoint: 0.0.0.0:4317
6
7processors:
8 batch:
9
10exporters:
11 otlp/jaeger:
12 endpoint: jaeger:4317
13 tls:
14 insecure: true
15
16service:
17 pipelines:
18 traces:
19 receivers: [otlp]
20 processors: [batch]
21 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
1plugins {
2 kotlin("jvm") version "1.9.22"
3 kotlin("plugin.spring") version "1.9.22"
4 id("org.springframework.boot") version "3.2.2"
5 id("io.spring.dependency-management") version "1.1.4"
6}
7
8dependencies {
9 implementation("org.springframework.boot:spring-boot-starter-web")
10 implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
11 implementation("org.jetbrains.kotlin:kotlin-reflect")
12}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
1FROM eclipse-temurin:21-jdk-alpine AS build
2WORKDIR /app
3COPY . .
4RUN ./gradlew bootJar
5
6FROM eclipse-temurin:21-jre-alpine
7WORKDIR /app
8ADD https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.1.0/opentelemetry-javaagent.jar /app/opentelemetry-javaagent.jar
9COPY /app/build/libs/*.jar app.jar
10ENTRYPOINT ["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
1server:
2 port: 8081
3
4inventory:
5 service:
6 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
1@RestController
2@RequestMapping("/api/orders")
3class OrderController(
4 private val restTemplate: RestTemplate,
5 @Value("${'$'}{inventory.service.url}") private val inventoryUrl: String
6) {
7 private val logger = LoggerFactory.getLogger(OrderController::class.java)
8
9 @PostMapping
10 fun createOrder(@RequestBody request: OrderRequest): ResponseEntity<OrderResponse> {
11 logger.info("Recibiendo orden para producto: ${request.productId}")
12
13 // Llamada sincrónica (blocking) al inventory-service
14 val stock = restTemplate.getForObject(
15 "$inventoryUrl/api/inventory/${request.productId}",
16 StockResponse::class.java
17 )
18
19 if (stock == null || stock.quantity < request.quantity) {
20 logger.warn("Stock insuficiente para producto: ${request.productId}")
21 return ResponseEntity.badRequest().body(
22 OrderResponse(status = "REJECTED", reason = "Stock insuficiente")
23 )
24 }
25
26 logger.info("Orden creada exitosamente para producto: ${request.productId}")
27 return ResponseEntity.ok(
28 OrderResponse(
29 status = "CREATED",
30 orderId = UUID.randomUUID().toString(),
31 productId = request.productId,
32 quantity = request.quantity
33 )
34 )
35 }
36}Las clases de datos y la configuración del
RestTemplate:kotlin
1data class OrderRequest(val productId: String, val quantity: Int)
2
3data class OrderResponse(
4 val status: String,
5 val orderId: String? = null,
6 val productId: String? = null,
7 val quantity: Int? = null,
8 val reason: String? = null
9)
10
11data class StockResponse(val productId: String, val quantity: Int)
12
13@Configuration
14class RestTemplateConfig {
15 @Bean
16 fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build()
17}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
1plugins {
2 kotlin("jvm") version "1.9.22"
3 kotlin("plugin.spring") version "1.9.22"
4 id("org.springframework.boot") version "3.2.2"
5 id("io.spring.dependency-management") version "1.1.4"
6}
7
8dependencies {
9 implementation("org.springframework.boot:spring-boot-starter-webflux")
10 implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
11 implementation("org.jetbrains.kotlin:kotlin-reflect")
12 implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
13}El Dockerfile es idéntico al del order-service. El mismo agente de Java instrumenta WebFlux con el mismo mecanismo.
yaml
1server:
2 port: 8082El 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
1@RestController
2@RequestMapping("/api/inventory")
3class InventoryController {
4 private val logger = LoggerFactory.getLogger(InventoryController::class.java)
5
6 private val inventory = mapOf(
7 "PROD-001" to 50,
8 "PROD-002" to 0,
9 "PROD-003" to 100
10 )
11
12 @GetMapping("/{productId}")
13 fun getStock(@PathVariable productId: String): Mono<ResponseEntity<StockResponse>> {
14 logger.info("Consultando stock para producto: $productId")
15
16 return Mono.fromCallable {
17 // Simular latencia de base de datos (operación bloqueante)
18 Thread.sleep(150)
19 inventory[productId]
20 }
21 .subscribeOn(Schedulers.boundedElastic())
22 .map { quantity ->
23 if (quantity != null) {
24 logger.info("Stock encontrado: $quantity unidades")
25 ResponseEntity.ok(StockResponse(productId, quantity))
26 } else {
27 logger.warn("Producto no encontrado: $productId")
28 ResponseEntity.notFound().build<StockResponse>()
29 }
30 }
31 }
32}
33
34data 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.
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, elmap. - 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.
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
1docker-compose up --buildLa 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
1# Crear una orden para un producto con stock disponible
2curl -X POST http://localhost:8081/api/orders \
3 -H "Content-Type: application/json" \
4 -d '{"productId": "PROD-001", "quantity": 5}'
5
6# Crear una orden para un producto sin stock (será rechazada)
7curl -X POST http://localhost:8081/api/orders \
8 -H "Content-Type: application/json" \
9 -d '{"productId": "PROD-002", "quantity": 1}'
10
11# Consultar stock directamente en el inventory-service
12curl http://localhost:8082/api/inventory/PROD-001
13
14# Consultar un producto que no existe
15curl http://localhost:8082/api/inventory/PROD-999Respuesta esperada para una orden aprobada:
json
1{
2 "status": "CREATED",
3 "orderId": "a3f9e12b-4c01-4f5d-9a7e-123456789abc",
4 "productId": "PROD-001",
5 "quantity": 5,
6 "reason": null
7}Respuesta esperada para una orden rechazada por stock insuficiente:
json
1{
2 "status": "REJECTED",
3 "orderId": null,
4 "productId": null,
5 "quantity": null,
6 "reason": "Stock insuficiente"
7}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.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 mas complejo pero también te da mas 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ó.


