Observabilidade
Como ver o que está acontecendo agora, em qualquer nível: se a instância está de pé (as rotas públicas de health), o que ela acabou de atender (o log ao vivo), como está sendo o dia (métricas e insights), o que merece acordar você (alertas) e por quanto tempo cada um desses dados continua existindo (retenção).
As rotas de health são públicas porque é o balanceador que as consulta. O tráfego chega ao painel por um WebSocket que empurra cada request proxied. Sentry, OpenTelemetry e o webhook de alertas só inicializam quando a env var existe — o default de todos é desligado, com zero overhead. O que liga cada um está em Variáveis de ambiente.
O que existe para olhar#
Quatro telas de leitura — todas abertas a qualquer autenticado e agrupadas na seção Monitoramento do menu —, uma API que serve os mesmos dados por HTTP, rotas públicas de health e um WebSocket. Cada superfície responde uma pergunta diferente:
| Superfície | Onde | O que responde |
|---|---|---|
| Visão geral | /app/overview | KPIs e o feed de tráfego em tempo real — o primeiro lugar quando algo está estranho e você ainda não sabe o quê |
| Logs de requisições | /app/requests | O log de cada request, com filtros, detalhe, export e realtime |
| Métricas | /app/metrics | Agregados de tráfego por janela |
| Insights | /app/insights | Leitura analítica do tráfego |
| API de painel | /api/requests/*, /api/metrics/*, /api/stats | Os mesmos dados por HTTP, para scripts e dashboards |
| Health público | /api/health, /api/health/db, /api/pool-stats | Prontidão da instância, sem credencial |
| Realtime | /ws/requests | Cada request proxied empurrada ao painel ao vivo |
A fronteira de papel fica nos agregados de infraestrutura: /api/stats, /api/metrics/health, /api/metrics/counters e /api/cleanup/stats exigem ADMIN — e, na API de requests, os agregados api-key-stats e stats/by-group também. Os agregados de tráfego e o log de requests são leitura de MEMBER; a lista completa de rotas e papéis está na API de painel.
Health e prontidão#
As três rotas abaixo são públicas, sem preHandler de auth: é o balanceador e o monitor externo que as consultam, sem credencial nenhuma.
- GET
/api/health - GET
/api/health/db - GET
/api/pool-stats
GET /api/health
A sonda geral: faz SELECT 1 no Postgres e client.ping() no Redis. O payload devolve:
| Campo | Conteúdo |
|---|---|
status | ok, degraded ou unhealthy |
timestamp e uptime | O momento ISO da resposta e o process.uptime() |
version e commit | A versão do build e o SHA curto do commit — nunca branch, nunca mensagem. É o GIT_SHA do build-arg; o gate assert-build-fresh confere esse valor contra a sua ref (veja Operação) |
cookieSecret | env ou derived — só o modo, nunca o valor |
rateLimitHits429 | O contador de 429 da instância |
errors | Os contadores de erro mais o lastDbError sanitizado |
db e redis | O status, a latencyMs e o error (quando houver) de cada dependência |
Quando devolve 503: quando o Postgres ou o Redis está fora — e o 503 vem com payload, não com corpo vazio. Agora, o que não gera 503: dbLogErrors > 0 derruba o status para degraded mantendo HTTP 200. E o dbLogDropped não degrada nem entra no cálculo — descartar o payload de um request que o sweeper já varreu é o comportamento correto; incluí-lo mantinha o /health em degraded com dbLogErrors: 0, o serviço são e o status dizendo que não.
O lastDbError só aparece fora de produção. O motivo está no código: um P1001 expõe host e porta (a mensagem canônica é Can't reach database server at 127.0.0.1:5432), e numa falha de conexão com DSN o fragmento pode conter usuário e senha.
GET /api/health/db
O SELECT 1 isolado, sem Redis no caminho: em sucesso devolve status, timestamp e latencyMs; em erro devolve 503 com latencyMs: 0 e a mensagem crua do banco. É a rota para separar uma queda de Postgres de uma queda de Redis.
GET /api/pool-stats
Dois blocos: http e https, cada um com sockets, freeSockets e requests do agente HTTP; e upstreamAdmission por host, com inFlight, queued e max, mais o timestamp.
A semântica que importa: queued > 0 significa que o teto de concorrência do upstream — e não o orçamento de RPM — é o que está ritmando o tráfego. É a leitura que separa “estou sem chave disponível” de “estou sem socket disponível”.
O tráfego ao vivo#
Cada request que o proxy atende vira um evento num WebSocket único, compartilhado pelo app inteiro. Três formas de ler esse fluxo: o feed da Visão geral, a tabela de Logs de requisições e a API de requests.
O WebSocket /ws/requests
Uma conexão só para o app inteiro, aberta pelo RealtimeProvider dentro do AuthGuard — sem sessão, não há socket. O provider faz fan-out: o feed da Visão geral e a tabela de Requests leem o mesmo socket; nenhum consumidor abre o seu.
São dois caminhos com o mesmo handler: /ws/requests, sem o prefixo, e /api/ws/requests, para quando o reverse proxy (easypanel/nginx) faz strip do /api. O protocolo tem três mensagens do servidor e uma do cliente:
servidor → cliente {type:'hello' | 'request' | 'ping'}
cliente → servidor {type:'ping'}
O keepalive é um ping do servidor a cada 25 s — proxies e LBs costumam matar conexão idle por volta de 60 s — e cada ping revalida sessão, org, usuário, papel e URLs. Se a sessão sumir ou trocar de org/usuário, o servidor fecha o socket com o código 1008. O token CSRF vai na query string do upgrade: o upgrade é um GET e o browser anexa o cookie a partir de qualquer site, e atrás do proxy do server.js a Origin pode não estar em CORS_ORIGINS.
No cliente, o reconnect faz backoff de 2 s → 4 s → 8 s até o teto de 15 s e, após 8 falhas consecutivas, desiste em vez de floodar o console. O socket é preso à organização da sessão: na troca de org ele fecha e outro abre na hora, sem esperar o backoff — senão o feed ficaria mudo por até 15 s. E o onmessage não faz setState por evento: um render por request travava a tela de logs com o tráfego de vários agentes.
A tela /app/requests
O log é paginado no servidor com PAGE_SIZE = 25, e a página montada vai na query string. Os filtros vivem na URL — filtersToParams escreve, filtersFromParams lê de volta — então o recorte que você montou é um link compartilhável:
| Filtro | Valores |
|---|---|
| Status HTTP | 2xx, 3xx, 4xx, 401, 403, 429, 5xx, 500, 503 |
| Resultado | Somente com sucesso · Somente com falha |
| Streaming | Somente streaming · Somente sem streaming |
| Chave, modelo e status | Um seletor cada — “Qualquer chave”, “Qualquer modelo”, “Qualquer status” |
Com itens selecionados aparece a BulkBar, com CSV, JSON, Excluir e Limpar. A exportação em lote sobe o limite para 10.000 registros, contra os 25 da tela; pela API, o /api/requests/export exporta CSV, JSON ou NDJSON e é rate-limited por IP — o teto vem como 429 com Retry-After: 60.
No realtime, a página liga no mesmo socket por useRealtimeRequests: o que chega é acumulado e pintado em lote a cada LIVE_FLUSH_MS = 1000, as estatísticas refazem a cada STATS_REFETCH_MS = 5000, e as linhas recém-chegadas ficam destacadas — até 2500 ids.
O fluxo de leitura mais comum, do sintoma ao dado exportado:
-
Abra o recorte
Entre em
/app/requests, filtre por status, resultado, streaming, chave ou modelo e copie a URL — o filtro está nela. A listagem vem paginada; o resumo agregado e os valores dos filtros existem como rotas próprias —/api/requests/summarye/api/requests/facets— se você quiser montar a sua própria leitura. -
Investigue uma request
O detalhe de uma linha chama
GET /api/requests/:id, que descomprime os corpos armazenados — rate-limited por IP, como o export. -
Leve o dado embora
Selecione na tela e use a
BulkBarpara exportar até 10.000 registros em CSV ou JSON — ou chame o/api/requests/exportdiretamente, respeitando oRetry-After: 60do 429.
As rotas de requests por baixo
A tela e qualquer script autenticado leem as mesmas dez rotas — todas dentro do escopo da organização da sessão:
| Rota | O que devolve |
|---|---|
GET /api/requests | Lista paginada com filtros e sort |
GET /api/requests/summary | Resumo agregado dos filtros em uso |
GET /api/requests/facets | Valores distintos para preencher os filtros |
GET /api/requests/:id | Detalhe de uma request; descomprime os corpos — rate-limited por IP |
GET /api/requests/export | CSV, JSON ou NDJSON — rate-limited por IP (429 + Retry-After: 60) |
GET /api/requests/stats | Stats com apiKeyId e timeRange (default 24 h) |
GET /api/requests/stats/by-config | Agregado por customUrl |
GET /api/requests/stats/by-group | Agregado por RequestConfigGroup ativo (ADMIN) |
GET /api/requests/api-key-stats | Stats por chave de provedor (ADMIN) |
DELETE /api/requests | Apaga requests por ids (CSV na querystring) |
A tela /app/overview
A Visão geral é a leitura de relance: os KPIs em KpiCard e o feed ao vivo no ActivityFeed, pendurados no mesmo socket. É a primeira tela quando o tráfego está estranho e você ainda não sabe por onde começar.
Métricas e agregados#
Quando a pergunta deixa de ser “o que acabou de acontecer?” e passa a ser “como está sendo o dia?”, os agregados estão nestas rotas. As telas dão a leitura visual; qualquer script autenticado lê as mesmas por HTTP:
| Rota | O que devolve |
|---|---|
GET /api/metrics/advanced | Métricas agregadas — days, default 7 |
GET /api/metrics/latency | Latência |
GET /api/metrics/costs | Custos |
GET /api/metrics/activity | Série de atividade (bucket/points) |
GET /api/metrics/models | Uso por modelo |
GET /api/metrics/insights | Insights derivados |
GET /api/metrics/health | Saúde das chaves (ADMIN) |
GET /api/metrics/counters | Snapshot dos contadores da instância (ADMIN) |
GET /api/stats | Estado do pool: status, uso e cooldown de cada chave (ADMIN) |
As telas: /app/metrics — agregados de tráfego, carregada por useMetrics — e /app/insights — a leitura analítica, montada pela feature de insights. As duas são abertas a qualquer autenticado; os três agregados de infraestrutura da tabela exigem ADMIN.
O rollup horário
Um job interno de 5 min mantém o agregado horário: ele agrupa a tabela requests na request_metrics_hourly, e só em horas fechadas — a hora em andamento não entra. A gravação é ON CONFLICT … DO UPDATE, idempotente por sobrescrita, e o lock Redis requestmetrics:lock (TTL de 240 s) impede que duas instâncias agreguem a mesma hora. No boot, o lookback é REQUEST_METRICS_ROLLUP_LOOKBACK_HOURS = 24 — e só no boot.
O estado das chaves
Para “por qual chave o tráfego está passando — e por que algumas saíram?”, a leitura é o GET /api/stats: status, uso e cooldown de cada chave do pool. Na tela /app/keys, o filtro Desativadas por erro separa as chaves que o auto-disable derrubou sozinho das desligadas à mão, e a categoria do erro diz de quem foi a culpa:
| Categoria | Exemplos | Efeito na chave |
|---|---|---|
temporary | 429, 5xx, wrappers de gateway (“Backend request failed”, “Provider returned error”) | Cooldown — nunca desativa |
request_error | 400 de input longo, 404 de modelo, 413, 422, content-blocked | Nunca desativa; fica registrada e filtrável |
key_error | 401 de chave inválida, 403 “Key limit exceeded”, 402 sem saldo | Desativa |
Antes de culpar as chaves, confira o guard: se o mesmo par (status + mensagem normalizada) atingir SAME_ERROR_GUARD_THRESHOLD — default 3 — chaves distintas dentro de uma mesma request, o problema é a request (modelo digitado errado, tool schema malformado), e o LB reverte todas as desativações daquela request com o motivo guard_revert. Chave que voltou com esse motivo não estava quebrada.
Alertas e log#
O log estruturado é Pino, os erros e traces saem por Sentry ou OpenTelemetry, e os alertas operacionais são um webhook. Todas as peças desta seção são ligadas por env var:
| Variável | Default | O que controla |
|---|---|---|
SENTRY_DSN | '' — desligado | Liga o Sentry; vazia = off |
SENTRY_ENVIRONMENT | '' ⇒ NODE_ENV | O environment do Sentry |
SENTRY_TRACES_SAMPLE_RATE | '0' | 0 = só erros, sem tracing |
OTEL_EXPORTER_OTLP_ENDPOINT | '' — desligado | Liga o OpenTelemetry |
OTEL_SERVICE_NAME | 'ai-proxy-loadbalancer' | O serviceName do OTel |
LOG_LEVEL | 'info' | O nível do Pino |
LOG_ENABLED | 'false' | O logger do Fastify — OFF por padrão em produção |
DEBUG_LOGGING | 'false' | Log verboso |
ALERT_WEBHOOK_URL | '' — desligado | O webhook de alertas; vazia = off |
ALERT_DEDUP_MS | '30000' | Janela de dedup por par (event, keyId); 0 desliga |
Está escrito no próprio bloco de env: cada integração só inicializa quando sua env está setada, sem import e sem hook. Sem SENTRY_DSN não há Sentry; sem OTEL_EXPORTER_OTLP_ENDPOINT não há SDK OTel; sem ALERT_WEBHOOK_URL não há POST de alerta.
Com ALERT_WEBHOOK_URL apontado, o LB dispara o webhook em quatro eventos:
[GUARD] — o same-error guard da seção anterior reverteu desativações em blocoOPENinvalid_grantkey_errorO payload é compatível com Slack incoming webhooks e com a Events API v2 da PagerDuty — os dois destinos mais comuns. E o ALERT_DEDUP_MS evita o bombardeio: a mesma combinação de evento e chave não dispara de novo dentro da janela de 30 s.
Retenção#
Três dados, três prazos — porque as perguntas e os custos são diferentes. A linha do log é barata e vale um mês; o corpo é caro e vale três dias; o audit precisa responder “quem fez isso” meses depois:
| Variável | Default | O que cobre |
|---|---|---|
RETENTION_DAYS | '30' | A janela de requests: o log que o painel lista e as APIs consultam |
REQUEST_PAYLOAD_RETENTION_HOURS | '72' | O corpo gzip e os metadados de cada request — janela menor, separada, porque o payload é caro |
AUDIT_RETENTION_DAYS | '365' | O audit log — responde “quem fez isso” meses depois |
A limpeza é um job de 6 h que também roda uma vez no boot. A cada tick ele chama cleanupExpiredRequests(), cleanupExpiredPayloads() e cleanupExpiredAuditLogs() — o audit não tem timer próprio: roda no mesmo tick, com a sua janela de 365 dias. Os deletes vão em lotes de CLEANUP_BATCH_SIZE, 1000 por padrão. Não existe cron externo: todo agendamento do LB é setInterval em processo (a lista completa dos jobs está em Operação).
Na prática, a leitura importa: depois de 72 horas a linha continua no painel, mas o corpo já não existe — o detalhe descomprime o que está guardado. Depois de 30 dias, a linha some; o audit fica um ano.
Duas rotas dão controle manual sobre a limpeza:
- POST
/api/cleanup/run - GET
/api/cleanup/stats
O /api/cleanup/run dispara na hora a limpeza de requests expiradas — é rate-limited. O /api/cleanup/stats (ADMIN) devolve as estatísticas da limpeza.
Próximos passos#
Agora você sabe onde olhar em cada situação. O resto do dia a dia de quem mantém o LB no ar — os outros jobs, o shutdown e o caminho real do deploy — está no guia de Operação.
Depois de dominar o básico, explore além: