DEV Community

Cover image for Tracing, métricas Prometheus y logs estructurados con dos decoradores: Fitz vs el setup de OpenTelemetry en FastAPI
Martin Palopoli
Martin Palopoli

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 structlog no tiene el processor inject_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
}
Enter fullscreen mode Exit fullscreen mode

Activar el export OTLP:

export OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318
fitz run main.fitz
Enter fullscreen mode Exit fullscreen mode

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 Prometheus 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_code al cerrar el span
  • duration_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)
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 = ***"
Enter fullscreen mode Exit fullscreen mode

Para exponer el valor real (al firmar JWT, al pegar a la DB):

let token = jwt.encode(claims, JWT_SECRET.expose())
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

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 @metric despachan 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 upstream metrics-exporter-opentelemetry compatible con opentelemetry_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-instrumentation para lo que toda app necesita (HTTP spans + access logs + metrics), decoradores opcionales para lo custom (@trace/@metric), y conexión nativa entre signals (trace_id propagado, Secret redactado).

El target sigue siendo el ecosistema OTel — exportás a los mismos backends, con los mismos env vars estándar. Lo que cambia es el código tuyo.

Si tu pipeline de observability ya está estabilizado con OTel en FastAPI y funciona, no hay urgencia. Si estás arrancando un proyecto en 2026 y querés evitar el día de plumbing, vale la prueba.


Próximo post de la serie: "ORM tipado con migraciones automáticas: Fitz vs SQLAlchemy + Alembic + Pydantic" — la última pieza del stack, con benchmarks reproducibles.

Repo: github.com/Thegreekman76/fitz
Capítulo 33 de la guía (Observability): docs/guide.md

Top comments (0)