Ir para o conteúdo

Métricas

O SDK oferece dois caminhos complementares de métricas:

  1. Prometheus de RED/USE para HTTP (PrometheusMiddleware + make_prometheus_router) — escuta cada request, calcula http_requests_total + histograma de latência + http_requests_in_progress, expõe tudo num GET /metrics no formato texto do Prometheus pronto pra ser raspado pelo seu Prometheus / Grafana / Datadog.
  2. Snapshots de sistema sob demanda (MetricsUtils) — coleta CPU / memória / disco / GPU NVIDIA pra um endpoint custom (debug page interna, /oncall, etc.). Sem export Prometheus integrado — o objetivo é dar a foto instantânea.

Use o #1 em produção sempre. Adicione o #2 quando precisar inspecionar o host onde a app roda.

#1 Prometheus HTTP — extra [prometheus]

Instale com [prometheus] (puxa prometheus-client). O middleware mede toda request; o router serve o endpoint de scrape.

uv add "tempest-fastapi-sdk[prometheus]>=0.151.1"
# src/api/app.py
from fastapi import FastAPI

from tempest_fastapi_sdk import (
    PrometheusMiddleware,
    make_prometheus_registry,
    make_prometheus_router,
    make_token_dependency,
    register_exception_handlers,
)


def create_app(metrics_token: str) -> FastAPI:
    app = FastAPI(title="my-service")
    register_exception_handlers(app)

    registry = make_prometheus_registry()

    app.add_middleware(PrometheusMiddleware, registry=registry)
    app.include_router(
        make_prometheus_router(
            registry=registry,
            dependencies=[make_token_dependency(metrics_token)],
        )
    )
    return app

O make_prometheus_registry() cria um registry isolado por app, o que evita colisão com outros usuários globais do prometheus-client. O make_token_dependency(metrics_token) protege o scrape por header: sem X-Token, ou com o valor errado, o GET /metrics responde 401; com X-Token: <metrics_token>, responde 200. O segredo viaja no header, nunca na URL, então não cai no log de acesso. Leia o metrics_token do seu Settings, não escreva no código.

Sem dependencies=, o /metrics é público

O default do make_prometheus_router é sem autenticação nenhuma: make_prometheus_router(registry=registry) responde 200 a qualquer um que alcance a porta, e os labels expõem o mapa de rotas do serviço. Monte sem dependencies= só quando a porta já está numa rede privada que só o Prometheus alcança.

Segredo vazio desliga a checagem

make_token_dependency("") não protege nada: com secret vazio, o GET /metrics responde 200 sem header nenhum. É de propósito, para dev local, mas um METRICS_TOKEN esquecido no ambiente de produção deixa o endpoint aberto em silêncio. Garanta que o valor não é vazio no boot.

Cada item de dependencies= é a função, ou um Depends(...) pronto

Passe a dependency crua ([make_token_dependency(token)]) — o router envolve em Depends sozinho. Um Depends(...) / Security(...) já montado também é aceito e anexado como está, sem ser envolvido de novo. O que não serve é o require_x_token: ele é a variante imperativa (require_x_token(secret, token)), e o FastAPI leria secret e token da query string — todo scrape viraria 422, e o único jeito de passar seria colocar o segredo na URL. Por isso o router recusa na construção:

TypeError: require_x_token is not a FastAPI dependency: its 'secret' and 'token' parameters would be read from the query string. Pass make_token_dependency(secret) instead, which reads the X-Token header.

Pronto. Depois de duas requests a uma rota GET /api/users/{user_id}, o GET /metrics com o header devolve (trecho da saída real; as linhas ..._created e os demais buckets foram cortados, e o in_progress conta o próprio scrape):

# HELP http_requests_total HTTP requests by method, route template, and response status.
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/api/users/{user_id}",status="200"} 2.0
...
# HELP http_request_duration_seconds HTTP request latency by method and route template (seconds).
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.005",method="GET",path="/api/users/{user_id}"} 2.0
...
http_request_duration_seconds_count{method="GET",path="/api/users/{user_id}"} 2.0
...
# HELP http_requests_in_progress HTTP requests currently being handled.
# TYPE http_requests_in_progress gauge
http_requests_in_progress{method="GET"} 1.0

Buckets padrão (DEFAULT_LATENCY_BUCKETS) cobrem 5ms → 10s — adequado pra APIs típicas. Sobrescreva com PrometheusMiddleware(registry=..., latency_buckets=(0.001, 0.005, 0.025, 0.1, 0.5, 2, 10)) quando seu workload é mais granular.

Path normalization

O path label usa o template da rota (/api/users/{user_id}), não o path concreto, pra não explodir a cardinalidade com UUIDs únicos. Isso vem do FastAPI/Starlette — você não precisa configurar nada.

Scrape config

prometheus.yml — o http_headers manda o X-Token em todo scrape, lido de um arquivo para o segredo não ficar no YAML:

scrape_configs:
  - job_name: my-service
    metrics_path: /metrics
    http_headers:
      X-Token:
        files: ["/etc/prometheus/metrics_token"]
    static_configs:
      - targets: ["my-service:8000"]

Medido no Prometheus v3.1.0, raspando o app acima: o job com http_headers fica up == 1; um job igual sem o bloco fica up == 0, com server returned HTTP status 401 Unauthorized na página de targets.

Ou em compose (o arquivo metrics_token tem o mesmo valor do METRICS_TOKEN):

services:
  my-service:
    image: ...
    environment:
      METRICS_TOKEN: ${METRICS_TOKEN}
    ports: ["8000:8000"]
  prometheus:
    image: prom/prometheus:v3.1.0
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./metrics_token:/etc/prometheus/metrics_token:ro
    ports: ["9090:9090"]

Métricas de negócio — BusinessMetrics

O middleware cobre HTTP (RED). Pras métricas do seu domínio — pedidos, profundidade de fila, duração de job — use BusinessMetrics: uma fábrica tipada de Counter / Gauge / Histogram ligada ao mesmo registry, então tudo sai no mesmo GET /metrics.

import asyncio

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import (
    BaseRepository,
    BusinessMetrics,
    make_prometheus_registry,
)

from src.db.models import UserModel

# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))

elapsed = 0.42

repo = BaseRepository(session, model=UserModel)


registry = make_prometheus_registry()
metrics = BusinessMetrics(registry, namespace="shop")

orders = metrics.counter("orders_total", "Pedidos criados", labelnames=["status"])
queue = metrics.gauge("queue_depth", "Itens na fila")
job = metrics.histogram("job_seconds", "Duração do job", buckets=[0.1, 1, 10])

orders.labels(status="paid").inc()


async def main() -> None:
    """Run this example."""
    queue.set(await repo.count({"status": "pending"}))
    job.observe(elapsed)


asyncio.run(main())

Sai no /metrics como shop_orders_total{status="paid"}, shop_queue_depth, shop_job_seconds_bucket. Criar duas vezes com o mesmo nome devolve a mesma métrica (sem Duplicated timeseries em reload/testes).

Sem mágica

Os objetos retornados são os Counter/Gauge/Histogram de verdade do prometheus_client — .inc() / .set() / .observe() / .labels(...) funcionam como na doc oficial. O BusinessMetrics só amarra ao registry e ao namespace, sem esconder nada.

#2 Snapshot de sistema — extra [metrics]

MetricsUtils coleta uso de CPU, memória, disco e GPU NVIDIA via psutil + pynvml. Todo método tem variante sync e async (o wrapper async roda o mesmo código via asyncio.to_thread). A amostragem de GPU degrada graciosamente para [] quando pynvml ou os drivers da NVIDIA estão ausentes.

Instale com [metrics].

# src/api/routers/system.py
from typing import Any

from fastapi import APIRouter

from tempest_fastapi_sdk import MetricsUtils

router = APIRouter()


@router.get("/system-metrics")
async def system_metrics() -> dict[str, Any]:
    """JSON snapshot. NOT the Prometheus endpoint — that one is /metrics."""
    snapshot = await MetricsUtils.snapshot_async(disk_paths=["/", "/data"])
    return snapshot.to_dict()

Não monte no path /metrics

Esse endpoint não é o do Prometheus — montar no mesmo path conflita com o make_prometheus_router quando os dois estão ativos. Use /system-metrics, /admin/sysinfo, ou algum prefixo restrito de oncall.

MetricsUtils.cpu(interval=...) bloqueia o event loop

A chamada sync gasta interval segundos amostrando — o wrapper cpu_async evita o bloqueio rodando em thread. Sempre prefira MetricsUtils.snapshot_async() em handlers.

Coletores individuais

import asyncio

from tempest_fastapi_sdk import MetricsUtils


async def main() -> None:
    """Run this example."""
    snapshot = await MetricsUtils.snapshot_async(disk_paths=["/"])

    print(snapshot.cpu.percent, snapshot.memory.percent)
    for disk in snapshot.disks:
        print(disk.path, disk.percent)
    for gpu in snapshot.gpus:
        print(gpu.name, gpu.utilization_percent, gpu.memory_used_bytes)


asyncio.run(main())

Os coletores individuais também estão disponíveis: MetricsUtils.cpu(interval=...), MetricsUtils.memory(), MetricsUtils.disk(path), MetricsUtils.disks(paths), MetricsUtils.gpus() — e suas variantes *_async. Cada um retorna uma dataclass tipada (CPUMetrics, MemoryMetrics, DiskMetrics, GPUMetrics, SystemMetrics) com um helper to_dict() para serialização JSON.

Um disco: use disk_async, não disks_async([path])

As duas variantes tratam erro de caminho de formas opostas, e a diferença aparece justamente num endpoint de métrica:

import asyncio

from tempest_fastapi_sdk import DiskMetrics, MetricsUtils


async def main() -> None:
    """Compare as duas leituras do mesmo caminho inexistente."""
    vazio = await MetricsUtils.disks_async(["/mnt/sumiu"])
    print(vazio)  # [] — o erro virou linha de log, e o payload sai sem o bloco

    try:
        disco: DiskMetrics = await MetricsUtils.disk_async("/mnt/sumiu")
    except FileNotFoundError as exc:
        print(f"propagou: {exc}")


asyncio.run(main())

disks_async loga e pula porque a lista é plural — um caminho ruim não deveria derrubar os outros quatro. Para um caminho só isso vira ausência silenciosa: o dashboard mostra "sem disco" onde havia falha de permissão ou mount que sumiu. Quem passa vários caminhos e quer falhar pede disks(paths, strict=True) (ou disks_async(paths, strict=True)).

Recap

  • Caminho #1 ([prometheus]) — PrometheusMiddleware + make_prometheus_router expõem séries RED/USE (http_requests_total, http_request_duration_seconds, http_requests_in_progress) num GET /metrics pronto pra scrape. É o que você liga em produção — com dependencies=[make_token_dependency(token)], porque sem isso o endpoint é público.
  • Caminho #2 ([metrics]) — MetricsUtils entrega um snapshot instantâneo de CPU / memória / disco / GPU num endpoint custom. Sem exporter Prometheus — é a foto sob demanda do host.

Próximos passos

  • Observabilidade — como combinar métricas com request-id, logging estruturado e o HTTPClient tipado.
  • Logging — logging estruturado, arquivos por nível e o endpoint /logs.