Documentação do app v1.0

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).

Observabilidade embutida, não um APM externo

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ícieOndeO que responde
Visão geral/app/overviewKPIs 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/requestsO log de cada request, com filtros, detalhe, export e realtime
Métricas/app/metricsAgregados de tráfego por janela
Insights/app/insightsLeitura analítica do tráfego
API de painel/api/requests/*, /api/metrics/*, /api/statsOs mesmos dados por HTTP, para scripts e dashboards
Health público/api/health, /api/health/db, /api/pool-statsProntidão da instância, sem credencial
Realtime/ws/requestsCada 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

A sonda geral: faz SELECT 1 no Postgres e client.ping() no Redis. O payload devolve:

CampoConteúdo
statusok, degraded ou unhealthy
timestamp e uptimeO momento ISO da resposta e o process.uptime()
version e commitA 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)
cookieSecretenv ou derived — só o modo, nunca o valor
rateLimitHits429O contador de 429 da instância
errorsOs contadores de erro mais o lastDbError sanitizado
db e redisO 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.

A mensagem crua do banco não vaza em produçã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:

text
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:

FiltroValores
Status HTTP2xx, 3xx, 4xx, 401, 403, 429, 5xx, 500, 503
ResultadoSomente com sucesso · Somente com falha
StreamingSomente streaming · Somente sem streaming
Chave, modelo e statusUm 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:

  1. 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/summary e /api/requests/facets — se você quiser montar a sua própria leitura.

  2. 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.

  3. Leve o dado embora

    Selecione na tela e use a BulkBar para exportar até 10.000 registros em CSV ou JSON — ou chame o /api/requests/export diretamente, respeitando o Retry-After: 60 do 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:

RotaO que devolve
GET /api/requestsLista paginada com filtros e sort
GET /api/requests/summaryResumo agregado dos filtros em uso
GET /api/requests/facetsValores distintos para preencher os filtros
GET /api/requests/:idDetalhe de uma request; descomprime os corpos — rate-limited por IP
GET /api/requests/exportCSV, JSON ou NDJSON — rate-limited por IP (429 + Retry-After: 60)
GET /api/requests/statsStats com apiKeyId e timeRange (default 24 h)
GET /api/requests/stats/by-configAgregado por customUrl
GET /api/requests/stats/by-groupAgregado por RequestConfigGroup ativo (ADMIN)
GET /api/requests/api-key-statsStats por chave de provedor (ADMIN)
DELETE /api/requestsApaga 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:

RotaO que devolve
GET /api/metrics/advancedMétricas agregadas — days, default 7
GET /api/metrics/latencyLatência
GET /api/metrics/costsCustos
GET /api/metrics/activitySérie de atividade (bucket/points)
GET /api/metrics/modelsUso por modelo
GET /api/metrics/insightsInsights derivados
GET /api/metrics/healthSaúde das chaves (ADMIN)
GET /api/metrics/countersSnapshot dos contadores da instância (ADMIN)
GET /api/statsEstado 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:

CategoriaExemplosEfeito na chave
temporary429, 5xx, wrappers de gateway (“Backend request failed”, “Provider returned error”)Cooldown — nunca desativa
request_error400 de input longo, 404 de modelo, 413, 422, content-blockedNunca desativa; fica registrada e filtrável
key_error401 de chave inválida, 403 “Key limit exceeded”, 402 sem saldoDesativa

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ávelDefaultO que controla
SENTRY_DSN'' — desligadoLiga o Sentry; vazia = off
SENTRY_ENVIRONMENT'' ⇒ NODE_ENVO environment do Sentry
SENTRY_TRACES_SAMPLE_RATE'0'0 = só erros, sem tracing
OTEL_EXPORTER_OTLP_ENDPOINT'' — desligadoLiga 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'' — desligadoO webhook de alertas; vazia = off
ALERT_DEDUP_MS'30000'Janela de dedup por par (event, keyId); 0 desliga
Zero overhead por princípio

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:

  • Trips do [GUARD] — o same-error guard da seção anterior reverteu desativações em bloco
  • Circuit-breaker OAuth em OPEN
  • Falhas permanentes de OAuth — o refresh que voltou invalid_grant
  • Auto-disable de chave — uma chave virou inativa por key_error
  • O 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ávelDefaultO 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:

    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:

    Documentação gerada com a skill docs-padrao.