Observabilidad desde el código: guía práctica con OpenTelemetry
Logs estructurados con trace_id, contexto propagado entre servicios y colas, spans y métricas de negocio: cómo instrumentar tu código con OpenTelemetry y qué evitar.
La autoinstrumentación de una plataforma APM resuelve mucho: solicitudes HTTP, consultas a bases de datos, dependencias entre servicios. Pero deja fuera lo que solo tu código conoce: qué operación de negocio se estaba ejecutando, qué decisión se tomó y por qué un resultado técnicamente exitoso fue un fallo para el cliente. Esta guía muestra, con ejemplos mínimos en Python, cómo cubrir ese hueco con OpenTelemetry.
Los ejemplos usan la API de OpenTelemetry para Python. Los conceptos son los mismos en Java, .NET, Go o Node.js, porque la especificación es común a todos los lenguajes.
Antes del código: API, SDK y autoinstrumentación
OpenTelemetry separa dos piezas que conviene distinguir desde el principio:
- La API (paquete
opentelemetry-api) es lo que usa tu código para crear spans, registrar métricas y leer el contexto. Es estable y liviana. Si no hay un SDK configurado, sus llamadas no hacen nada, lo que permite instrumentar bibliotecas sin imponer una dependencia operativa. - El SDK (paquete
opentelemetry-sdk) decide qué se hace con esa telemetría: muestreo, procesamiento y exportación, normalmente por el protocolo OTLP hacia un OpenTelemetry Collector o directamente hacia tu plataforma de análisis.
Esta separación tiene una consecuencia práctica: el código de negocio solo depende de la API, y la configuración del destino (qué plataforma, qué endpoint, qué muestreo) se resuelve al arrancar la aplicación, a menudo con variables de entorno estándar como OTEL_SERVICE_NAME y OTEL_EXPORTER_OTLP_ENDPOINT. Para Python existe además una autoinstrumentación (el comando opentelemetry-instrument) que cubre frameworks y clientes conocidos. Lo que sigue es lo que la autoinstrumentación no puede hacer por ti.
Logs estructurados que llevan la traza
El primer paso, y el de mejor relación entre esfuerzo y beneficio, es que cada línea de log sea JSON y lleve el trace_id y el span_id activos. Con eso, desde cualquier traza lenta o con error se llega a los logs exactos de esa solicitud, y desde un log se llega a la traza completa.
import json
import logging
from opentelemetry import trace
class JsonFormatter(logging.Formatter):
"""Emite cada registro como una línea JSON con el contexto de traza activo."""
def format(self, record):
ctx = trace.get_current_span().get_span_context()
entry = {
"timestamp": self.formatTime(record),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
# Mismo formato hexadecimal que usa W3C Trace Context
"trace_id": format(ctx.trace_id, "032x") if ctx.is_valid else None,
"span_id": format(ctx.span_id, "016x") if ctx.is_valid else None,
}
# Campos de negocio pasados con extra={"fields": {...}}
entry.update(getattr(record, "fields", {}))
return json.dumps(entry, ensure_ascii=False)
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger = logging.getLogger("pagos")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.info("Pago autorizado", extra={"fields": {"operacion": "pago", "canal": "web"}})
Existen integraciones oficiales que inyectan estos identificadores en el módulo logging de forma automática, y muchas plataformas hacen lo mismo con sus agentes. El ejemplo muestra el mecanismo para que sepas qué verificar: si tus logs no tienen trace_id, la correlación no existe, por buena que sea la herramienta.
Dos reglas complementan el formato. Los campos deben ser estables (los mismos nombres en todos los servicios) y el mensaje debe describir el evento, no concatenar datos. "Pago autorizado" con campos aparte se filtra y se agrega; "Pago 123 autorizado para cliente X por 500" no.
Spans propios y atributos de negocio
La autoinstrumentación crea spans para la llamada HTTP y la consulta SQL. Los spans propios marcan los pasos que el negocio entiende y llevan los atributos que explican la decisión. También son el lugar para registrar los errores de dominio: resultados que no lanzan ninguna excepción, pero que son un fallo.
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer("pagos")
def autorizar_pago(pago, regla):
with tracer.start_as_current_span("autorizar_pago") as span:
# Atributos de negocio no sensibles: nunca cuentas, tarjetas ni nombres
span.set_attribute("pago.canal", pago["canal"])
span.set_attribute("pago.tipo", pago["tipo"])
if regla is None:
# Error de dominio: nada falló técnicamente, pero el flujo no puede decidir
span.set_attribute("pago.resultado", "retenido")
span.set_status(Status(StatusCode.ERROR, "regla_no_encontrada"))
return "retenido"
span.set_attribute("pago.regla", regla)
if pago["monto"] > pago["saldo_disponible"]:
# Rechazo esperado: resultado de negocio válido, no un error del sistema
span.set_attribute("pago.resultado", "rechazado")
span.set_attribute("pago.motivo", "saldo_insuficiente")
return "rechazado"
span.add_event("pago_autorizado")
span.set_attribute("pago.resultado", "autorizado")
return "autorizado"
El ejemplo distingue dos casos que conviene separar en cualquier dominio. Un rechazo por saldo insuficiente es un resultado legítimo: se registra como atributo, no como error, para no contaminar la tasa de errores. Una regla de autorización que no se encuentra es un fallo del sistema, aunque ninguna línea lance una excepción: se marca con estado de error para que aparezca en alertas y análisis de causa raíz. Decidir qué resultados son errores es una decisión de diseño que debe tomarse con el dueño del proceso, no dejarse al criterio de cada desarrollador.
Propagación de contexto entre servicios y colas
Una traza solo es útil si no se corta. Entre servicios HTTP, la autoinstrumentación suele propagar el contexto con la cabecera traceparent del estándar W3C Trace Context. Donde la propagación falla con más frecuencia es en las colas de mensajes, los procesos por lotes y las integraciones propias. Ahí el código debe inyectar el contexto al publicar y extraerlo al consumir.
from opentelemetry import trace
from opentelemetry.propagate import extract, inject
from opentelemetry.trace import SpanKind
tracer = trace.get_tracer("pagos")
cola = [] # en producción, el cliente de tu broker de mensajes
def publicar(cuerpo):
with tracer.start_as_current_span("publish pagos", kind=SpanKind.PRODUCER):
cabeceras = {}
inject(cabeceras) # agrega traceparent (y tracestate si existe)
cola.append({"cuerpo": cuerpo, "cabeceras": cabeceras})
def consumir():
mensaje = cola.pop(0)
ctx = extract(mensaje["cabeceras"]) # recupera el contexto del productor
with tracer.start_as_current_span("process pagos", context=ctx, kind=SpanKind.CONSUMER):
return mensaje["cuerpo"]
Con el contexto viajando en los metadatos del mensaje, el span del consumidor queda dentro de la misma traza que el productor, aunque el procesamiento ocurra minutos después y en otro servicio. Para procesos por lotes que agrupan muchos mensajes, OpenTelemetry ofrece los links entre spans, que relacionan un span con varias trazas de origen sin forzar una única jerarquía.
Métricas de negocio con baja cardinalidad
Los spans explican solicitudes individuales; las métricas muestran el comportamiento agregado y alimentan las alertas. Una métrica de negocio bien diseñada responde preguntas como "¿cuántos pagos quedaron en cada estado en los últimos cinco minutos?" sin consultar trazas.
from opentelemetry import metrics
meter = metrics.get_meter("pagos")
pagos_procesados = meter.create_counter(
"pagos.procesados",
unit="{pago}",
description="Pagos procesados por resultado final",
)
duracion_autorizacion = meter.create_histogram(
"pagos.autorizacion.duracion",
unit="s",
description="Tiempo de autorización de pagos",
)
def registrar(resultado, canal, segundos):
# Atributos de baja cardinalidad: pocos valores, conocidos de antemano
atributos = {"pago.resultado": resultado, "pago.canal": canal}
pagos_procesados.add(1, atributos)
duracion_autorizacion.record(segundos, atributos)
Los atributos de la métrica son la clave del diseño. Resultado y canal tienen pocos valores posibles, así que generan pocas series temporales. Si agregaras el identificador del cliente o del pago, cada valor nuevo crearía una serie nueva, con un costo que crece sin límite y consultas cada vez más lentas. Los identificadores únicos van en los spans y en los logs; las métricas se quedan con dimensiones acotadas.
Convenciones semánticas y qué evitar
OpenTelemetry define convenciones semánticas: nombres estándar para atributos frecuentes, como service.name para el recurso, http.request.method y http.response.status_code para HTTP, o messaging.destination.name para mensajería. Usarlas permite que cualquier plataforma interprete tus datos sin configuración adicional, y que los paneles funcionen igual entre equipos. Para atributos propios, define un prefijo de dominio (como pago. en los ejemplos) y documenta los nombres en un catálogo compartido. Ten en cuenta que algunas convenciones aún evolucionan entre versiones, así que conviene revisar su estado de estabilidad en la documentación oficial antes de fijarlas en un estándar interno.
Antes de dar por terminada la instrumentación de un servicio, recomendamos revisar esta lista:
- Sin datos sensibles en la telemetría: nada de números de cuenta o tarjeta, documentos de identidad, contraseñas, tokens ni datos de salud en atributos, eventos o logs. La telemetría se replica, se retiene y la consultan muchas personas.
- Sin alta cardinalidad en métricas: identificadores de cliente, transacción, sesión o URL completas con parámetros nunca como atributos de métricas.
- Nombres de span estables: el nombre describe la operación ("autorizar_pago"), no lleva identificadores ("autorizar_pago_123"), porque eso fragmenta los análisis.
- Errores de dominio marcados: los fallos de negocio relevantes llevan estado de error; los rechazos esperados, solo atributos.
- Contexto propagado en todos los saltos: HTTP, colas, procesos asíncronos y lotes.
- Atributos de recurso completos: nombre del servicio, versión y entorno, para poder comparar antes y después de un despliegue.
Cómo verificar que la instrumentación sirve
Que el código emita telemetría no significa que la telemetría responda las preguntas de un incidente. La forma más directa de comprobarlo es ensayar un diagnóstico antes de necesitarlo. Recomendamos esta prueba, en un entorno de preproducción y con datos sintéticos:
- Provoca un fallo de dominio conocido: por ejemplo, desactiva una regla de autorización de prueba para que los pagos queden retenidos.
- Parte de la alerta o del panel: verifica que la métrica por resultado muestra el cambio sin que nadie tenga que buscarlo.
- Salta a una traza de ejemplo: confirma que la traza cruza todos los servicios y colas del flujo sin cortes, y que el span propio tiene el estado de error y el atributo correcto.
- Salta de la traza a los logs: filtra por trace_id y confirma que aparecen las líneas de todos los servicios involucrados, en JSON y sin datos sensibles.
- Mide el tiempo: anota cuánto tardó una persona que no escribió el código en llegar a la causa. Ese tiempo es el mejor indicador de la calidad de la instrumentación.
Si algún paso falla, ahí está la siguiente tarea de instrumentación. Repetir este ensayo cuando cambia un flujo crítico evita que la observabilidad se degrade con el tiempo sin que nadie lo note.
Instrumentar bien no es instrumentar todo. Es emitir, con nombres estables y sin datos sensibles, la información que el equipo necesitará para explicar un incidente. Empieza por un flujo crítico, verifica que puedes ir de una alerta a la traza y de la traza al log, y extiende el patrón a partir de ahí.
Fuentes
- Documentación de OpenTelemetry: https://opentelemetry.io/docs/
- OpenTelemetry, convenciones semánticas: https://opentelemetry.io/docs/specs/semconv/
- W3C, Trace Context (recomendación): https://www.w3.org/TR/trace-context/
- OWASP, Logging Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
¿Quieres aplicar esto en tu operación?
Solicitar diagnóstico