Cuando un sistema distribuido crece a 10, 15, 20 servicios, la pregunta ya no es si algo va a fallar. La pregunta es cuánto tiempo vas a tardar en saber qué fue, dónde, y por qué.
La respuesta habitual es agregar logging. Después agregas métricas. Después agregas traces distribuidas. Y en algún punto tienes tres herramientas distintas que no hablan entre sí, atributos inconsistentes en los logs, y un equipo que cada vez que llega un bug tiene que buscar en cuatro lugares distintos.
Decidí resolver eso construyendo una plataforma de observabilidad propia: cuatro SDKs en cuatro lenguajes, todos sobre OpenTelemetry, todos exportando a Azure Monitor, con comportamiento consistente.
Este post es el registro técnico de cómo lo diseñé.
El problema de la observabilidad inconsistente
En un sistema con servicios en Python, TypeScript, C# y Databricks, cada runtime tiene su ecosistema de logging. Python tiene logging estándar. TypeScript tiene winston, pino, o lo que cada desarrollador prefiera. C# tiene Microsoft.Extensions.Logging. Databricks tiene su propio sistema de notebooks.
El resultado sin estandarización: cada servicio loggea diferente. Cuando un request falla y atraviesa tres servicios, buscar el rastro completo requiere conocer las convenciones de cada uno, buscar en sistemas separados, y correlacionar manualmente.
OpenTelemetry resuelve el problema de estandarización: un modelo de datos unificado (traces, metrics, logs) con SDKs oficiales en todos los lenguajes principales. El exporter determina adónde van los datos — en este caso, Azure Monitor / Application Insights.
Arquitectura de la plataforma
graph TD
subgraph "Servicios instrumentados"
PY["Servicio Python\n(FastAPI, workers)"]
TS["Servicio TypeScript\n(Node.js, Express)"]
CS["Servicio C#\n(.NET Core API)"]
DB["Databricks\n(notebooks, jobs)"]
end
subgraph "SDK por lenguaje"
SDK_PY["sdk-python\n(pypi feed)"]
SDK_TS["sdk-typescript\n(npm feed)"]
SDK_CS["sdk-csharp\n(nuget feed)"]
SDK_DB["sdk-databricks\n(wheel)"]
end
subgraph "OTel + Export"
OTel["OpenTelemetry\nSDK oficial"]
EXP["Azure Monitor\nExporter"]
end
subgraph "Destino"
AI["Application Insights\n(logs + traces + metrics)"]
WB["Azure Workbooks\n(dashboards operacionales)"]
end
PY --> SDK_PY
TS --> SDK_TS
CS --> SDK_CS
DB --> SDK_DB
SDK_PY --> OTel
SDK_TS --> OTel
SDK_CS --> OTel
SDK_DB --> OTel
OTel --> EXP
EXP --> AI
AI --> WB
Sin collector en producción (ADR-0003). Los SDKs exportan directamente a Azure Monitor. La simplificación operacional de eliminar el collector justifica el acoplamiento al exporter — si el día de mañana migro de Azure Monitor, cambio el exporter en el SDK, no en cada servicio.
ADR-0009: El SDK no puede crashear la app
Esta fue la decisión de diseño más importante del proyecto. Y la documenté como ADR porque no es obvia.
Un SDK de observabilidad es una dependencia transversal. Si el SDK falla — por un error de configuración, por un problema de red con el exporter, por cualquier razón — no puede matar el proceso que lo consume. El propósito del SDK es reportar fallos; si falla, debe hacerlo en silencio.
Implementación en Python:
class ObservabilitySDK:
def __init__(self, service_name: str, connection_string: str | None = None):
try:
self._tracer_provider = self._setup_tracer_provider(
service_name, connection_string
)
self._meter_provider = self._setup_meter_provider(
service_name, connection_string
)
self._configured = True
except Exception:
# SDK falla silencioso — nunca propaga excepción al caller
self._configured = False
logger.warning(
"observability.sdk.init_failed",
exc_info=True
)
def get_tracer(self, name: str):
if not self._configured:
return trace.NoOpTracer()
return self._tracer_provider.get_tracer(name)
El NoOpTracer de OpenTelemetry implementa la misma interfaz que un tracer real pero no hace nada. El servicio que usa el SDK no sabe si la observabilidad está activa o no — su código no cambia.
En C# el patrón es el mismo con el NullLogger de Microsoft.Extensions.Logging.
ADR-0011: Sampling head-based al 10%
Sin sampling, en un servicio con tráfico alto cada request genera una traza completa. A escala, eso es costoso — Application Insights cobra por volumen de datos ingestados.
Head-based sampling al 10% significa que la decisión de samplear se toma al inicio del trace, no al final. El 90% de los traces se descartan antes de ser enviados. El 10% que pasa incluye el trace completo, no fragmentos.
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased
sampler = TraceIdRatioBased(0.1) # 10% de traces
tracer_provider = TracerProvider(
sampler=sampler,
resource=Resource.create({"service.name": service_name})
)
El trade-off: pierdes visibilidad del 90% del tráfico normal. Lo que siempre captura son los traces que comienzan en el 10% seleccionado, más cualquier trace marcado como prioritario (errors, slow requests) — para eso uso ParentBasedSampler combinado con TraceIdRatioBased.
Atributos canónicos — el problema real
Puedes tener OpenTelemetry en todos tus servicios y aun así no poder correlacionar logs entre ellos si cada uno llama diferente al mismo atributo. Un servicio logea user_id, otro logea userId, otro uid. En Kusto Query Language (KQL) terminás haciendo tres queries distintas para el mismo concepto.
La solución fue definir un spec de atributos canónicos compartido entre los cuatro SDKs:
# Atributos estándar — mismo nombre en Python, TS, C# y Databricks
ATTRS = {
"intento": "operation.attempt", # número de intento en retry
"servicio_origen": "source.service", # servicio que origina el evento
"correlacion_id": "correlation.id", # ID cross-service
}
Y el refactor obligatorio: standardize log attrs, exc= API, intento canonical — un pass por todos los servicios que usaban el SDK para migrar a los atributos canónicos. Doloroso una sola vez, invaluable después.
El parámetro exc= (en lugar de exc_info=True) fue una decisión de API inspirada en el estándar de Python logging pero adaptada para OpenTelemetry — permite pasar la excepción explícitamente y capturar el stacktrace estructurado como atributo del span, no solo como texto plano en el log.
Publicación en feed privado
Los cuatro SDKs se distribuyen como paquetes privados:
- Python → PyPI feed (Azure Artifacts)
- TypeScript → npm feed (Azure Artifacts)
- C# → NuGet feed (Azure Artifacts)
- Databricks → wheel distribuido vía storage
El workflow de release en GitHub Actions publica los cuatro en una sola ejecución. La regla de idempotencia (Makefile publish-all): si la versión ya está publicada en el feed, el paso hace skip — no falla, no repite. Eso permite re-ejecutar el workflow sin consecuencias.
py-publish:
@VERSION=$$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") && \
pip index versions $(PACKAGE_NAME) --index-url $(FEED_URL) 2>/dev/null | grep -q "$$VERSION" \
&& echo "Version $$VERSION already published, skipping" \
|| pip upload --repository $(FEED_URL) dist/*
Pin explícito del exporter
Una lección cara: el exporter de Azure Monitor para OpenTelemetry tuvo breaking changes entre versiones durante el período de release candidate. Pinear la versión del exporter en el SDK (no dejar >=) evita que una actualización automática rompa la instrumentación de todos los servicios a la vez.
# pyproject.toml del SDK
[project]
dependencies = [
"opentelemetry-sdk>=1.20,<1.42",
"azure-monitor-opentelemetry-exporter==1.0.0b21", # pin explícito
]
El constraint superior en opentelemetry-sdk previene que la API de OTel cambie bajo el SDK sin una actualización explícita.
Lo que aprendí
La observabilidad es infraestructura, no feature. Igual que no deployas sin CI/CD, no deberías desplegar un servicio sin instrumentación básica. El SDK tiene que ser tan fácil de agregar como una línea de configuración.
Atributos canónicos son no-negociables a escala. El costo de definirlos tarde es un refactor en todos los servicios. El costo de definirlos temprano es una tarde de diseño y documentación.
El collector opcional simplifica las operaciones. La arquitectura sin collector (export directo) tiene menos componentes que operar y monitorear. Para la mayoría de los casos, es la opción correcta.
14 ADRs no son burocracia. Son las respuestas a “por qué hiciste esto así” que de otra forma tendrías que responder de viva voz cada vez que alguien nuevo entra al proyecto.