Observabilidad: logs, Prometheus y OpenTelemetry

Por: Artiko
bifrostobservabilidadprometheusopentelemetrymetricas

Observabilidad: logs, Prometheus y OpenTelemetry

En el capítulo anterior montamos el MCP Gateway para darles herramientas a tus agentes. Ya tienes un gateway que enruta a múltiples proveedores, aplica retries y fallbacks, cachea respuestas con semantic caching y controla el gasto con governance. Pero hay una pregunta que sigue sin respuesta: ¿qué está pasando realmente dentro de Bifrost en cada request?

Sin observabilidad, un gateway de IA es una caja negra carísima. No sabes cuánto te cuesta cada modelo, no detectas cuándo un proveedor empieza a degradarse, no sabes si tus fallbacks se están activando demasiado seguido y no puedes responder a un incidente sin adivinar. Este capítulo te enseña las tres capas de observabilidad que Bifrost trae de fábrica:

  1. Logs en tiempo real desde la UI: el detalle de cada request (latencia, tokens, costo, proveedor, reintentos).
  2. Métricas Prometheus: contadores e histogramas agregados para dashboards y alertas.
  3. Trazas OpenTelemetry (OTLP): spans distribuidos que viajan a tu collector y de ahí a Grafana, Datadog o Langfuse.

Y como cereza, veremos la integración con Maxim AI, la plataforma de observabilidad y evals de la que nació Bifrost. Lo importante es que estas tres capas no compiten: se complementan. Los logs son para inspeccionar requests individuales, las métricas para tendencias y alertas, y las trazas para entender el flujo completo de una operación a través de tu sistema distribuido.

flowchart TB
    App[Tu aplicacion] -->|request| Bifrost[Bifrost Gateway]
    Bifrost -->|inferencia| Providers[Proveedores LLM]

    Bifrost -.->|logging plugin| Logs[(logs_store<br/>SQLite / PostgreSQL)]
    Logs --> UI[Web UI: logs en tiempo real<br/>WebSocket ws://.../ws]

    Bifrost -.->|telemetry plugin| Metrics["/metrics<br/>formato Prometheus"]
    Metrics --> Prom[Prometheus]
    Prom --> Grafana[Grafana / Alertmanager]

    Bifrost -.->|otel plugin| OTLP[OTel Collector<br/>OTLP http:4318 / grpc:4317]
    OTLP --> Backends[Grafana Cloud / Datadog / Langfuse]

    Bifrost -.->|maxim plugin| Maxim[Maxim AI<br/>observabilidad + evals]

Logs integrados: la UI en tiempo real

Bifrost incluye un logging plugin que captura información detallada de cada request y respuesta. Lo más importante para producción: opera de forma asíncrona, con menos de 0.1ms de overhead y sin impacto en la latencia de tus requests. Es decir, puedes dejarlo encendido sin pagar el precio que normalmente asocias al logging síncrono.

Qué se captura por request

El logging plugin no guarda solo “llegó un request”. Captura el ciclo de vida completo:

Cómo activarlo

Tienes tres formas de encender los logs. Desde la Web UI, ve a http://localhost:8080 -> Settings -> activa el toggle “Enable Logs”.

Por API:

curl -X PUT 'http://localhost:8080/api/config' \
  -H 'Content-Type: application/json' \
  -d '{"client_config": {"enable_logging": true}}'

O directamente en config.json:

{
  "client": {
    "enable_logging": true,
    "disable_content_logging": false,
    "drop_excess_requests": false,
    "initial_pool_size": 300
  },
  "logs_store": {
    "enabled": true,
    "type": "sqlite",
    "config": { "path": "./logs.db" }
  }
}

Un detalle clave de privacidad: disable_content_logging. Si lo pones en true, Bifrost sigue registrando metadata (latencia, tokens, costo, proveedor, reintentos) pero no almacena el contenido de los mensajes. En sectores regulados o con datos sensibles, esta es la configuración que quieres en producción.

Consultar y filtrar logs

Para inspeccionar logs ya almacenados, el endpoint de consulta acepta filtros por proveedor, modelo, estado, rango de tiempo, latencia, tokens, costo y búsqueda de contenido:

GET http://localhost:8080/api/logs?providers=openai&models=gpt-4o-mini&status=success

Streaming en vivo vía WebSocket

La “UI en tiempo real” no es magia: detrás hay un WebSocket que emite cada log a medida que ocurre. Puedes conectarte tú mismo para construir un dashboard propio o un tail en consola:

const ws = new WebSocket('ws://localhost:8080/ws')
ws.onmessage = (event) => {
  const logUpdate = JSON.parse(event.data)
  console.log(logUpdate)
}

Esto es lo que alimenta la vista de logs en vivo de la UI: ves aparecer cada request con su latencia, tokens, costo, proveedor usado y si hubo fallbacks, sin recargar la página.

Almacenamiento: SQLite vs PostgreSQL

El logs_store soporta dos backends, y la elección importa para producción:

BackendCaracterísticaCuándo usarlo
sqliteSingle-writer, solo filesystem localDesarrollo, instancias únicas
postgresExcelente para escrituras concurrentes y consultas complejasProducción, múltiples instancias

Si vas a correr varias instancias de Bifrost detrás de un balanceador, SQLite se queda corto porque es single-writer: usa PostgreSQL.

Logging headers: metadata personalizada

Puedes inyectar metadata propia en cada log de dos maneras. La primera es definir headers en el array logging_headers de la configuración. La segunda, más cómoda, es el prefijo automático x-bf-lh-*: cualquier header con ese prefijo se captura automáticamente y se le quita el prefijo al guardarlo. Por ejemplo:

curl -X POST http://localhost:8080/v1/chat/completions \
  -H "x-bf-lh-tenant-id: acme" \
  -H "Content-Type: application/json" \
  -d '{ "model": "openai/gpt-4o-mini", "messages": [{"role":"user","content":"hola"}] }'

El header x-bf-lh-tenant-id: acme se convierte en la metadata {"tenant-id": "acme"} en el log. Así puedes filtrar logs por tenant, por feature flag o por cualquier dimensión de tu negocio.

Prometheus: métricas para dashboards y alertas

Los logs responden “¿qué pasó en este request?”. Las métricas responden “¿cómo se comporta el sistema en agregado?”. Bifrost expone métricas en formato Prometheus a través del telemetry plugin, que está activo por defecto.

El endpoint /metrics

El endpoint /metrics expone las métricas en formato de exposición de Prometheus cuando el telemetry plugin está habilitado. Si activaste autenticación en tu gateway, el endpoint requiere basic auth.

curl http://localhost:8080/metrics

Métricas que expone

Bifrost separa las métricas en dos familias. Las HTTP describen el transporte:

Y las métricas LLM de Bifrost, que son las que de verdad te importan para IA:

Fíjate en lo poderoso de esta lista: con bifrost_cost_total mides gasto real, con bifrost_stream_first_token_latency_seconds mides TTFT (time to first token) de streaming, con bifrost_provider_key_up detectas claves caídas y con bifrost_request_retries ves si tu resiliencia está trabajando de más.

Labels por defecto

Las métricas a nivel de request incluyen labels que te permiten cortar los datos por casi cualquier dimensión: provider, model, alias, method, virtual_key_id, virtual_key_name, routing_engine_used, routing_rule_id, routing_rule_name, selected_key_id, selected_key_name, fallback_index, team_id, team_name, customer_id, customer_name.

El label fallback_index es especialmente útil: te dice cuántas posiciones de fallback se necesitaron para resolver el request. Si fallback_index empieza a subir, tu proveedor primario está fallando.

Labels personalizados

Además de los labels por defecto, puedes definir dimensiones propias en config.json:

{
  "client": {
    "prometheus_labels": ["team", "environment", "organization", "project"]
  }
}

Y luego inyectarlos en tiempo de ejecución con headers x-bf-dim-*:

curl -X POST http://localhost:8080/v1/chat/completions \
  -H "x-bf-dim-team: engineering" \
  -H "x-bf-dim-environment: production" \
  -H "Content-Type: application/json" \
  -d '{ "model": "openai/gpt-4o-mini", "messages": [{"role":"user","content":"hola"}] }'

El formato es: prefijo x-bf-dim-, nombre del label es cualquier string después del prefijo, y el valor es un string. Importante: estos x-bf-dim-* son dimensiones compartidas que también se ven en logs y en OpenTelemetry, no solo en Prometheus.

Scraping: el modelo pull

El modelo recomendado para una sola instancia o acceso directo es pull-based: Prometheus rasca el endpoint /metrics cada cierto intervalo. Configuración básica:

scrape_configs:
  - job_name: 'bifrost'
    static_configs:
      - targets: ['bifrost-host:8080']
    scrape_interval: 15s

Si tu gateway tiene autenticación activada, añade las credenciales:

scrape_configs:
  - job_name: 'bifrost'
    basic_auth:
      username: '<admin_username>'
      password: '<admin_password>'

Push Gateway: el modelo push

Cuando corres múltiples instancias detrás de un balanceador o instancias serverless/efímeras que Prometheus no puede rascar de forma confiable, usa el modelo push-based. Bifrost empuja sus métricas a un Push Gateway. Se configura como parte del telemetry plugin:

{
  "plugins": [
    {
      "name": "telemetry",
      "enabled": true,
      "config": {
        "push_gateway": {
          "enabled": true,
          "push_gateway_url": "http://pushgateway:9091",
          "job_name": "bifrost",
          "push_interval": 15
        }
      }
    }
  ]
}

Los campos del push gateway son:

CampoTipoRequerido
push_gateway_urlstring | EnvVar
job_namestringNo (default: bifrost)
instance_idstringNo (default: hostname)
push_intervalinteger (1-300 seg)No (default: 15)
basic_auth.usernamestring | EnvVarCondicional
basic_auth.passwordstring | EnvVarCondicional

Para no hardcodear secretos, los campos que aceptan EnvVar usan la sintaxis "env.VAR_NAME", que Bifrost resuelve en tiempo de ejecución.

Stack de desarrollo listo para usar

Para experimentar localmente, el plugin trae un docker-compose con Prometheus y Grafana ya configurados:

cd plugins/telemetry
docker-compose up -d
# Prometheus: http://localhost:9090
# Grafana:    http://localhost:3000 (admin/admin)

OpenTelemetry: trazas distribuidas con OTLP

Prometheus te da agregados, pero no te cuenta la historia de una operación concreta atravesando tu sistema. Para eso existe el otel plugin de Bifrost, que exporta trazas vía OpenTelemetry Protocol (OTLP) a un collector. Desde ahí llegan a Grafana Cloud, Datadog, Langfuse o cualquier backend compatible con OTLP.

flowchart LR
    Bifrost[Bifrost<br/>otel plugin] -->|OTLP http :4318<br/>o grpc :4317| Collector[OTel Collector]
    Collector --> Grafana[Grafana Cloud]
    Collector --> Datadog[Datadog]
    Collector --> Langfuse[Langfuse]
    Collector --> Otros[Cualquier backend OTLP]

Configuración del plugin OTel

Los campos requeridos son:

Y los opcionales más relevantes: service_name (default bifrost), headers (objeto para auth, soporta env.VAR_NAME), tls_ca_cert (ruta al CA para TLS), metrics_enabled, metrics_endpoint, metrics_push_interval y plugin_span_filter.

Un detalle de soporte: a fecha de hoy, el único trace_type liberado es genai_extension. Los formatos vercel y open_inference figuran como “coming soon”, así que para algo en producción usa genai_extension.

Configuración HTTP (OTLP/HTTP)

{
  "plugins": [
    {
      "enabled": true,
      "name": "otel",
      "config": {
        "service_name": "bifrost",
        "collector_url": "http://localhost:4318/v1/traces",
        "trace_type": "genai_extension",
        "protocol": "http",
        "headers": {
          "Authorization": "env.OTEL_API_KEY"
        }
      }
    }
  ]
}

Fíjate en el puerto: OTLP/HTTP usa el 4318 y la ruta /v1/traces. Si te equivocas de puerto, el collector no recibe nada.

Configuración gRPC con TLS

{
  "plugins": [
    {
      "enabled": true,
      "name": "otel",
      "config": {
        "service_name": "bifrost",
        "collector_url": "localhost:4317",
        "trace_type": "genai_extension",
        "protocol": "grpc",
        "insecure": false,
        "tls_ca_cert": "/path/to/your/ca.cert"
      }
    }
  ]
}

Aquí OTLP/gRPC usa el 4317, sin ruta /v1/traces (gRPC enruta por servicio, no por path).

Variables de entorno y atributos de recurso

Toda configuración sensible debería salir del config.json. La sustitución env. resuelve los valores en runtime y nunca los persiste:

{
  "collector_url": "env.OTEL_COLLECTOR_URL",
  "metrics_endpoint": "env.OTEL_METRICS_ENDPOINT",
  "headers": {
    "Authorization": "env.OTEL_API_KEY"
  }
}

Además, la variable estándar OTEL_RESOURCE_ATTRIBUTES adjunta metadata a todos los spans automáticamente:

export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,service.version=1.2.3"

Qué llevan los spans

Bifrost emite spans con atributos semánticos GenAI estándar, lo que los hace legibles en cualquier backend OTLP sin mapeos manuales:

Y cada tipo de request genera su propio span nombrado: chat completions -> gen_ai.chat, text completions -> gen_ai.text, embeddings -> gen_ai.embedding, speech -> gen_ai.speech, transcription -> gen_ai.transcription y Responses API -> gen_ai.responses.

Filtrar spans de plugins

Por defecto, los plugins de Bifrost generan sus propios spans. Si te genera ruido, puedes filtrarlos con plugin_span_filter:

{
  "plugin_span_filter": {
    "mode": "exclude",
    "plugins": ["logging", "telemetry", "otel"]
  }
}

El modo exclude exporta todos los spans excepto los plugins listados; include exporta solo los listados.

Métricas push vía OTLP (v1.5.8+)

Desde la versión v1.5.8, el otel plugin también puede empujar métricas (no solo trazas) a tu collector, usando la estructura profiles:

{
  "plugins": [
    {
      "enabled": true,
      "name": "otel",
      "config": {
        "profiles": [
          {
            "service_name": "bifrost",
            "enabled": true,
            "collector_url": "http://otel-collector:4318/v1/traces",
            "trace_type": "genai_extension",
            "protocol": "http",
            "metrics_enabled": true,
            "metrics_endpoint": "http://otel-collector:4318/v1/metrics",
            "metrics_push_interval": 15
          }
        ]
      }
    }
  ]
}

Ejemplos por plataforma

Grafana Cloud:

{
  "collector_url": "https://otlp-gateway-prod-us-central-0.grafana.net/otlp",
  "headers": {
    "Authorization": "env.GRAFANA_CLOUD_API_KEY"
  }
}

Datadog:

{
  "collector_url": "https://trace.agent.datadoghq.com",
  "headers": {
    "DD-API-KEY": "env.DATADOG_API_KEY"
  }
}

Langfuse requiere protocolo HTTP (gRPC no está soportado). El collector_url es https://cloud.langfuse.com/api/public/otel/v1/traces (EU) o https://us.cloud.langfuse.com/api/public/otel/v1/traces (US).

Inicialización desde el Go SDK

Si embebes Bifrost con el Go SDK, inicializas el plugin así:

otelPlugin, err := otel.Init(ctx, &otel.Config{
    ServiceName:  "bifrost",
    CollectorURL: "http://localhost:4318/v1/traces",
    TraceType:    otel.TraceTypeGenAIExtension,
    Protocol:     otel.ProtocolHTTP,
    Headers: map[string]string{
        "Authorization": "env.OTEL_API_KEY",
    },
}, logger, pricingManager)

Integración con Maxim AI

Bifrost nació dentro de Maxim AI, una plataforma de observabilidad y evaluación (evals) de aplicaciones de IA. El maxim plugin envía automáticamente cada generación a Maxim, donde puedes correr evals, agrupar por sesiones y analizar el comportamiento de tus modelos más allá de simples métricas.

Configuración

El plugin necesita dos campos. api_key es requerido; log_repo_id es opcional y sirve como repositorio de fallback. En config.json:

{
  "plugins": [
    {
      "enabled": true,
      "name": "maxim",
      "config": {
        "api_key": "your_maxim_api_key",
        "log_repo_id": "your_default_repo_id"
      }
    }
  ]
}

Desde el Go SDK los campos se llaman ApiKey y LogRepoId:

maximPlugin, err := maxim.Init(maxim.Config{
    ApiKey:    "your_maxim_api_key",
    LogRepoId: "your_default_repo_id",
})

Personalización por request vía headers

Lo potente de Maxim es que puedes estructurar tus trazas por sesión, generación y nombre legible desde los headers del request, sin tocar el código del backend. Headers reservados:

Según la documentación: las claves reservadas son session-id, trace-id, trace-name, generation-id, generation-name y log-repo-id; cualquier otro header x-bf-maxim-* se trata como tag personalizado.

curl -X POST http://localhost:8080/v1/chat/completions \
  -H "x-bf-maxim-session-id: user-42-session" \
  -H "x-bf-maxim-trace-name: checkout-assistant" \
  -H "x-bf-maxim-feature: pricing-explainer" \
  -H "Content-Type: application/json" \
  -d '{ "model": "openai/gpt-4o-mini", "messages": [{"role":"user","content":"hola"}] }'

Aquí session-id y trace-name son reservados, mientras que feature se convierte en un tag libre.

Dimensiones compartidas con x-bf-dim-*

Los mismos headers x-bf-dim-* que vimos en Prometheus son dimensiones compartidas: se ven en logs, OpenTelemetry y Prometheus, y además se propagan a Maxim como tags (con menor prioridad que los x-bf-maxim-* explícitos). Un solo header como x-bf-dim-environment: production aparece en las cuatro capas de observabilidad. Esto evita que tengas que repetir la misma dimensión una vez por backend.

El telemetry plugin en una frase

A lo largo del capítulo lo mencionamos varias veces, así que cerremos el concepto: el telemetry plugin es el que provee “telemetría y monitoreo integrados a través de la recolección de métricas Prometheus”. Está activo por defecto, opera de forma asíncrona para no afectar la latencia, y es quien alimenta el endpoint /metrics. Cuando configuras push_gateway o prometheus_labels, estás configurando este plugin. No necesitas instalar nada extra: viene de fábrica.

Buenas prácticas de monitoreo en producción

Tener métricas no sirve de nada si no decides qué alertar. Estas son las alertas que de verdad importan en un gateway de IA, mapeadas a las métricas reales que expone Bifrost:

flowchart TD
    A[Tasa de errores] -->|bifrost_error_requests_total / bifrost_upstream_requests_total| Alert1[Alerta: error rate > umbral]
    B[Salud de claves] -->|bifrost_provider_key_up == 0| Alert2[Alerta: clave de proveedor caida]
    C[Latencia P95] -->|bifrost_upstream_latency_seconds| Alert3[Alerta: P95 degradada]
    D[TTFT streaming] -->|bifrost_stream_first_token_latency_seconds| Alert4[Alerta: primer token lento]
    E[Costo] -->|rate de bifrost_cost_total| Alert5[Alerta: gasto anomalo]
    F[Reintentos / fallbacks] -->|bifrost_request_retries + fallback_index| Alert6[Alerta: resiliencia trabajando de mas]

En concreto, prioriza estas reglas:

La regla de oro: usa las tres capas juntas. Cuando una alerta de Prometheus se dispara, abres los logs filtrados por ese intervalo y proveedor para ver el attempt_trail, y revisas la traza OTLP para entender el flujo completo. Las métricas te dicen qué falló, los logs por qué, y las trazas dónde dentro de tu arquitectura.

Resumen

Siguiente: Plugins y extensibilidad