Observabilidad: logs, Prometheus y OpenTelemetry
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:
- Logs en tiempo real desde la UI: el detalle de cada request (latencia, tokens, costo, proveedor, reintentos).
- Métricas Prometheus: contadores e histogramas agregados para dashboards y alertas.
- 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:
- Datos del request: mensajes de entrada e historial de conversación, parámetros del modelo (
temperature,max_tokens,tools, etc.), proveedor y modelo usados, y el nombre/versión/ID del prompt cuando el Prompts plugin está activo. - Datos de la respuesta: mensajes de salida y tool calls, métricas de latencia y uso de tokens, y el estado de éxito/error.
- Reintentos y selección de clave: aquí está el oro para depurar resiliencia. El sistema registra
selected_key_id,selected_key_name,number_of_retriesy unattempt_trail: un array ordenado donde cada intento muestra la clave usada y la razón del fallo. Si tu fallback se disparó, lo ves aquí paso a paso. - Extras: inputs/outputs de audio, URLs de imágenes y respuestas de visión, argumentos y resultados de ejecución de herramientas, y metadata personalizada vía logging headers.
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:
| Backend | Característica | Cuándo usarlo |
|---|---|---|
sqlite | Single-writer, solo filesystem local | Desarrollo, instancias únicas |
postgres | Excelente para escrituras concurrentes y consultas complejas | Producció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:
http_requests_total(Counter)http_request_duration_seconds(Histogram)http_request_size_bytes(Histogram)http_response_size_bytes(Histogram)
Y las métricas LLM de Bifrost, que son las que de verdad te importan para IA:
bifrost_upstream_requests_total,bifrost_upstream_latency_secondsbifrost_success_requests_total,bifrost_error_requests_totalbifrost_input_tokens_total,bifrost_output_tokens_totalbifrost_cost_total(costo real en USD)bifrost_cache_hits_totalbifrost_stream_first_token_latency_seconds,bifrost_stream_inter_token_latency_secondsbifrost_active_requests(Gauge)bifrost_provider_key_up(Gauge: salud de cada clave de proveedor)bifrost_key_rotation_events_total(Counter, desde v1.5.0-prerelease4+)bifrost_request_retries(Histogram)
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:
| Campo | Tipo | Requerido |
|---|---|---|
push_gateway_url | string | EnvVar | Sí |
job_name | string | No (default: bifrost) |
instance_id | string | No (default: hostname) |
push_interval | integer (1-300 seg) | No (default: 15) |
basic_auth.username | string | EnvVar | Condicional |
basic_auth.password | string | EnvVar | Condicional |
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:
collector_url(string | EnvVar): el endpoint OTLP. Soporta la sintaxisenv.VAR_NAME.trace_type(string): uno degenai_extension,verceluopen_inference.protocol(string):httpogrpc.
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:
gen_ai.provider.name,gen_ai.request.modelgen_ai.request.temperature,gen_ai.request.max_tokensgen_ai.usage.prompt_tokens,gen_ai.usage.completion_tokens,gen_ai.usage.total_tokensgen_ai.usage.cost(en dólares)- Los headers que tú envíes aparecen como
gen_ai.request.extra_header.<name>
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:
x-bf-maxim-log-repo-idx-bf-maxim-trace-idx-bf-maxim-generation-idx-bf-maxim-session-idx-bf-maxim-trace-namex-bf-maxim-generation-name
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:
- Tasa de errores: alerta cuando
bifrost_error_requests_totalcrece respecto abifrost_upstream_requests_total. Es tu primera línea de defensa ante un proveedor caído. - Claves caídas: alerta inmediata cuando
bifrost_provider_key_upvalga0para alguna clave. Una clave inválida o sin saldo degrada silenciosamente tu servicio. - Latencia: vigila el P95 de
bifrost_upstream_latency_seconds. Para flujos de chat con streaming, vigila tambiénbifrost_stream_first_token_latency_seconds(TTFT), que es lo que el usuario percibe como “tarda en responder”. - Costo anómalo: el
rate()debifrost_cost_totales tu sistema de detección de fugas de gasto. Un agente en bucle infinito se ve aquí antes que en la factura. Combínalo con los límites de governance. - Resiliencia que trabaja de más: si
bifrost_request_retrieso el labelfallback_indexsuben, tus fallbacks te están salvando pero tu proveedor primario tiene un problema que debes investigar antes de que el secundario también falle. - Privacidad por defecto en logs: en producción regulada, activa
disable_content_logging: truepara registrar métricas sin almacenar contenido sensible, y prefiere PostgreSQL sobre SQLite si tienes más de una instancia. - Saca los secretos del config.json: usa siempre la sintaxis
env.VAR_NAMEpara API keys de Maxim, Datadog, Grafana Cloud o el OTLP collector. Se resuelven en runtime y no quedan persistidas en disco.
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
- Bifrost ofrece tres capas de observabilidad nativas que se complementan: logs (requests individuales), Prometheus (agregados y alertas) y OpenTelemetry (trazas distribuidas).
- El logging plugin captura cada request con menos de 0.1ms de overhead: latencia, tokens, costo, proveedor, y el
attempt_trailcon cada reintento y fallback. Se activa conenable_logging, se consulta vía/api/logsy se ve en vivo por WebSocket enws://localhost:8080/ws. Usadisable_content_loggingpara privacidad y PostgreSQL para múltiples instancias. - El telemetry plugin (activo por defecto) expone métricas en
/metrics:bifrost_cost_total,bifrost_provider_key_up,bifrost_upstream_latency_seconds,bifrost_request_retriesy más. Configura scraping pull conscrape_configso push conpush_gateway. Añade dimensiones conprometheus_labelsy headersx-bf-dim-*. - El otel plugin exporta trazas OTLP a un collector. HTTP usa el puerto
4318/v1/traces, gRPC el4317. Usatrace_type: genai_extension(el único liberado), atributosgen_ai.*estándar y la sintaxisenv.VAR_NAMEpara secretos. Integra con Grafana Cloud, Datadog y Langfuse. - El maxim plugin envía generaciones a Maxim AI para observabilidad y evals, configurable por request con headers
x-bf-maxim-*. - En producción, alerta sobre tasa de errores, claves caídas (
bifrost_provider_key_up == 0), latencia P95, TTFT de streaming, costo anómalo y exceso de reintentos/fallbacks.
Siguiente: Plugins y extensibilidad