Tracing, métricas Prometheus y logs estructurados con dos decoradores: Fitz vs el setup de OpenTelemetry en FastAPI
Tracing, métricas Prometheus y logs estructurados con dos decoradores: Fitz vs el setup de OpenTelemetry en FastAPI
Para tener observability completa en FastAPI necesitás 6 paquetes pip + 60 líneas de config + glue manual entre logs/spans/métricas. En Fitz son dos decoradores y un env var. Con trace_id correlacionado auto entre logs y spans, y Secret<T> redactado en logs sin pensar.
El stack que toda app "production-ready" termina pegoteando
Tu app crece. El cliente quiere saber qué endpoint está lento, cuántos requests fallaron en la última hora, y por qué un user específico vio un error a las 3 AM. Hablamos del Triángulo Sagrado de observability: traces, metrics, logs.
En 2026 la respuesta de la industria es OpenTelemetry para los tres. En Python con FastAPI:
pip install opentelemetry-distro[otlp] \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-requests \
opentelemetry-exporter-otlp-proto-grpc \
prometheus-fastapi-instrumentator \
structlog
observability.py (~60 líneas):
import os
import logging
from opentelemetry import trace, metrics
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased
import structlog
from prometheus_fastapi_instrumentator import Instrumentator
SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "myapp")
OTLP_ENDPOINT = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
SAMPLE_RATIO = float(os.environ.get("OTEL_TRACES_SAMPLER_ARG", "1.0"))
def setup_observability(app, engine):
resource = Resource.create({"service.name": SERVICE_NAME})
if OTLP_ENDPOINT:
trace_provider = TracerProvider(
resource=resource,
sampler=TraceIdRatioBased(SAMPLE_RATIO),
)
trace_provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint=OTLP_ENDPOINT))
)
trace.set_tracer_provider(trace_provider)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=OTLP_ENDPOINT)
)
meter_provider = MeterProvider(
resource=resource,
metric_readers=[metric_reader]
)
metrics.set_meter_provider(meter_provider)
FastAPIInstrumentor.instrument_app(app)
SQLAlchemyInstrumentor().instrument(engine=engine)
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
# Logs estructurados con trace_id
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.dict_tracebacks,
inject_trace_context, # propio, ver abajo
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
cache_logger_on_first_use=True,
)
def inject_trace_context(logger, method_name, event_dict):
span = trace.get_current_span()
if span and span.get_span_context().is_valid:
ctx = span.get_span_context()
event_dict["trace_id"] = format(ctx.trace_id, "032x")
event_dict["span_id"] = format(ctx.span_id, "016x")
return event_dict
Plus uso en handlers:
import structlog
from opentelemetry import trace, metrics
log = structlog.get_logger()
tracer = trace.get_tracer(__name__)
meter = metrics.get_meter(__name__)
orders_counter = meter.create_counter("orders_calls_total")
orders_histogram = meter.create_histogram("orders_duration_seconds", unit="s")
@app.post("/orders")
async def process_order(body: OrderIn):
with tracer.start_as_current_span("process_order") as span:
span.set_attribute("order.id", body.id)
start = time.time()
try:
log.info("order.processing", order_id=body.id, total=body.total)
receipt = await actually_process(body)
orders_counter.add(1, {"status": "success"})
log.info("order.processed", receipt_id=receipt.id)
return receipt
except Exception as e:
orders_counter.add(1, {"status": "error"})
log.error("order.failed", order_id=body.id, error=str(e))
raise
finally:
orders_histogram.record(time.time() - start)
Ocho instalaciones de paquetes. ~60 líneas de setup. ~25 líneas por handler para tracear + metricar + loguear. Conexión manual entre las tres signals.
Y ojo con:
- Si te olvidás de llamar
FastAPIInstrumentor.instrument_app(app), no hay spans HTTP. - Si te olvidás del
SQLAlchemyInstrumentor, no se ve la DB. - Si
structlogno tiene el processorinject_trace_context, los logs no se correlacionan con los spans.
Lo mismo en Fitz
@server(8080, prometheus=true)
fn main() => 0
@trace(name="process_order")
@metric(name="orders")
async fn process_order(body: OrderIn) -> Result<Receipt> {
log.info("order.processing", { order_id: body.id, total: body.total })
let receipt = actually_process(body).await?
log.info("order.processed", { receipt_id: receipt.id })
return Ok(receipt)
}
@post("/orders")
async fn create_order(body: OrderIn) -> Result<Receipt> {
return process_order(body).await
}
Activar el export OTLP:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318
fitz run main.fitz
Eso es todo.
La tabla cruda
| Item | Python (OTel + structlog + 6 libs) | Fitz |
|---|---|---|
| Setup inicial | ~60 LoC + 6 instalaciones pip | @server(prometheus=true) |
| Span HTTP por request | FastAPIInstrumentor.instrument_app(app) |
Auto-instrumented |
| Span custom sobre fn | with tracer.start_as_current_span("X") |
@trace(name="X") |
| Counter de calls | meter.create_counter(...) + .add(1) |
@metric(name="X") (incluye duration histogram) |
| Histogram de duration | meter.create_histogram(...) + .record(...) + try/finally |
Mismo @metric (RAII guard) |
Endpoint /metrics |
Instrumentator().instrument(app).expose(app) |
@server(prometheus=true) |
| Logs estructurados JSON | structlog.configure(...) con 6 processors |
log.info("event", { ... }) built-in |
| Trace_id propagado a logs | Processor custom inject_trace_context |
Automático (task-local) |
| Secret redactado en logs | .get_secret_value() manual con cuidado |
Secret<T> se redacta auto |
| HTTP access log | FastAPIInstrumentor lo emite | Auto-emitido con trace_id / span_id |
| Sampling tail-based | TraceIdRatioBased |
TraceIdRatioBased (mismo) |
Por partes
Spans HTTP auto-instrumentados
En Fitz, cada @get / @post / @put / @delete / @ws abre un span OTel con:
http.method(GET/POST/...)http.target(el path template -/users/{id}no/users/42, low cardinality friendly)http.status_codeal cerrar el spanduration_ms
Y emite un access log con los mismos campos + trace_id / span_id. Sin código del user.
En Python tenés que llamar FastAPIInstrumentor.instrument_app(app) y rezar que tu versión matchea. Si tu user agent emite headers con caracteres no-ASCII, la version vieja de la lib panickeaba - bug famoso.
Trace_id propagado a logs custom
Adentro del span del request:
@authenticated
@post("/orders")
async fn create_order(user: User, body: OrderIn) -> Result<Receipt> {
log.info("order.received", { order_id: body.id, user_email: user.email })
let receipt = process(body).await?
log.info("order.ack", { receipt_id: receipt.id })
return Ok(receipt)
}
Cada log.info(...) adentro del handler incluye automáticamente:
{
"timestamp": "2026-06-16T10:23:01.231Z",
"level": "info",
"msg": "order.received",
"trace_id": "5e4f9b2c8a7d3e1f0b6c9a4d8e2f1a3b",
"span_id": "a1b2c3d4e5f6a7b8",
"order_id": 42,
"user_email": "ada@example.com"
}
El trace_id matchea con el trace_id del span en Jaeger/Tempo. Por la cierre 9.x.4 iter2.a, cuando hay OTel activo, el trace_id de los logs es exactamente el mismo del span OTel - habilita queries cross-pipeline ("dame todos los logs cuyo trace_id coincide con este span de Jaeger").
En Python tenés que escribir el processor inject_trace_context a mano (~10 líneas), agregarlo a la config de structlog, y validar que cada handler usa structlog en lugar del logging stdlib (porque si alguien hace import logging; logging.info(...) directamente, los logs NO van a tener el trace_id).
Métricas con un decorador
@metric(name="orders") automáticamente registra DOS metrics:
orders_calls_total- Counter, incrementado al return de la fn.orders_duration_seconds- Histogram, registrado al return (incluso si la fn paniquea, por RAII guard).
@trace(name="process_order")
@metric(name="orders")
async fn process(order: Order) -> Result<Receipt> {
// process_order span se cierra al return
// orders_calls_total += 1
// orders_duration_seconds.observe(elapsed)
}
En Python con OTel, para el mismo efecto:
orders_counter = meter.create_counter("orders_calls_total")
orders_histogram = meter.create_histogram("orders_duration_seconds", unit="s")
@tracer.start_as_current_span("process_order")
async def process(order: Order) -> Receipt:
start = time.time()
try:
result = await actually_process(order)
orders_counter.add(1)
return result
finally:
orders_histogram.record(time.time() - start)
5× más código. Y si te olvidás el finally, la histogram pierde casos. Decoradores de Fitz garantizan el cleanup vía RAII.
Endpoint Prometheus opcional
@server(8080, prometheus=true)
fn main() => 0
Auto-monta GET /metrics en el mismo puerto, devolviendo el formato exposition de Prometheus. Sin librería separada. Sin definir el endpoint a mano. Si el user declaró su propio @get("/metrics"), gana - mismo patrón que /openapi.json / /healthz.
En Python: Instrumentator().instrument(app).expose(app) - está bien, pero es otra dep, otra responsabilidad, otra versión a matchear con FastAPI.
Export OTLP con un env var
# Para Jaeger
export OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318
# Para Honeycomb (con headers)
export OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io
export OTEL_EXPORTER_OTLP_HEADERS=x-honeycomb-team=abc123
# Para Tempo
export OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4318
# Tail-based sampling 10%
export OTEL_TRACES_SAMPLER_ARG=0.1
# Service name
export OTEL_SERVICE_NAME=myapp
Sin la env var, cero overhead, cero conexiones de red. El instrumento corre como no-op si no hay endpoint declarado.
Estos env vars son los standard de OpenTelemetry, no inventados por Fitz. Compatible con cualquier backend OTel (Jaeger, Tempo, Honeycomb, Datadog, NewRelic, Grafana Cloud, etc.).
Secret<T> redactado auto en logs
Esta es mi feature favorita.
En Python:
log.info("auth.success", token=user.access_token) # ¡bug! va el token a Loki
El bug que más vi en código de producción: alguien loguea la API key, la password, el JWT. El secret va a Loki/Sentry/Datadog. Borrar logs de producción es una operación lenta y dolorosa.
En Fitz, Secret<T> redacta automáticamente en Display y en serialización JSON:
let JWT_SECRET: Secret<Str> = secret("JWT_SECRET")
let user_token: Secret<Str> = generate_token(user)
log.info("auth.success", { token: user_token })
// → {"msg": "auth.success", "token": "***"}
print("JWT_SECRET = {JWT_SECRET}")
// → "JWT_SECRET = ***"
Para exponer el valor real (al firmar JWT, al pegar a la DB):
let token = jwt.encode(claims, JWT_SECRET.expose())
.expose() es explícito, grepeable. El code review puede auditar cada call site en segundos.
En Pydantic existe SecretStr pero tenés que recordar opt-in en cada lugar, y la gente se olvida. Fitz hace que la versión segura sea la default.
Decisiones de diseño que vale la pena entender
@trace / @metric solo sobre fns user, no sobre HTTP/WS
Los handlers HTTP y WebSocket ya tienen auto-instrumentation con el span del request. Stackear @trace arriba de @get sería redundante y crearía spans anidados sin valor. El checker lo rechaza con mensaje claro.
Acceso log auto-emitido
Cada handler HTTP emite un log.info("http.access", ...) al return con http.method / http.target / http.status_code / duration_ms. Sin opt-in.
Si querés desactivarlo:
@server(8080, observability=false)
fn main() => 0
Y el wrapper de instrumentación se bypassea entero.
Storage del span context con task-local
SpanContext vive en un tokio::task_local!. Atraviesa thread boundaries en runtime multi-thread. Sin globales mutables, sin race conditions.
Lo que Fitz NO te da (todavía)
Honestidad sobre las deudas residuales de Fase 12.3 documentadas explícitamente:
- Bridge logs OTel: los
log.X(...)van a stderr (con trace_id propagado). Para que ALSO vayan al log signal de OTel del backend (correlacionado con spans ahí), tenés que esperar al sub-paso 12.3.iter2.b - diseñado pero no shipped. - Bridge métricas OTel: las metrics que emite
@metricdespachan al recorder Prometheus cuando@server(prometheus=true). Para que también vayan al OTel metrics signal (push a Honeycomb metrics, NewRelic metrics) hay que esperar release del crate upstreammetrics-exporter-opentelemetrycompatible conopentelemetry_sdk 0.32(deuda documentada, no por desidia nuestra). - Sampling tail-based: solo head-based (
TraceIdRatioBased). Para "exportá traces que tuvieron error" o "samplealos por latencia" hay que correr OTel collector en el medio con la config. - Profiling continuo (pyroscope/pprof) - no integrado.
- Auto-instrumentation de DB: el ORM nativo emite el SQL ejecutado en los spans del handler que lo llama. Para queries vía
db.query(...)raw, hoy no se crea span hijo (deuda menor).
Cierre
Observability en Python es el área donde más visiblemente paga "ser library-first": cada signal vive en una lib distinta, cada lib tiene su propia config, cada handler necesita boilerplate para usarlas, y mantener consistencia entre las tres signals es responsabilidad tuya.
Fitz mete las tres signals en el lenguaje, con auto-instrument.
Comments
No comments yet. Start the discussion.