jfastframework
Documentación English PyPI GitHub

Telemetría: un request, seguido a todas partes

Los logs dicen qué hizo un servicio; las métricas, con qué frecuencia. Ninguno sigue un request por la API, su SQL, una llamada a un modelo, un segundo servicio y el job que encoló -- que es lo primero que hace falta cuando algo va lento en producción. El plugin telemetry hace eso con trazas de OpenTelemetry, exportadas por OTLP a cualquier cosa que lo hable: Jaeger, Grafana Tempo, Honeycomb, Datadog, un OpenTelemetry Collector.

[plugins]
enabled = ["observability", "database", "http", "telemetry"]
# .env -- la única línea que lo enciende
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
pip install "jfastframework[telemetry]"

Gratis hasta que se configura

Con el plugin habilitado y sin endpoint no se instala nada: ni middleware, ni listener de SQL, ni tracer. Cada span del framework sigue siendo el no-op que es sin el plugin, y una línea al arrancar lo dice:

telemetry: no OTEL_EXPORTER_OTLP_ENDPOINT, so no traces are recorded or exported

/ready reporta telemetry como not exporting, nunca como falla. OpenTelemetry ni siquiera tiene que estar instalado hasta que hay endpoint; con endpoint y sin el extra, el arranque se detiene con el pip install que hay que correr.

El endpoint se lee, en orden de precedencia, de [plugin.telemetry] traces_endpoint / endpoint en jfast.toml, luego JFAST_TELEMETRY_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT (URL completa) y OTEL_EXPORTER_OTLP_ENDPOINT (URL base; se le agrega /v1/traces) -- del entorno o del .env.

Qué se traza

Span Tipo Atributos
GET /users/{user_id} -- cada request HTTP server http.route (la plantilla, no la ruta), http.request.method, http.response.status_code, jfast.tenant_id, jfast.request_id; excepciones como eventos
SELECT notes -- cada sentencia SQL client db.system, db.name, db.operation, db.sql.table, db.rows_affected; error.type si falla
GET billing -- cada llamada del cliente http client jfast.upstream, server.address, http.response.status_code, http.request.resend_count
GET /billing -- cada llamada que proxea el gateway client jfast.gateway.prefix, server.address, http.response.status_code
llm.chat, llm.embed client llm.model, llm.purpose, jfast.tenant_id, llm.usage.input_tokens, llm.usage.output_tokens, llm.usd, llm.ms, llm.retries, llm.finish_reason
rag.ingest internal rag.document_id, rag.chunks, rag.embedded, rag.reused, rag.store
rag.search internal rag.limit, rag.hybrid, rag.filtered, rag.hits, rag.store
jobs y handlers de eventos consumer los ponen los plugins de cola y eventos, dentro de la traza del request que los creó

Cada span lleva el recurso del servicio: service.name (el app_name, salvo que exista OTEL_SERVICE_NAME), service.version y deployment.environment.name.

/health, /ready y /metrics no se trazan (exclude_paths): los probes y los scrapes llegan varias veces por segundo, para siempre, y nadie los lee.

Qué nunca se traza

Texto de prompts, documentos, respuestas, consultas de búsqueda, cuerpos de request y de respuesta, parámetros de SQL. La misma regla que el ledger de llm, aplicada en tres lugares:

La ruta cruda tampoco se registra, solo la plantilla: una ruta puede traer un correo o un id por request, y el request id ya lleva a la línea de log que la tiene.

Las excepciones se registran con tipo, mensaje y stack trace, como en toda integración de OpenTelemetry. Los errores del framework dicen qué falló, no qué se mandó (LLMError lleva el mensaje del proveedor, nunca el prompt); el mensaje que tu código pone en una excepción es tuyo de mantener limpio.

Entre servicios, jobs y eventos

Una sola traza cubre cada salto porque el traceparent de W3C viaja con el trabajo:

Un cliente que no es el del framework -- una llamada directa con httpx -- tiene que mandar el header él mismo:

from jfastframework import tracing

await httpx_client.get(url, headers=tracing.inject())

Y el trabajo que vale la pena ver en tu código lleva un span igual que el del framework, gratis cuando telemetry está apagado:

with tracing.span("invoice.render", invoice_id=invoice.id, pages=len(pages)):
    pdf = render(invoice)

El provider además queda como el global de OpenTelemetry (set_global_provider, solo si nadie puso uno antes), así que opentelemetry.trace.get_tracer(__name__) en tu código o una instrumentación de terceros se une a las mismas trazas.

Jaeger en local

[plugin.telemetry]
include_infra = true

jfast deploy compose agrega entonces un OpenTelemetry Collector y Jaeger, y apunta la API al collector (OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 en la red de compose). En un workspace todos los servicios nombran los mismos dos contenedores, así que hay un collector y un Jaeger para todo el workspace, y el contenedor de cada servicio recibe la misma variable. La configuración del collector va en línea en su comando -- OTLP de entrada, un batch, OTLP de salida a Jaeger --, así que no hay archivo que mantener junto al compose.

Puerto en el host
UI de Jaeger http://localhost:16686 (jaeger_ui_host_port)
Collector, OTLP/HTTP http://localhost:4318 (collector_host_port)

Un servicio que corre fuera de compose (jfast dev) exporta al collector publicado con OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 en su .env.

Muestreo

[plugin.telemetry]
sample_ratio = 0.1

Conserva una traza nueva de cada diez, decidido por el trace id. Un request que llega con un traceparent muestreado siempre se conserva y uno que llega sin muestrear siempre se descarta, así que una traza nunca queda registrada en un servicio y ausente en el siguiente: se decide en el borde (el gateway o el primer servicio) y todo lo que está detrás lo sigue. Un request no muestreado igual lleva su traceparent hacia adelante.

Configuración

[plugin.telemetry]
# endpoint = "http://collector:4318"    # mejor OTEL_EXPORTER_OTLP_ENDPOINT
sample_ratio = 1.0
sql = true                               # un span por sentencia SQL
record_sql_statement = false
exclude_paths = ["/health", "/ready", "/metrics"]
export_timeout = 10.0                    # segundos por exportación
shutdown_timeout = 5.0                   # segundos para vaciar al apagar
exporter = "otlp"                        # "memory" para pruebas, "console" para imprimir
include_infra = false

La API key de un backend hospedado va en el entorno, nunca en jfast.toml: JFAST_TELEMETRY_HEADERS='{"x-honeycomb-team": "..."}', o el estándar OTEL_EXPORTER_OTLP_HEADERS.

Los spans se exportan en lotes desde un hilo en segundo plano. Al apagar se vacía el último lote, fuera del event loop y dentro de shutdown_timeout, así que un collector caído retrasa el apagado a lo mucho eso.

Salud

/ready reporta el exportador, nunca como crítico -- un collector caído no debe sacar al servicio de rotación:

Las fallas nunca llegan al request

Telemetría que rompe un request es peor que ninguna. Iniciar, terminar o anotar un span, extraer un header, registrar una sentencia SQL: cada paso está protegido, y una falla es una línea de log en debug y un request sin trazar. Un exportador que lanza o un collector que rechaza se cuenta para /ready y lo registra el propio exportador; el request nunca lo ve. tests/test_telemetry.py reemplaza el tracer y el backend por objetos que fallan en cada atributo y comprueba que el request se atiende igual.

La telemetría propia de FastAPI está apagada

FastAPI 0.142 trae OpenTelemetry propio, y lee el mismo OTEL_EXPORTER_OTLP_ENDPOINT. Encendido, configuraba un segundo provider global sin nombre de servicio (unknown_service), exportaba un duplicado de cada span de servidor y habría exportado logs con mensajes de excepción y los valores de entrada rechazados -- contenido que este plugin nunca exporta. create_app lo apaga (auto_configure, trazas, métricas, logs y spans de operación) siempre que el FastAPI instalado tenga el interruptor, así que sus spans -- fastapi.dependencies, fastapi.endpoint, la serialización -- no se registran, a propósito. Las trazas salen solo del plugin telemetry. Encontrado al trazar un servicio real hacia Jaeger; tests/test_telemetry.py comprueba un solo span de servidor por petición y el provider global intacto.

Probar tus propios spans

# configuración de pruebas
[plugin.telemetry]
exporter = "memory"
spans = app.state.jfast.require("telemetry").exporter.get_finished_spans()
assert any(span.name == "invoice.render" for span in spans)

El exportador en memoria registra de forma síncrona: el span está ahí en cuanto termina.

Cuánto cuesta

Medido en proceso para 0.1.0a11: un GET /users/{user_id} llamado directo por la app ASGI (sin sockets ni cliente HTTP), observability en WARNING, mediana de siete rondas de 5,000 requests, tres corridas, laptop Apple serie M:

us por request sobre sin plugin
Sin plugin telemetry 41 --
telemetry habilitado, sin endpoint 41 dentro del ruido entre corridas (±5)
telemetry exportando (exportador en memoria) 66 +19 a +29 (mediana +24)

Sin endpoint el costo no es medible -- no hay nada instalado que cueste. Exportando, es el precio de un span de servidor por request y sus atributos; frente a un endpoint que consulta PostgreSQL (1-5 ms) o llama a un modelo no se nota, y sample_ratio lo baja para los requests que no se conservan. El exportador OTLP real agrega la serialización y el HTTP del hilo de lotes, que corre fuera del camino del request.

Qué está verificado y qué no

Probado (tests/test_telemetry.py, exportador en memoria): sin endpoint no se instala nada y se avisa; spans de servidor con plantilla de ruta, tenant, request id y status; un traceparent entrante continuado; probes excluidos; excepciones y respuestas 5xx registradas; muestreo, incluido un padre muestreado conservado con ratio 0; spans de SQL contra PostgreSQL como hijos del request, sin parámetros, con la sentencia solo si se pide, fallas marcadas, el listener retirado al apagar; dos apps -- A llamando a B con el cliente http por transporte ASGI -- en una sola traza, con el span de servidor de B hijo del span de cliente de A; el gateway reenviando su propio span y, con telemetry apagado, el header de quien llama; spans de llm y rag con sus conteos y costo y sin texto de prompt, documento o respuesta en ningún atributo ni evento; un tracer roto, un backend roto y un exportador que falla sin romper nunca un request; la salida de compose.

Revisado a mano, no en CI (2026-09-30): el par generado para compose -- otel/opentelemetry-collector:0.136.0 con su configuración en línea y jaegertracing/jaeger:2.10.0 -- arrancó, un servicio exportó por OTLP/HTTP al collector, y las trazas se leyeron de vuelta en la API de Jaeger bajo el nombre del servicio, con la plantilla de ruta como nombre de span. No probado: un backend hospedado (Honeycomb, Tempo, Datadog) ni TLS hacia el collector.