Capacidad de entender el estado interno de un sistema a partir de sus salidas: trazas, métricas y logs.
Registros que siguen el recorrido completo de una solicitud a través de múltiples servicios, permitiendo ver dónde ocurren problemas.
Modelo de ejecución donde cada solicitud ocupa un hilo del servidor hasta completarse, esperando sin hacer nada más mientras tanto.
Modelo de programación que maneja solicitudes de forma asíncrona sin bloquear hilos, permitiendo mayor concurrencia con menos recursos.
Unidad de trabajo individual dentro de una traza distribuida. Tiene nombre, tiempo de inicio, duración y atributos.
Qué es OpenTelemetry y Jaeger
- 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
- 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
- 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íagRPC
Protocolo de comunicación de alto rendimiento desarrollado por Google, basado en HTTP/2 y Protocol Buffers.
de ambos servicios y la reenvía a Jaeger.telemetríaDatos recopilados automáticamente sobre el comportamiento, rendimiento y estado de un sistema en ejecución.
- Jaeger all-in-one, UI en el puerto 16686. Almacena y visualiza las trazas.
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
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.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-collectorotel-collector-config.yml en la raíz del proyecto con el siguiente contenido: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]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)
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.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")
}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"]-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.application.yml del order-service es minimalista. Solo declara el puerto y la URL del inventory-service como propiedad configurable:server:
port: 8081
inventory:
service:
url: ${INVENTORY_SERVICE_URL:http://localhost:8082}@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
)
)
}
}RestTemplate: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()
}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)
build.gradle.kts cambia una sola dependencia: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")
}server:
port: 8082Mono 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:@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
El modelo bloqueante (Spring Web)
El modelo reactivo (Spring WebFlux)
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
- 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.
Traza del order-service en Jaeger: los spans son estrictamente secuenciales. El hilo estuvo bloqueado 165ms de los 215ms totales.
- 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.
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.
Probando el sistema
docker-compose up --build# 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{
"status": "CREATED",
"orderId": "a3f9e12b-4c01-4f5d-9a7e-123456789abc",
"productId": "PROD-001",
"quantity": 5,
"reason": null
}{
"status": "REJECTED",
"orderId": null,
"productId": null,
"quantity": null,
"reason": "Stock insuficiente"
}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.