Desentrañando el laberinto de la observabilidad (II): Pipelines prácticos y exportación OTLP hacia backends Enterprise

En un hilo de la comunidad de Dynatrace de febrero de 2023, un usuario llevaba ya un buen rato encallado intentando conectar su OpenTelemetry Collector contra el endpoint de ingesta de Dynatrace usando gRPC — el transporte «por defecto» de OTLP según la propia especificación, el que cualquier tutorial genérico da por hecho que va a funcionar. Probó distintos puertos, distintas rutas, distintas combinaciones del endpoint. Nada. La respuesta que finalmente zanjó el hilo, de un ingeniero de Dynatrace identificado como alexander_dt, no dejaba lugar a dudas: «gRPC is currently not yet supported. Only export format OTLP/HTTP binary Protobuf from OpenTelemetry is supported.»

No era un error de configuración del usuario. Era —y, a fecha de hoy, sigue siendo la limitación documentada oficialmente por Dynatrace— una restricción real y concreta del backend: solo acepta OTLP sobre HTTP, con el cuerpo en Protobuf binario, nunca gRPC ni JSON. Es exactamente el tipo de matiz que ninguna guía genérica sobre «cómo exportar por OTLP» menciona, porque la mayoría de esas guías asumen, implícitamente, que todos los backends del mercado hablan los dos transportes por igual. No es así, y descubrirlo a las malas en mitad de un despliegue es bastante más caro que leerlo aquí primero.

En la Parte I de esta serie vimos qué es OTLP, por qué Grafana Alloy es una distribución de OpenTelemetry Collector y cómo confluyen dentro de un pipeline los datos que llegan por push (aplicaciones instrumentadas) y por pull (scraping de métricas). Esta segunda entrega es la parte de verdad práctica: cómo se construye, bloque a bloque, un pipeline que recoge telemetría de un clúster de Kubernetes y la exporta de forma segura y controlada hacia un backend externo, usando Dynatrace como caso de estudio concreto pero con lecciones que aplican prácticamente a cualquier endpoint OTLP estándar.


Anatomía de un pipeline completo: receiver → processor → exporter

Retomando el ejemplo mínimo con el que cerrábamos la Parte I —un receptor OTLP conectado directamente a un exportador de depuración—, un pipeline de producción real añade siempre, como mínimo, una etapa de procesamiento entre medias. La estructura conceptual, en los tres bloques que ya conocemos, queda así:

 PIPELINE DE PRODUCCIÓN (esqueleto conceptual)
 ═══════════════════════════════════════════════

  Apps instrumentadas          Scraping (métricas
  (push OTLP gRPC/HTTP)        de sistema/servicios)
        │                             │
        ▼                             ▼
 ┌─────────────────┐         ┌──────────────────────┐
 │ otelcol.receiver │         │ discovery.kubernetes  │
 │      .otlp        │        │  + prometheus.scrape  │
 └────────┬─────────┘         └───────────┬───────────┘
          │                               │
          └───────────────┬───────────────┘
                           ▼
              ┌────────────────────────┐
              │  otelcol.processor.*    │
              │  (batch, transform,     │
              │   filter...)            │
              └────────────┬────────────┘
                           ▼
              ┌────────────────────────┐
              │  otelcol.exporter.      │
              │      otlphttp           │
              │  (hacia el backend      │
              │   externo, con auth)    │
              └─────────────────────────┘

Vamos a construir esto pieza a pieza, con sintaxis real de Alloy, empezando por el receptor y terminando por la exportación autenticada hacia Dynatrace.

El receptor: la puerta de entrada

Ya lo vimos en la Parte I, pero aquí lo dejamos con un nombre de etiqueta más propio de producción:

otelcol.receiver.otlp "apps" {
  grpc {
    endpoint = "0.0.0.0:4317"
  }
  http {
    endpoint = "0.0.0.0:4318"
  }

  output {
    metrics = [otelcol.processor.batch.principal.input]
    logs    = [otelcol.processor.batch.principal.input]
    traces  = [otelcol.processor.batch.principal.input]
  }
}

Nótese que el output de este receptor ya no apunta a un exportador de depuración, sino al input de un procesador — el siguiente eslabón de la cadena.

El procesador batch: casi nunca deberías saltártelo

otelcol.processor.batch es, con diferencia, el procesador más universal de todo el ecosistema OpenTelemetry, hasta el punto de que su ausencia en un pipeline de producción es casi siempre un error, no una elección deliberada. Su función es sencilla mecánicamente pero importante en la práctica: en vez de enviar cada métrica, log o span individual al exportador en cuanto llega —lo que dispararía el número de peticiones HTTP/gRPC hacia el backend hasta niveles absurdos—, agrupa los datos en lotes por tamaño o por tiempo de espera, y los envía de una vez.

otelcol.processor.batch "principal" {
  timeout         = "5s"
  send_batch_size = 8192

  output {
    metrics = [otelcol.exporter.otlphttp.dynatrace.input]
    logs    = [otelcol.exporter.otlphttp.dynatrace.input]
    traces  = [otelcol.exporter.otlphttp.dynatrace.input]
  }
}

Con timeout = "5s", el procesador envía un lote como muy tarde cada cinco segundos aunque no se haya llenado; con send_batch_size = 8192, envía antes si acumula ocho mil ciento noventa y dos elementos. Es, en la práctica, el ajuste que decide el equilibrio entre latencia de ingesta (cuánto tarda un dato en aparecer en tu backend desde que se genera) y eficiencia de red.

Exportar hacia Dynatrace por OTLP/HTTP: la configuración que sí funciona

Recordando la lección del hilo de foro que abría este artículo: hacia Dynatrace, exclusivamente OTLP sobre HTTP, nunca gRPC. La documentación oficial de Dynatrace especifica endpoints concretos según cómo accedas a tu entorno — para un entorno SaaS, la URL base tiene esta forma:

https://{tu-id-de-entorno}.live.dynatrace.com/api/v2/otlp

Con rutas específicas por señal añadidas automáticamente por el propio exportador (/v1/traces, /v1/metrics, /v1/logs). El bloque de exportador queda así:

otelcol.exporter.otlphttp "dynatrace" {
  client {
    endpoint = "https://" + sys.env("DT_ENV_ID") + ".live.dynatrace.com/api/v2/otlp"

    headers = {
      "Authorization" = "Api-Token " + sys.env("DT_API_TOKEN"),
    }

    compression = "gzip"
  }
}

Dos detalles que vale la pena no pasar por alto porque son justo los que suelen romper una primera prueba: Dynatrace exige el formato de cabecera exacto Authorization: Api-Token <valor> (no Bearer, como sí esperan muchos otros backends OTLP), y el token necesita los scopes concretos para cada señal que quieras enviar — openTelemetryTrace.ingest para trazas, metrics.ingest para métricas, logs.ingest para logs. Un token sin el scope correcto no da un error evidente de autenticación en todos los casos; a veces simplemente no ingiere esa señal en concreto, lo cual es más difícil de diagnosticar que un 401 directo.

Autenticación sin dejar credenciales tiradas por ahí

La función sys.env(...) que aparece en el ejemplo anterior es la pieza clave para no cometer el error más común y más caro de arreglar después: escribir un token de API directamente en el fichero de configuración, que casi siempre acaba subido a un repositorio Git tarde o temprano. sys.env() lee el valor de una variable de entorno del propio proceso de Alloy en tiempo de evaluación — el token nunca aparece como texto literal en ningún fichero versionado.

En un clúster de Kubernetes, el patrón habitual es guardar el token como un Secret nativo y exponerlo como variable de entorno del contenedor de Alloy en su manifiesto de despliegue:

# Fragmento del Deployment/DaemonSet de Alloy
env:
  - name: DT_API_TOKEN
    valueFrom:
      secretKeyRef:
        name: dynatrace-credenciales
        key: api-token
  - name: DT_ENV_ID
    valueFrom:
      secretKeyRef:
        name: dynatrace-credenciales
        key: environment-id

Alloy también admite autenticación mediante bloques dedicados como otelcol.auth.bearer o otelcol.auth.basic para backends que usan esos esquemas — útil cuando el propio bloque de autenticación necesita reutilizarse entre varios exportadores distintos, en vez de repetir la cabecera manualmente en cada uno:

otelcol.auth.bearer "token_backend" {
  client_auth {
    token = sys.env("BACKEND_TOKEN")
  }
}

otelcol.exporter.otlphttp "otro_backend" {
  client {
    endpoint = "https://otro-backend.example.com"
    auth     = otelcol.auth.bearer.token_backend.handler
  }
}

En ningún caso el token pasa por el propio fichero .alloy: siempre por variable de entorno, y esa variable, en Kubernetes, siempre respaldada por un Secret, nunca por un ConfigMap ni mucho menos por un valor hardcodeado en el manifiesto.

Filtrar antes de exportar: no todo merece salir del clúster

Aquí llega, probablemente, la lección más cara de aprender a las malas en observabilidad de producción: enviar todo sin filtrar es la forma más rápida de disparar la factura de ingesta de cualquier backend de pago, Dynatrace incluido, sin que eso se traduzca en más capacidad real de diagnóstico. Cardinalidad descontrolada en las etiquetas de una métrica (por ejemplo, incluir un pod_id o un request_id único como etiqueta de una métrica agregada, en vez de dejarlo solo en las trazas o los logs, donde sí tiene sentido) puede multiplicar el volumen de series activas por varios órdenes de magnitud sin ningún beneficio de observabilidad a cambio.

otelcol.processor.transform, que usa el lenguaje OTTL (OpenTelemetry Transformation Language), es la herramienta estándar para atajar esto antes de que la telemetría salga del clúster:

otelcol.processor.transform "limpieza" {
  error_mode = "ignore"

  metric_statements {
    context = "datapoint"
    statements = [
      // Conserva solo las etiquetas que de verdad usas para
      // filtrar/agrupar en tus dashboards; descarta el resto
      `limit(attributes, 6, ["service.name", "namespace", "pod"])`,

      // Evita valores de atributo desproporcionadamente largos
      // (por ejemplo, una URL completa con query string) que
      // inflan el coste de almacenamiento sin aportar nada
      `truncate_all(attributes, 256)`,
    ]
  }

  output {
    metrics = [otelcol.exporter.otlphttp.dynatrace.input]
  }
}

Para descartar directamente señales completas que no aportan valor de diagnóstico —por ejemplo, las métricas de health check de Kubernetes que se generan cada pocos segundos en cada Pod, o los logs de nivel DEBUG que solo interesan durante el propio desarrollo—, la vía habitual es un procesador de tipo filtro trabajando también con expresiones OTTL sobre el nombre de la métrica o la severidad del log, colocado antes del batch en la cadena para no gastar ni ancho de banda ni presupuesto de ingesta en algo que va a descartarse igualmente.

La regla de fondo, la que de verdad conviene interiorizar antes de conectar cualquier pipeline a un backend de pago: cada atributo de alta cardinalidad que decides mantener es una decisión consciente sobre cuánto vas a pagar, no un detalle técnico neutro. Revisar qué se está exportando de verdad, con otelcol.exporter.debug apuntando a consola durante la fase de pruebas —el mismo exportador que usábamos en la Parte I antes de tener un backend real configurado—, es un paso que merece la pena no saltarse nunca antes de apuntar por primera vez a producción.

Lecciones aprendidas para el día a día

Después de configurar unos cuantos pipelines de este tipo, hay unas cuantas ideas que conviene tener presentes de forma más o menos permanente:

Verifica la limitación del backend antes de diseñar el pipeline, no después. El caso de Dynatrace y gRPC de la apertura de este artículo no es una excepción rara: cada backend OTLP tiene sus propios límites de tamaño de lote, sus propios formatos de cabecera de autenticación esperados, y a veces sus propias restricciones de transporte. Leer la documentación de ingesta específica del backend elegido antes de escribir la primera línea de configuración ahorra, casi siempre, una tarde entera de depuración a ciegas.

Empieza siempre con otelcol.exporter.debug antes de apuntar a producción. Verificar en consola qué está recibiendo realmente el colector —y qué forma tiene tras pasar por tus procesadores— antes de gastar una sola petición contra un backend de pago es la forma más barata de detectar un error de configuración.

El batch no es opcional, es casi obligatorio. Un pipeline sin otelcol.processor.batch funciona en una demo con tráfico mínimo y se cae de rendimiento —tanto del lado del colector como del lado del backend, que empieza a recibir muchísimas más peticiones de las necesarias— en cuanto el volumen real de producción entra en juego.

La cardinalidad se decide en el pipeline, no se corrige después. Una vez que una métrica de alta cardinalidad ha entrado en un backend de pago durante semanas, revertir el coste acumulado no es tan sencillo como cambiar la configuración; el daño económico ya está hecho. Revisar los atributos de cada métrica antes del primer despliegue a producción es mucho más barato que limpiar después.

Las credenciales viven en variables de entorno respaldadas por Secrets, siempre. Ninguna excepción razonable justifica un token de API escrito en texto plano dentro de un fichero de configuración versionado.

Resumiendo: de la teoría a un pipeline que se sostiene solo

A lo largo de estos dos artículo hemos recorrido el camino completo: por qué existe OTLP como idioma común de la telemetría, por qué Grafana Alloy es una distribución de OpenTelemetry Collector con un modelo de configuración declarativo propio, cómo confluyen los datos que entran por push y por pull, y ahora, cómo se construye de verdad un pipeline de producción — con procesamiento en lotes, filtrado consciente de cardinalidad, y autenticación segura hacia un backend externo real.

Lo que queda, más que una conclusión cerrada, es una recomendación práctica: la próxima vez que un tutorial genérico te diga «simplemente configura el exportador OTLP y ya está», trátalo como el punto de partida que es, no como la respuesta completa. Cada backend tiene sus propios matices de transporte, autenticación y coste, y la única forma fiable de descubrirlos es la que hemos seguido aquí — leyendo la documentación específica del backend, probando primero contra consola, y decidiendo de forma consciente qué telemetría de verdad merece salir del clúster.


Serie: De OTel Collector a Grafana Alloy

  • Parte I: De OpenTelemetry Collector a Grafana Alloy — qué es OTLP, por qué Alloy es una distribución de OTel Collector, y cómo viaja un dato desde un Pod hasta la salida del colector.
  • Parte II (este artículo): anatomía de un pipeline real en Alloy, autenticación segura y exportación OTLP hacia Dynatrace como caso de estudio, filtrado de ruido y buenas prácticas de producción.

Fuentes:

Autor

Deja un comentario

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.