Arquitectura y operación

Telemetría en aplicaciones Spring Boot

La observabilidad permite entender qué sucede en una aplicación en ejecución mediante logs, métricas y trazas. En Spring Boot, Micrometer instrumenta métricas y observaciones; Micrometer Tracing conecta las observaciones con un trazador, y OpenTelemetry puede transportar los datos a un backend mediante OTLP. OTLP es un protocolo de exportación, no una interfaz de consulta ni un servidor que haya que instalar dentro de la aplicación. Véanse la guía de observabilidad de Spring Boot y el artículo de Spring sobre OpenTelemetry.

Señal Pregunta que responde Ejemplo en Spring Boot
Logs ¿Qué evento o error ocurrió? Mensaje de SLF4J con excepción y traceId para correlación.
Métricas ¿Cuánto ocurre y cómo evoluciona? Tasa y duración de http.server.requests, memoria JVM, conexiones del pool.
Trazas ¿Por dónde pasó una solicitud y dónde tardó? Span de una petición HTTP y de las operaciones observadas que ejecutó.

Una traza agrupa spans relacionados mediante un traceId; cada span tiene un spanId y puede apuntar a un span padre. W3C Trace Context propaga ese contexto entre servicios con el encabezado traceparent. La propagación entre procesos y la exportación a OTLP son pasos distintos: el primero relaciona solicitudes; el segundo envía datos al backend. Un span de SQL, por ejemplo, requiere instrumentación de JDBC adicional; la presencia de JPA o jOOQ no implica que cada consulta se registre automáticamente como span. Fuentes: trazas de Spring Boot, observabilidad de Spring Boot.

Una traza es un span raíz con sus descendientes. Cada span guarda su propia duración, y es esa jerarquía la que permite señalar dónde se fue la latencia:

flowchart TD
    subgraph Proc1["⚙️ Proceso lis-app"]
        direction TB
        Raiz["span raíz · GET /api/v1/public/test<br/><b>spanId</b> a1b2 · <b>padre</b> ninguno · 120 ms"]
        Proc["lis.procesar · observación propia<br/><b>spanId</b> c3d4 · <b>padre</b> a1b2 · 60 ms"]
        Sql["consulta JDBC<br/><b>spanId</b> e5f6 · <b>padre</b> c3d4 · 25 ms<br/><i>solo con instrumentación aparte</i>"]
        Cliente["llamada saliente con RestClient<br/><b>spanId</b> 0708 · <b>padre</b> a1b2 · 35 ms"]
    end

    subgraph Proc2["⚙️ Proceso servicio-remoto"]
        direction TB
        Remoto["GET /recurso<br/><b>spanId</b> 9a0b · <b>padre</b> 0708 · 30 ms"]
    end

    Raiz --> Proc
    Proc --> Sql
    Raiz --> Cliente
    Cliente -->|"traceparent: 00-<b>traceId</b>-0708-01"| Remoto

    classDef raiz fill:#D9ECFF,stroke:#165A91,stroke-width:3px,color:#102D46
    classDef hijo fill:#DCF5E7,stroke:#247247,stroke-width:2px,color:#173C2A
    classDef ausente fill:#F1F3F5,stroke:#50555A,stroke-width:2px,color:#24282B,stroke-dasharray:4 3
    classDef remoto fill:#EAE1FF,stroke:#6941A5,stroke-width:2px,color:#30204F

    class Raiz raiz
    class Proc,Cliente hijo
    class Sql ausente
    class Remoto remoto

Los cinco spans comparten un mismo traceId; cada uno tiene su spanId y apunta al de su padre. El span remoto vive en otro proceso y se une a la traza porque recibió el encabezado traceparent, cuyo tercer campo es precisamente el spanId del emisor. El de JDBC aparece en gris punteado porque, como se indicó, no se genera sin instrumentación adicional. El archivo Mermaid está disponible aparte.

Qué usa este repositorio

Este proyecto LIS usa Spring Boot 4.1. En pom.xml ya están spring-boot-starter-opentelemetry y spring-boot-starter-actuator. El primero incluye el puente de Micrometer Tracing a OpenTelemetry y la exportación OTLP de métricas y trazas; Actuator aporta los endpoints de gestión. No se necesitan dependencias de Brave ni de Zipkin para la configuración actual. Fuente: Spring Boot, trazadores compatibles.

application.yaml configura spring.application.name: lis-app y estos destinos OTLP/HTTP:

Señal Propiedad de Spring Boot Destino
Métricas management.otlp.metrics.export.url ${OTLP_ENDPOINT}/v1/metrics
Trazas management.opentelemetry.tracing.export.otlp.endpoint ${OTLP_ENDPOINT}/v1/traces
Logs management.opentelemetry.logging.export.otlp.endpoint ${OTLP_ENDPOINT}/v1/logs

El host sale de la variable OTLP_ENDPOINT, con http://localhost:4318 como default de desarrollo. Es una variable y no un literal por la razón que se explica más abajo: localhost se interpreta desde el proceso Java.

Los prefijos no son homogéneos y conviene saber por qué: las métricas salen por management.otlp.metrics.* porque las exporta el registro de Micrometer (micrometer-registry-otlp), mientras que trazas y logs salen por management.opentelemetry.* porque los exporta el SDK de OpenTelemetry (opentelemetry-exporter-otlp). Ambas rutas llegan al mismo puerto 4318, pero son dos mecanismos distintos y sus propiedades no se intercambian.

Con spring.application.name Spring Boot deriva el atributo de recurso service.name, que es el nombre con el que la aplicación aparece en Grafana. Los demás atributos del recurso se configuran aparte, y tienen su propia sección.

Además de los destinos, el proyecto configura estos ajustes de observabilidad. Cada uno responde a una razón concreta, no a un valor por omisión que se dejó escrito:

Ajuste Valor Por qué
management.tracing.sampling.probability ${OTEL_SAMPLING:1.0} Variable, no literal: 1.0 sirve en desarrollo y arruinaría producción. Muestreo
management.opentelemetry.tracing.sampler parent-based-trace-id-ratio Explícito: una traza que entra muestreada no se parte. Muestreo
...tracing.limits.max-attribute-value-length 256 Si un atributo con PII se cuela, viaja truncado. Límites
...logging.limits.max-attribute-value-length 256 Lo mismo para los registros de log
management.opentelemetry.resource-attributes deployment.environment Distinguir dev de producción en el backend
...metrics.distribution.percentiles-histogram http.server.requests: true Sin histograma el SLO de p95 no es medible. Percentiles y SLO
...metrics.distribution.slo 100ms,300ms,500ms,1s Cubetas alineadas con el SLO declarado
server.tomcat.mbeanregistry.enabled true Sin esto no hay métricas de hilos del contenedor

Las convenciones de qué se instrumenta y cómo se nombra no están aquí, sino en ADR-0007: esto es configuración, aquello es criterio.

docker-compose.yaml incluye grafana/otel-lgtm, que recibe OTLP por el puerto 4318 y ofrece la interfaz de Grafana en http://localhost:3000. Si ese contenedor no está levantado, la aplicación arranca igual: el exportador registra errores de conexión periódicamente y las señales se pierden, sin afectar al tráfico HTTP. El Collector es útil para recibir, procesar y reenviar señales, pero no es obligatorio cuando el backend acepta OTLP directamente. localhost en estas propiedades se interpreta desde el proceso Java: si la aplicación se ejecuta dentro de otro contenedor, debe usarse un nombre de servicio o una dirección accesible desde ese contenedor. Fuente: artículo de Spring sobre OpenTelemetry.

Comunicación entre los componentes

Las flechas continuas muestran los caminos disponibles hoy. El appender OTEL conecta Logback con el exportador de logs. La consulta a /actuator/metrics ocurre dentro de la aplicación y es independiente del envío OTLP.

flowchart LR
    Cliente["👤 Cliente HTTP"] -->|GET /api/v1/public/test| MVC

    subgraph LIS["⚙️ LIS · Spring Boot 4.1"]
        direction TB
        MVC["🌐 Spring MVC y código de aplicación"] --> Observacion["🔎 Micrometer Observation"]
        MVC --> SLF4J["📝 SLF4J y Logback"]
        Observacion --> Registro["📊 Micrometer MeterRegistry"]
        Observacion --> Puente["🧵 Micrometer Tracing y puente OTel"]
        Registro -->|"métricas locales"| Actuator["🔍 Actuator /metrics"]
    end

    subgraph LGTM["📈 Grafana LGTM · Docker Compose"]
        direction TB
        Receptor["📥 Receptor OTLP/HTTP :4318"] --> Almacenes["🗄️ Almacenamiento de señales"]
        Almacenes --> Grafana["📊 Grafana :3000"]
    end

    Registro -->|"OTLP /v1/metrics"| Receptor
    Puente -->|"OTLP /v1/traces"| Receptor
    SLF4J --> Consola["🖥️ Consola de la aplicación"]
    SLF4J -->|"OTLP /v1/logs: appender OTEL"| Receptor
    Operador["👩‍💻 Operador autenticado"] -->|"Consulta local"| Actuator

    classDef actor fill:#FFF2CC,stroke:#7A5900,stroke-width:2px,color:#302300
    classDef app fill:#D9ECFF,stroke:#165A91,stroke-width:2px,color:#102D46
    classDef signal fill:#DCF5E7,stroke:#247247,stroke-width:2px,color:#173C2A
    classDef backend fill:#EAE1FF,stroke:#6941A5,stroke-width:2px,color:#30204F
    classDef local fill:#F1F3F5,stroke:#50555A,stroke-width:2px,color:#24282B

    class Cliente,Operador actor
    class MVC,Observacion app
    class Registro,Puente,SLF4J signal
    class Receptor,Almacenes,Grafana backend
    class Actuator,Consola local

El archivo Mermaid del diagrama permite editarlo o renderizarlo fuera de la wiki.

Muestreo

El muestreo decide qué trazas se exportan. El valor predeterminado de Spring Boot es 0.1 (10 %) para no inundar el backend; este proyecto lo declara como variable con 1.0 de default de desarrollo:

management:
  tracing:
    sampling:
      probability: ${OTEL_SAMPLING:1.0}
  opentelemetry:
    tracing:
      sampler: parent-based-trace-id-ratio

Es una variable y no un literal a propósito: un 1.0 escrito en el archivo es una decisión de desarrollo que se despliega por olvido, y muestrear el 100 % del tráfico en producción cuesta dinero y ancho de banda sin mejorar el diagnóstico.

Con OpenTelemetry, además de la probabilidad se puede elegir el muestreador con management.opentelemetry.tracing.sampler:

Muestreador Comportamiento
always-on Muestrea todas las trazas.
always-off Descarta todas las trazas.
trace-id-ratio Muestrea una fracción según management.tracing.sampling.probability, ignorando al padre.
parent-based-always-on Sigue la decisión del padre; sin padre, muestrea todo.
parent-based-always-off Sigue la decisión del padre; sin padre, descarta todo.
parent-based-trace-id-ratio (predeterminado) Sigue la decisión del padre; sin padre, muestrea una fracción según la probabilidad.

La familia parent-based es la que importa en un sistema con varios procesos: si la petición entra con un traceparent ya muestreado, el span hijo se muestrea también y la traza no queda partida por la mitad. Por eso está declarado explícitamente aunque sea el valor por omisión. Fuente: muestreo en Spring Boot.

Límites de atributos

OpenTelemetry permite acotar cuántos atributos lleva un span o un registro de log y cuánto puede medir el valor de cada uno. Este proyecto fija la longitud en las dos señales:

management:
  opentelemetry:
    tracing:
      limits:
        max-attribute-value-length: 256
    logging:
      limits:
        max-attribute-value-length: 256

No es una optimización de tamaño, es una segunda línea de defensa. La regla 14 de 00-agents.md prohíbe PII en telemetría; si un atributo prohibido se cuela pese a la revisión, viaja truncado en lugar de completo. Las propiedades disponibles son max-attributes y max-attribute-value-length para ambas señales, y además max-events, max-links, max-attributes-per-event y max-attributes-per-link para trazas. Si hace falta control total, se registra un bean SpanLimits o LogLimits.

Atributos de recurso: quién emite

El recurso identifica al emisor en el backend. service.name lo deriva Spring Boot de spring.application.name, y el resto se añade con management.opentelemetry.resource-attributes:

management:
  opentelemetry:
    resource-attributes:
      "[deployment.environment]": ${DEPLOY_ENV:dev}

Los corchetes son la forma documentada de conservar literal una clave con puntos dentro de un mapa. Estos atributos se combinan con las variables de entorno OTEL_RESOURCE_ATTRIBUTES y OTEL_SERVICE_NAME, y la propiedad gana sobre la variable. El registro OTLP de Micrometer no usa el bean Resource, pero sí respeta esta propiedad, así que sirve para las tres señales. Si se define un bean Resource propio, deja de aplicarse.

Etiquetas comunes y cardinalidad

Hay dos mecanismos, y no son el mismo:

Propiedad Alcance
management.observations.key-values.* Etiquetas de baja cardinalidad en todas las observaciones (métricas y trazas).
management.metrics.tags.* Etiquetas en todos los medidores de Micrometer, incluidos los que no vienen de una observación.

La cardinalidad de una etiqueta es el número de valores distintos que puede tomar, y gobierna el coste: cada combinación de etiquetas es una serie temporal separada. Una etiqueta con el identificador de la orden o del paciente multiplica las series por el número de órdenes y, en este dominio, además expone información clínica. Las convenciones de nombres y cardinalidad de este proyecto están en ADR-0007; el resumen es que solo los conjuntos cerrados y pequeños van como baja cardinalidad, los identificadores técnicos van como alta cardinalidad y por tanto solo a la traza, y la PII no va en ninguna de las dos.

Logs: correlación y exportación

La aplicación usa SLF4J para emitir logs. Los niveles habituales son TRACE, DEBUG, INFO, WARN y ERROR; se pueden configurar con propiedades como logging.level.com.sineltek.lis=DEBUG. Spring Boot añade por defecto los identificadores de traza y span al patrón de logs cuando está activo Micrometer Tracing. Esto permite buscar en el backend de trazas la solicitud asociada con una línea de log. Fuente: correlación de logs en Spring Boot.

La URL OTLP de logs por sí sola no envía los mensajes de SLF4J. Spring Boot configura el exportador, pero necesita un appender para conectar Logback con él. Este proyecto ya incluye opentelemetry-logback-appender-1.0, lo declara en src/main/resources/logback-spring.xml y lo instala con OpenTelemetryAppenderInitializer. Por eso los mensajes se mantienen en consola y también se envían por OTLP a Grafana LGTM. La exportación de métricas y trazas es independiente del appender. Fuente: loggers en Spring Boot.

La dependencia del appender no forma parte de spring-boot-starter-opentelemetry ni la gestiona la BOM de Spring Boot. En pom.xml se fija la versión 2.28.1-alpha, cuya versión de instrumentación apunta al SDK OpenTelemetry 1.62.0 gestionado por Spring Boot 4.1.0. La versión se elige por el opentelemetry-api que declara, que debe coincidir con el SDK que gestiona Boot, no por ser la más reciente: 2.30.0-alpha pide el 1.64.0 y 2.31.1-alpha el 1.65.0. Al subir Spring Boot se comprueba primero qué SDK pasa a gestionar. La decisión y su motivo están en ADR-0006:

<dependency>
    <groupId>io.opentelemetry.instrumentation</groupId>
    <artifactId>opentelemetry-logback-appender-1.0</artifactId>
    <version>${opentelemetry-instrumentation.version}</version>
</dependency>

src/main/resources/logback-spring.xml conserva el appender de consola y añade el de OpenTelemetry:

<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>

    <appender name="OTEL" class="io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender"/>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="OTEL"/>
    </root>
</configuration>

OpenTelemetryAppenderInitializer le proporciona el bean OpenTelemetry durante el arranque, porque Logback se configura antes que el contexto de Spring:

@Component
class OpenTelemetryAppenderInitializer implements InitializingBean {

    private final OpenTelemetry openTelemetry;

    OpenTelemetryAppenderInitializer(OpenTelemetry openTelemetry) {
        this.openTelemetry = openTelemetry;
    }

    @Override
    public void afterPropertiesSet() {
        OpenTelemetryAppender.install(this.openTelemetry);
    }
}

El appender no captura atributos adicionales del MDC: traceId y spanId viajan como campos propios del registro OpenTelemetry cuando hay un span activo. Los mensajes anteriores a la instalación se almacenan temporalmente hasta el límite del buffer del appender. En una aplicación clínica no se debe activar captureMdcAttributes=*, que podría exportar datos sensibles; si hacen falta otros atributos, se eligen por nombre y se revisa su contenido. Fuente: appender de Logback de OpenTelemetry.

Este cableado se rompe sin romper la compilación: quitar el <appender-ref ref="OTEL"/> o el @Component del inicializador deja el build en verde y la aplicación muda en el backend. ExportacionLogsOtelTest lo impide: arranca la aplicación, emite un log dentro de un span activo y afirma que llega al SDK con el mismo traceId y spanId, sustituyendo el exportador OTLP por uno en memoria para no depender de la red. Se comprobó rompiéndolo a propósito en esos dos puntos.

El endpoint /actuator/loggers permite consultar y cambiar niveles de log en ejecución mediante POST; no muestra ni exporta los mensajes. Este proyecto no lo expone: management.endpoints.web.exposure.include se limita a health,info. Subir un paquete a DEBUG desde fuera convertiría cualquier traza de depuración en una fuga de datos de paciente. Si se necesita para diagnosticar, se habilita de forma temporal y consciente, nunca de forma permanente. Con independencia de ello, evite registrar contraseñas, datos de pacientes, resultados clínicos o cuerpos completos de solicitudes.

Métricas: inspección y exportación

Actuator y Micrometer registran automáticamente métricas de JVM, proceso, servidor HTTP y otras integraciones presentes. Por ejemplo, después de llamar a un controlador MVC puede consultarse http.server.requests. Una métrica agrupa medidas por etiquetas; use etiquetas con pocos valores posibles, como método HTTP o estado. Identificadores de pacientes, órdenes o solicitudes producirían demasiadas series y podrían exponer información sensible. Fuente: métricas de Spring Boot.

Hay dos mecanismos diferentes:

Mecanismo Uso en este proyecto
GET /actuator/metrics y GET /actuator/metrics/{nombre} Inspección puntual dentro de la aplicación. No está expuesto: la lista de endpoints web se limita a health,info. Para consultarlo hay que añadirlo explícitamente.
OTLP a /v1/metrics Exportación periódica al backend configurado; no depende de consultar /actuator/metrics.

Para obtener spans de cada consulta SQL, la instrumentación habitual es net.ttddyy.observation:datasource-micrometer-spring-boot, que envuelve el DataSource y crea una observación por consulta. No forma parte de Spring Boot. Conviene comprobar el resultado: combinada con el starter de OpenTelemetry se han reportado spans duplicados para una misma consulta, porque dos manejadores distintos atienden la misma observación. Fuente: datasource-micrometer.

/actuator/prometheus es otra opción, orientada al scraping por Prometheus. Requiere la dependencia micrometer-registry-prometheus y exponer ese endpoint; no está configurado aquí. No debe confundirse con la exportación OTLP ya presente. Fuente: métricas OTLP y Prometheus en Spring Boot.

Métricas que ya existen sin escribir código

Antes de instrumentar algo conviene saber qué se publica solo. Registrar un contador propio de peticiones o de errores es duplicar peor lo que ya está:

Familia Qué responde Requiere
jvm.* Memoria, pools, recolección de basura, hilos, clases cargadas, tiempo de JIT —
system.*, process.*, disk.* CPU, descriptores de fichero, tiempo en marcha, espacio libre —
application.started.time, application.ready.time Cuánto tardó en arrancar y en estar listo para atender —
http.server.requests Latencia, estado y resultado de cada petición MVC —
jdbc.connections.*, hikaricp.* Pool de conexiones: activas, inactivas, máximo, esperas —
logback.events Contador de eventos por nivel: la tasa de ERROR sin escribir una línea —
tomcat.* Hilos ocupados, conexiones, sesiones del contenedor servlet server.tomcat.mbeanregistry.enabled: true, ya activado
executor.* Colas y pools de ThreadPoolTaskExecutor y ThreadPoolTaskScheduler Que existan esos beans
ssl.chain.expiry Segundos hasta que caduca cada cadena de certificados Que haya bundles SSL configurados
Hilos virtuales Estadísticas de hilos virtuales io.micrometer:micrometer-java21, no está en el classpath

Las de pool y logback.events son las dos que más rápido explican una caída: una saturación del pool de Hikari y un pico de ERROR se ven antes aquí que en cualquier traza. Fuente: métricas soportadas en Spring Boot.

Percentiles, histogramas y el SLO

00-agents.md fija un SLO provisional de p95 < 500 ms en consultas operativas. Un percentil no se puede calcular desde un contador y una media, así que sin configurarlo ese SLO no es medible. Hay tres mecanismos y conviene no confundirlos:

Propiedad Qué produce
management.metrics.distribution.percentiles-histogram Histograma agregable: el backend calcula el percentil, y puede hacerlo sobre varias instancias.
management.metrics.distribution.percentiles Percentiles calculados dentro de la aplicación. No son agregables entre instancias.
management.metrics.distribution.slo Histograma acumulado con cubetas en los límites que se declaren.

Este proyecto configura el histograma y las cubetas del SLO para las peticiones HTTP:

management:
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
      slo:
        http.server.requests: 100ms,300ms,500ms,1s

Con eso, «¿cumplimos el p95?» y «¿cuántas peticiones pasaron de 500 ms?» son consultas, no estimaciones. Si el número de cubetas resulta excesivo, minimum-expected-value y maximum-expected-value acotan el rango. Fuente: histogramas y percentiles en Micrometer.

Exemplars: de una métrica a su traza

Un exemplar es un puntero desde una muestra de una métrica hasta una traza concreta que la produjo. Es lo que convierte «el p95 se fue a 2 segundos a las 11:40» en «esta es una de las peticiones que tardó 2 segundos, y aquí está su traza».

OTLP los admite y no hay nada que configurar: requieren un bean ExemplarContextProvider, que Spring Boot autoconfigura cuando está Micrometer Tracing, y está. Por omisión solo se incluyen trazas muestreadas, lo que se controla con management.tracing.exemplars.include (sampled-traces es el valor por defecto). Fuente: OTLP en Spring Boot.

Trazas: instrumentación y propagación

Spring Boot crea observaciones para las solicitudes MVC y para los clientes HTTP construidos con sus builders configurados. Para mantener el contexto al llamar a otro servicio, inyecte RestClient.Builder, RestTemplateBuilder o WebClient.Builder; un cliente creado manualmente puede perder la instrumentación y el encabezado de propagación. Fuente: propagación de trazas en Spring Boot.

El siguiente ejemplo muestra la comunicación si LIS llama a otro servicio instrumentado; ese segundo servicio no forma parte de este repositorio. traceparent viaja en la petición HTTP, mientras cada aplicación exporta sus propios spans al backend.

sequenceDiagram
    actor Cliente as 👤 Cliente
    participant LIS as ⚙️ LIS / Spring Boot
    participant HTTP as 🌐 Cliente HTTP con builder
    participant Servicio as ⚙️ Otro servicio instrumentado
    participant LGTM as 📈 Backend OTLP

    Cliente->>LIS: GET /api/ejemplo
    activate LIS
    Note over LIS: Observación HTTP: crea o continúa traceId
    LIS->>HTTP: Llamada mediante RestClient.Builder
    HTTP->>Servicio: GET /recurso + header traceparent
    activate Servicio
    Note over Servicio: Extrae traceId y crea un span hijo
    Servicio-->>HTTP: Respuesta
    deactivate Servicio
    HTTP-->>LIS: Respuesta
    LIS-->>Cliente: Respuesta HTTP
    deactivate LIS
    LIS-)LGTM: Span de LIS por OTLP, si fue muestreado
    Servicio-)LGTM: Span remoto por OTLP, si fue muestreado

También está disponible el archivo Mermaid de la secuencia.

Para medir una operación de negocio propia, use ObservationRegistry. Una observación puede producir una métrica y un span con el mismo nombre:

import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.stereotype.Service;

@Service
class ProcesamientoService {
    private final ObservationRegistry observations;

    ProcesamientoService(ObservationRegistry observations) {
        this.observations = observations;
    }

    void procesar() {
        Observation.createNotStarted("lis.procesar", observations)
                .lowCardinalityKeyValue("tipo", "rutina")
                .observe(() -> {
                    // Operación que se desea medir.
                });
    }
}

Ese único bloque de código alimenta dos señales a la vez, y cada tipo de etiqueta llega a un sitio distinto:

flowchart LR
    Codigo["🧩 Código de negocio<br/>Observation.createNotStarted(...)"] --> Obs["🔎 Observación<br/>lis.procesar"]

    Obs --> Baja["🏷️ Etiquetas de baja cardinalidad<br/>tipo=rutina"]
    Obs --> Alta["🏷️ Etiquetas de alta cardinalidad<br/>solo a la traza"]

    Baja --> Metrica["📊 Métrica<br/>lis.procesar · contador y duración"]
    Baja --> Span["🧵 Span<br/>lis.procesar"]
    Alta --> Span

    Metrica -->|"OTLP /v1/metrics"| Backend["📈 Backend OTLP"]
    Span -->|"OTLP /v1/traces"| Backend

    classDef codigo fill:#D9ECFF,stroke:#165A91,stroke-width:2px,color:#102D46
    classDef obs fill:#DCF5E7,stroke:#247247,stroke-width:2px,color:#173C2A
    classDef etiqueta fill:#FFF2CC,stroke:#7A5900,stroke-width:2px,color:#302300
    classDef salida fill:#EAE1FF,stroke:#6941A5,stroke-width:2px,color:#30204F

    class Codigo codigo
    class Obs obs
    class Baja,Alta etiqueta
    class Metrica,Span,Backend salida

El archivo Mermaid de la observación permite reutilizarlo. Las etiquetas de baja cardinalidad se incorporan a métricas y trazas. Las de alta cardinalidad solo se añaden a las trazas, pero en una aplicación clínica también deben excluir datos sensibles. No es necesario crear un span para cada método; empiece por operaciones que ayuden a explicar latencia o fallos. Si necesita únicamente un span, use la API Tracer. Las anotaciones @Observed y @NewSpan requieren habilitar management.observations.annotations.enabled=true y añadir el soporte AspectJ indicado por Spring Boot; la API programática anterior no requiere esa configuración. Fuentes: observabilidad, spans personalizados.

Para @Async con el ejecutor autoconfigurado, Spring Boot 4.1 ofrece spring.task.execution.propagate-context=true. Si se crea un ejecutor propio, configure ContextPropagatingTaskDecorator. Sin propagación, los logs y spans de la tarea pueden perder el contexto de la solicitud. Fuente: propagación entre hilos.

Anotaciones de observación

Micrometer admite @Observed, @Timed, @Counted, @MeterTag y @NewSpan. No funcionan por defecto: hay que poner management.observations.annotations.enabled: true y añadir org.springframework.boot:spring-boot-starter-aspectj, que trae aspectjweaver. Ninguna de las dos cosas está en este proyecto, por decisión de ADR-0007.

La razón es concreta: anotar un método que ya está instrumentado —un controlador MVC, un repositorio de Spring Data— produce observaciones duplicadas, una de la instrumentación automática y otra de la anotación. Para evitarlo hay que desactivar la automática por propiedades o con un ObservationPredicate, y entonces la anotación deja de ser el atajo que parecía. La instrumentación de este proyecto es programática con ObservationRegistry.

Silenciar y desactivar señales

Instrumentar de más también es un problema: cuesta dinero en el backend y entierra lo que importa. Hay cuatro niveles, de más fino a más grueso:

Mecanismo Alcance
management.observations.enable.<prefijo>: false Impide las observaciones cuyo nombre empieza por ese prefijo.
Bean ObservationPredicate Control programático: una observación se reporta solo si todos los predicados devuelven true.
management.metrics.enable.<prefijo>: false Filtra medidores por prefijo de identificador.
management.opentelemetry.enabled: false Desactiva el SDK completo: métricas, trazas y logs pasan a implementaciones no-op. Equivale a OTEL_SDK_DISABLED invertido. Los propagadores de contexto siguen funcionando.

Un caso habitual: management.observations.enable.spring.security: false corta las observaciones que genera Spring Security, que con muestreo al 100 % añaden bastante ruido a cada traza. Este proyecto todavía no las desactiva, pero cuando llegue la autorización por recurso de M8 conviene revisar si aportan o estorban.

Ojo con el nivel más grueso: Spring Boot no usa la parte de métricas de OpenTelemetry —las exporta Micrometer—, así que desactivar OpenTelemetry no apaga necesariamente las métricas.

Baggage

Baggage es un par clave-valor que viaja con la traza y cruza procesos. Se crea con la API Tracer de Micrometer, se propaga automáticamente con W3C (no con B3), y dos propiedades deciden hasta dónde llega:

Propiedad Efecto
management.tracing.baggage.remote-fields Publica el campo en una cabecera HTTP, de modo que lo reciba el siguiente servicio.
management.tracing.baggage.correlation.fields Copia el campo al MDC, de modo que aparezca en cada línea de log.

En este proyecto no se usa baggage (ADR-0007). Leídas juntas, esas dos propiedades describen el camino más corto para que un dato clínico salga por una cabecera HTTP y termine escrito en los logs de otro sistema. Si alguna vez hace falta propagar algo, se decide en un ADR y no puede ser PII.

Configuración por variables de entorno

Spring Boot 4.1 traduce al arrancar un subconjunto de las variables OTEL_* del SDK a sus propias propiedades. Esto importa para desplegar: un contenedor se configura por entorno sin reescribir application.yaml. La traducción se desactiva con management.opentelemetry.map-environment-variables: false.

Dos reglas de precedencia que conviene tener claras:

  • La variante específica de señal gana sobre la general y se usa tal cual: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT se toma literal.
  • Cuando se usa la general como respaldo, Spring Boot le añade la ruta de la señal: OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 produce http://collector:4318/v1/traces para trazas, y lo equivalente para métricas y logs.

Las más relevantes aquí:

Variable Propiedad
OTEL_SDK_DISABLED management.opentelemetry.enabled (invertida)
OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES Atributos del recurso; las propiedades de resource-attributes ganan
OTEL_EXPORTER_OTLP_ENDPOINT Destino de las tres señales, con /v1/... añadido
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG management.opentelemetry.tracing.sampler y la probabilidad
OTEL_METRIC_EXPORT_INTERVAL management.otlp.metrics.export.step
OTEL_EXPORTER_OTLP_HEADERS Cabeceras del exportador, para backends que exigen autenticación

OTEL_RESOURCE_ATTRIBUTES es una lista clave=valor separada por comas, y todo carácter fuera del rango permitido va codificado en porcentaje: key3=spring%20boot.

Las demás variables del SDK no están soportadas. Para que valgan todas hay que aportar un bean OpenTelemetry propio con opentelemetry-sdk-extension-autoconfigure, y eso apaga la autoconfiguración de Spring Boot y puede romper la observabilidad integrada. No es el camino de este proyecto. Fuente: soporte de OpenTelemetry en Spring Boot.

Personalización avanzada

Cuando el destino exige algo que las propiedades no cubren —autenticación, TLS con un bundle concreto, cabeceras calculadas— se registran beans que se invocan antes de construir el exportador y tienen precedencia sobre la autoconfiguración:

Señal Beans
Trazas OtlpHttpSpanExporterBuilderCustomizer, OtlpGrpcSpanExporterBuilderCustomizer
Logs OtlpHttpLogRecordExporterBuilderCustomizer, OtlpGrpcLogRecordExporterBuilderCustomizer
Métricas Bean OtlpMetricsSender

Para las etiquetas de las peticiones HTTP, en lugar de tocar el exportador se extiende DefaultServerRequestObservationConvention (añadir etiquetas) o se implementa ServerRequestObservationConvention (reemplazarlas). Es el punto donde se decide qué describe una petición, y por tanto donde hay que releer la regla de cardinalidad antes de añadir nada.

Observabilidad en los tests

Los componentes de trazado que reportan datos no se autoconfiguran con @SpringBootTest. Es comportamiento de Spring Boot, y es la razón por la que la batería de tests no llena el backend ni ensucia la salida con errores de conexión al puerto 4318.

La exportación de logs no es un componente de trazado y no entra en esa exención. Por eso ExportacionLogsOtelTest desactiva explícitamente el exportador OTLP de logs y lo sustituye por un procesador en memoria:

@SpringBootTest(properties = "management.logging.export.otlp.enabled=false")

Sin esa propiedad, el test dependería de que hubiera un backend escuchando. Con ella, comprueba lo que de verdad es responsabilidad de este repositorio: que el log sale de SLF4J y llega al SDK con su contexto de traza.

Comprobación local

  1. Inicie los servicios con make dev y la aplicación con make run (o use make dev-full, que también migra y carga datos de demostración). Los valores de desarrollo de DB_HOST, DB_PORT, DB_NAME, DB_USER y DB_PASS ya están configurados. grafana-lgtm debe estar accesible desde el proceso Java en localhost:4318.
  2. Reinicie la aplicación después de añadir el appender y llame varias veces a GET http://localhost:8080/api/v1/public/test. Compruebe que Test endpoint called aparece en consola con un identificador de traza.
  3. Abra http://localhost:3000 y, en Drilldown → Logs, seleccione la fuente Loki, el servicio lis-app y un intervalo que incluya las peticiones nuevas. Busque Test endpoint called y compruebe que el registro contiene el mismo traceId que la consola. En Traces, busque la misma traza; en Metrics, compruebe la serie HTTP después del siguiente intervalo de exportación (predeterminado 1m).
  4. /actuator/metrics y /actuator/loggers no están expuestos: management.endpoints.web.exposure.include se limita a health,info. Solo health es público; SecurityConfig exige autenticación para los demás endpoints que se expongan en el futuro. Si no aparece una señal en Grafana, revise el puerto 4318, OTLP_ENDPOINT y los errores del exportador en consola.

Resultado comprobado

El 25 de septiembre de 2026 se reinició la aplicación con el appender OTLP y se invocó GET /api/v1/public/test. El mensaje Test endpoint called apareció tanto en la consola como en Grafana → Drilldown → Logs (Loki), con el mismo identificador de traza y de span. En Loki estos campos se muestran como trace_id y span_id; en la consola forman el bloque [traceId-spanId]. Para repetir la búsqueda en Explore con la fuente Loki:

{service_name="lis-app"} |= "Test endpoint called"

Después de cambiar pom.xml, logback-spring.xml o la inicialización del appender hay que reiniciar el proceso Java. Una instancia que siga ejecutando la versión anterior continuará escribiendo solo en consola; los mensajes anteriores al reinicio no se envían retroactivamente a Loki.

Las trazas técnicas y los logs ayudan a diagnosticar la aplicación, pero no sustituyen la auditoría funcional de cambios en resultados, validaciones o informes clínicos.

Enfoques para otros proyectos Spring Boot

Contexto Punto de partida
Spring Boot 4.x, como este repositorio org.springframework.boot:spring-boot-starter-opentelemetry y propiedades management.* para OTLP.
Spring Boot 3.x con integración Micrometer Actuator, micrometer-tracing-bridge-otel y el exportador OTLP, según la versión; no copiar las propiedades de Boot 4 sin verificarlas.
Aplicación JVM que requiere instrumentación amplia sin cambiar código Agente Java de OpenTelemetry, configurado con variables como OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT y OTEL_METRIC_EXPORT_INTERVAL, y con validación de compatibilidad.

El siguiente árbol resume esa decisión y los dos añadidos que el starter no trae:

flowchart TD
    Inicio(["¿Qué instrumentación uso?"]) --> Boot{"¿Versión de<br/>Spring Boot?"}

    Boot -->|"4.x"| Cambio{"¿Puedo tocar<br/>el código y el pom?"}
    Boot -->|"3.x"| Tres["Actuator +<br/>micrometer-tracing-bridge-otel<br/>+ exportador OTLP"]

    Cambio -->|"Sí · caso de LIS"| Starter["spring-boot-starter-opentelemetry<br/>propiedades management.*"]
    Cambio -->|"No"| Agente["Agente Java de OpenTelemetry<br/>variables OTEL_*"]

    Starter --> Logs{"¿Necesito los logs<br/>en el backend?"}
    Logs -->|"Sí"| Appender["Añadir appender de Logback<br/>e instalarlo con el bean OpenTelemetry"]
    Logs -->|"No"| Listo(["Métricas y trazas ya salen por OTLP"])

    Starter --> SQL{"¿Necesito spans<br/>de consultas SQL?"}
    SQL -->|"Sí"| Jdbc["datasource-micrometer-spring-boot<br/>vigilar spans duplicados"]
    SQL -->|"No"| Listo

    Agente --> Aviso["No combinar con el starter:<br/>duplica señales"]
    Tres --> Aviso2["No copiar propiedades de Boot 4<br/>sin verificarlas"]

    classDef pregunta fill:#FFF2CC,stroke:#7A5900,stroke-width:2px,color:#302300
    classDef opcion fill:#D9ECFF,stroke:#165A91,stroke-width:2px,color:#102D46
    classDef extra fill:#DCF5E7,stroke:#247247,stroke-width:2px,color:#173C2A
    classDef aviso fill:#FFE3E3,stroke:#C92A2A,stroke-width:2px,color:#5C0000
    classDef fin fill:#EAE1FF,stroke:#6941A5,stroke-width:2px,color:#30204F

    class Boot,Cambio,Logs,SQL pregunta
    class Starter,Tres,Agente opcion
    class Appender,Jdbc extra
    class Aviso,Aviso2 aviso
    class Inicio,Listo fin

También está el archivo Mermaid de la decisión.

El starter oficial de Spring Boot 4, el starter mantenido por la comunidad OpenTelemetry y el agente Java son caminos distintos. Al combinar instrumentaciones se pueden duplicar señales; elija una estrategia principal y compruebe qué produce cada componente. Para comparar enfoques, consulte el artículo de Spring, la guía de Baeldung para Boot 3, la guía de SigNoz sobre el agente, la explicación de Dan Vega para Boot 4 y la comparación de Uptrace. Para propiedades concretas de este proyecto, prevalece la referencia oficial de Spring Boot.