Observabilidade
Métricas, sondas de saúde, tracing distribuído e logging estruturado vêm incluídos — sem bibliotecas de cliente, sem exporters a instalar, sem SDK do OpenTelemetry. Cada um é um plugin, e cada um assenta na costura HTTP_SERVER, neutra face à framework, em vez de no Fastify, por isso as mesmas quatro linhas funcionam em Fastify, Express e Hono. Usa esta página quando precisares de responder a "está de pé?", "quão rápido está?" e "o que aconteceu a este pedido?" em produção.
Modelo mental
Quatro plugins, quatro perguntas, um mecanismo partilhado:
| Plugin | Responde | Regista | Token |
|---|---|---|---|
metricsPlugin | Quanto, quão rápido, quantos em voo? | GET /metrics + um par de hooks pre/after | METRICS |
healthPlugin | O processo está de pé? Está pronto para tráfego? | GET /livez, GET /readyz | — |
tracingPlugin | Onde é que este pedido gastou o tempo, entre serviços? | um par de hooks pre/after + um temporizador de flush | TRACER |
loggerPlugin | O que aconteceu, com que identificadores? | nada de HTTP | LOGGER |
Os três primeiros resolvem HTTP_SERVER no seu boot() e registam-lhe hooks e rotas; o adaptador (fastifyPlugin / expressPlugin / honoPlugin) fornece esse token e monta tudo o que recolheu no app:booted. Como o register de cada plugin corre antes do boot de qualquer plugin, a ordem deles no array plugins é irrelevante — mas um adaptador tem de estar presente, e tens de chamar boot().
metricsPlugin, healthPlugin, tracingPlugin, METRICS e TRACER vivem em @basaltkit/http, e o @basaltkit/fastify re-exporta-os por conveniência — em Express e Hono importa-os diretamente de @basaltkit/http; os plugins em si são idênticos. As primitivas de métricas e spans (Counter, Gauge, Histogram, Tracer, os exporters) vêm de @basaltkit/core. O loggerPlugin é o seu próprio pacote, @basaltkit/logger, e não precisa de adaptador nenhum.
Ligar tudo em conjunto
// src/app.ts
import { createApp, OtlpHttpExporter } from '@basaltkit/core'
import { fastifyPlugin, FASTIFY, metricsPlugin, healthPlugin, tracingPlugin } from '@basaltkit/fastify'
import { loggerPlugin } from '@basaltkit/logger'
export const app = await createApp({
plugins: [
fastifyPlugin({ routes: [/* as tuas rotas */] }),
loggerPlugin({ level: 'info', base: { service: 'acme-api' } }),
metricsPlugin(), // GET /metrics
healthPlugin({ checks: { db: () => ({ ok: pool.isHealthy() }) } }), // GET /livez, /readyz
tracingPlugin({
serviceName: 'acme-api',
// define serviceName também no exporter — é esse que o OTLP reporta
exporter: new OtlpHttpExporter({ url: 'http://otel-collector:4318', serviceName: 'acme-api' }),
}),
],
}).boot()
await app.container.get(FASTIFY).listen({ port: 3000 })Os plugins de borda precisam do adaptador
O metricsPlugin, o healthPlugin e o tracingPlugin resolvem HTTP_SERVER durante o boot(). Sem um adaptador registado, essa resolução lança UnknownTokenError (DI_UNKNOWN_TOKEN) e a app recusa arrancar — uma falha ruidosa em vez de um /metrics silenciosamente ausente. E sem boot(), nada é montado de todo.
Não exponhas as sondas à internet
O /metrics revela a tua tabela de rotas, a forma do tráfego e as taxas de erro; o /readyz revela que dependências estão em baixo. Nenhum é autenticado. Liga-os a uma interface interna, restringe-os no ingress, ou tira-os do caminho público com metricsPlugin({ path }) / healthPlugin({ readyPath }). Vê Segurança.
Métricas — metricsPlugin
Expõe um endpoint Prometheus e auto-instrumenta cada pedido:
import { metricsPlugin } from '@basaltkit/fastify'
metricsPlugin() // serve GET /metrics como text/plain; version=0.0.4Logo à partida obténs:
| Métrica | Tipo | Labels |
|---|---|---|
http_requests_total | counter | method, route, status |
http_request_duration_seconds | histogram | method, route |
http_requests_in_flight | gauge | — |
Os pedidos são rotulados pelo template de rota que o adaptador reporta (/users/:id), nunca pelo URL em bruto, por isso a cardinalidade dos labels mantém-se limitada. Um pedido que não correspondeu a nenhuma rota é rotulado unknown — um único bucket, não uma série por URL 404. O histograma usa o conjunto de buckets predefinido (0,005 … 10 segundos); o http_requests_in_flight é incrementado num pre-hook e decrementado no after-hook, por isso também conta pedidos ainda a ser servidos — incluindo streams sse() abertos. É contado por pedido: um pedido respondido por um pre-hook anterior (um 429, um preflight de CORS) nunca é descontado, e uma resposta que o cliente abandonou é libertada, nos três adaptadores.
Passa instrumentHttp: false para manteres o endpoint e largares as séries HTTP automáticas, ou registry para partilhares um MetricsRegistry com código que corre fora do caminho do pedido (workers, filas).
Métricas personalizadas
Resolve o registry através do METRICS e regista as tuas próprias — são renderizadas no mesmo endpoint. O registry é um get-or-create por nome, por isso chamar counter() duas vezes com o mesmo nome devolve o mesmo instrumento em vez de o substituir:
import { METRICS } from '@basaltkit/fastify'
const jobs = container.get(METRICS).counter('jobs_processed_total', {
help: 'Background jobs processed',
labelNames: ['queue'],
})
jobs.inc({ queue: 'emails' })
// os histogramas aceitam buckets explícitos quando as predefinições não servem
const render = container.get(METRICS).histogram('render_seconds', {
help: 'Template render time',
buckets: [0.001, 0.005, 0.02, 0.1],
})
render.observe(0.004)Counter, Gauge e Histogram também são exportados de @basaltkit/core para uso em qualquer lado — renderizam o formato de exposição de texto Prometheus diretamente. Mantém os labelNames poucos e limitados: um label cujo valor seja um id de utilizador ou um URL é a causa habitual de um backend de métricas ir abaixo.
Sondas de saúde — healthPlugin
Liveness e readiness são deliberadamente distintas, e falham de forma diferente:
import { healthPlugin } from '@basaltkit/fastify'
healthPlugin({
checks: {
db: () => ({ ok: pool.isHealthy(), detail: 'primary' }),
redis: async () => ({ ok: await redis.ping().then(() => true).catch(() => false) }),
},
})GET /livez— devolve{ "status": "ok" }incondicionalmente. Nunca toca numa dependência, por isso uma base de dados lenta não pode desencadear um loop de reinício.GET /readyz— corre todos os checks em paralelo e devolve200só se todos passarem; caso contrário503com uma discriminação por check, para que um load balancer drene a instância em vez de lhe enviar tráfego.
// GET /readyz → 503
{ "status": "unavailable", "checks": { "db": { "ok": false }, "redis": { "ok": true } } }O detail é para os teus logs, nunca para a resposta da sonda
Um check pode devolver { ok, detail }, mas só o ok é serializado — o corpo da resposta leva passa/falha por check e mais nada. Um check que lança é apanhado, registado no servidor como [basalt:health] readiness check "<name>" failed: com a causa, e reportado como { ok: false }. Ambas as regras existem para que uma sonda não autenticada não possa ser transformada num endpoint de reconhecimento que vaza fragmentos de DSN, hostnames ou portos.
Mantém os checks baratos e limitados — correm em cada sonda, e um check sem timeout próprio mantém o /readyz aberto durante todo o tempo em que a dependência pendurar.
Tracing distribuído — tracingPlugin
Tracing zero-dependências que fala W3C trace-context e exporta OTLP/JSON para qualquer collector OpenTelemetry — sem OTel SDK necessário.
import { tracingPlugin } from '@basaltkit/fastify'
import { OtlpHttpExporter } from '@basaltkit/core'
tracingPlugin({
serviceName: 'acme-api',
exporter: new OtlpHttpExporter({
url: 'http://otel-collector:4318', // o /v1/traces é acrescentado por ti
serviceName: 'acme-api',
headers: { authorization: `Bearer ${process.env.OTEL_TOKEN}` },
maxBatch: 100,
}),
flushIntervalMs: 5000,
})Por pedido, o plugin continua um traceparent de entrada (ou inicia um novo trace), regista um span de servidor chamado ${method} ${templateDeRota} com os atributos http.method / http.target (valores da query mascarados como [REDACTED], para que códigos OAuth e tokens nunca cheguem ao teu backend de traces), ecoa traceparent na resposta, e no fim define http.status_code, marca o span como error para 5xx e ok caso contrário, e termina-o. Os spans concluídos são acumulados e enviados a cada flushIntervalMs (o temporizador tem unref()) mais uma vez no app.shutdown().
O serviceName define-se em dois sítios
O tracingPlugin({ serviceName }) nomeia o Tracer. O nome que de facto aterra no resource.service.name do payload OTLP vem do serviceName do exporter, cuja predefinição é 'basalt'. Define-o nos dois, ou os teus spans chegam ao collector atribuídos a basalt.
Resolve o TRACER para envolver o teu próprio trabalho em spans — o inSpan coloca o span em AsyncLocalStorage, por isso tudo o que arranque lá dentro é automaticamente um filho:
import { TRACER } from '@basaltkit/fastify'
const tracer = container.get(TRACER)
await tracer.inSpan(tracer.startSpan('charge.capture', { kind: 'client' }), async () => {
await gateway.capture(/* … */)
})Para desenvolvimento local troca por ConsoleSpanExporter (uma linha por span); em testes, InMemorySpanExporter recolhe spans para asserções — vê Testes. As falhas de exportação são engolidas de propósito: o tracing nunca pode partir o caminho do pedido, por isso um collector morto custa-te spans, não pedidos.
Logging — loggerPlugin
O @basaltkit/logger envolve o Pino: logs JSON estruturados, campos de contexto por pedido injetados automaticamente, e redação de segredos ligada por predefinição.
import { loggerPlugin, LOGGER } from '@basaltkit/logger'
loggerPlugin({
level: 'info', // um de LOG_LEVELS; 'silent' desliga o logging
pretty: true, // saída legível para dev (precisa de pino-pretty)
redact: ['user.ssn'], // caminhos extra, somados às predefinições
base: { service: 'api' }, // campos fixos em cada linha
})
// em qualquer lado:
container.get(LOGGER).info({ orderId }, 'order placed')Cada linha leva automaticamente o que o contexto ativo tiver: requestId, correlationId, traceId, userId e tenantId — mais tenant.id / user.id promovidos a tenantId / userId quando só os objetos estão definidos. Nunca os passas numa chamada de log. Fora de um contexto (um log de boot, um script) o mixin não contribui com nada em vez de lançar.
A redação está ligada por predefinição, a qualquer profundidade
Cada objeto registado e cada binding de um child logger é percorrido recursivamente (dentro de arrays, em Errors registados, até 10 níveis; o que for mais fundo passa a [Truncated], os ciclos passam a [Circular]) e qualquer chave que transporte um segredo é substituída por [REDACTED]. As chaves são comparadas sem distinguir maiúsculas nem separadores, por isso access_token, accessToken, Access-Token e ACCESS_TOKEN são a mesma. Os nomes cobertos incluem password, passwordHash, secret, token, jwt, otp, mfaCode, apiKey, privateKey, authorization, cookie, set-cookie, credentials, connectionString, creditCard, cardNumber, cvv, cvc e ssn, mais qualquer chave que termine em password, secret, token, apiKey, privateKey, authorization ou cookie (x-api-key, client_secret, mfaSecret, webhookSecret, APP_SECRET, refresh_token, id_token, proxy-authorization…). Os teus objetos nunca são alterados. O redactacrescenta caminhos Pino para os teus próprios campos (customer.iban); nunca substitui as predefinições.
O percurso segue o que a serialização JSON emitiria: objetos simples, arrays, erros (incluindo cause aninhados e membros de AggregateError), valores com toJSON() (p. ex. AxiosHeaders do axios) e instâncias de classe (as suas próprias chaves enumeráveis). A única exceção é uma instância de classe registada nas chaves de topo req/res, que fica para os serializers do Fastify/pino-http. Nomes no plural e em forma de chave também são cobertos (tokens, apiKeys, passwords, signingKey, encryptionKey, recoveryCodes, sessionId…). Segredos dentro de valores string (um DATABASE_URL, uma mensagem) não são detetados — regista os campos de que precisas ({ url: req.url }), não objetos de pedido inteiros nem strings em bruto.
Os corpos de email são um problema à parte com um interruptor à parte: o driver log do mailer redige os corpos das mensagens em produção porque levam links de reset e magic links. Isso é o logBody no mailerPlugin, não uma opção do logger — vê Notificações.
Os níveis de log são tipados
O level é a união LogLevel — não uma string livre — por isso um erro de escrita não compila. Do mais para o menos severo:
'fatal' · 'error' · 'warn' · 'info' (default) · 'debug' · 'trace' · 'silent'
Reutiliza o mesmo tipo e valores (LogLevel / LOG_LEVELS) nas tuas próprias opções e na validação do env, para um nível errado ser apanhado no código e no boot:
import { z } from 'zod'
import { defineEnv } from '@basaltkit/env'
import { LOG_LEVELS, type LogLevel } from '@basaltkit/logger'
// env — um LOG_LEVEL inválido é rejeitado no arranque
const env = defineEnv({ LOG_LEVEL: z.enum(LOG_LEVELS).default('info') })
// a tua própria opção — um nível inválido é erro de compilação
interface BuildAppOptions { logLevel?: LogLevel }O 'silent' desliga toda a saída — útil para comandos de CLI e testes. Faz parte do LogLevel (o tipo Level do próprio Pino omite-o). Vê Configuração para a canalização do env.
Erros HTTP — onError no adapter
Um pedido que falha é reportado pelo adapter: 5xx para error levando o objeto do erro (a stack é o que interessa — é um bug) e 4xx para warn com o código e a razão (a stack de uma falha de validação é ruído). Ambos passam pela mesma política no Fastify, no Express e no Hono.
Os relatos são estruturados, nunca uma frase interpolada: o sink é chamado como (fields, message) — a assinatura do próprio pino — em que message é sempre um literal e os dados do pedido vivem em fields. Isto é uma propriedade de segurança tanto como de formatação: um %s ou uma quebra de linha num URL nunca chega a uma format string, portanto a injeção de format string e a forja de logs são eliminadas em vez de escapadas.
fastifyPlugin({
routes,
onError: ({ error, status, code, method, url }) => {
logger.error({ err: error, status, code, method, url }, 'request failed')
},
})Passa () => {} para os silenciar. A mesma opção existe no expressPlugin e no honoPlugin.
Onde o default escreve. No Fastify é o logger do próprio Fastify, para os registos continuarem estruturados nas apps que configuraram pino — e a consola nas que não configuraram, já que um servidor criado com logger: false (o default do Fastify) instala um logger no-op que engoliria o relato. O Express e o Hono usam a consola.
Dois loggers, não um
O logger do Fastify e o @basaltkit/logger são sistemas separados. O token LOGGER é para o teu código; o default do adapter escreve no do Fastify. Definir um nível no loggerPlugin não muda o que o adapter reporta — liga o onError ao teu logger se os quiseres no mesmo sítio.
Os erros de cliente também são reportados, de propósito. Antes eram silenciosos, o que se defende para um 404 de um scanner e não serve de nada quando estás a tentar perceber porque é que o teu pedido voltou 400 sem nada no terminal. Se for ruidoso para ti, filtra no teu próprio reporter — a decisão é da app, não do default do framework.
O url reportado tem todos os valores da query mascarados (/cb?code=[REDACTED]): códigos OAuth, tokens de reset e assinaturas de URLs assinados viajam aí, e um log é o sítio errado para os guardar. Uma entrada sem valor (/magic?<token>) também é mascarada.
O corpo da resposta nunca muda: é o toErrorResponse que decide o que o cliente vê, e um 500 continua a dizer apenas Internal server error. Os erros de cliente levantados pela própria framework — JSON malformado, um corpo acima do limite, um content type não suportado — mantêm o seu status (400 BAD_REQUEST, 413 PAYLOAD_TOO_LARGE, 415 UNSUPPORTED_MEDIA_TYPE) com uma mensagem fixa, e são reportados em warn, não como erros do servidor. Um status trazido por qualquer outro erro (p. ex. uma chamada falhada a um SDK externo) não é confiado: esse continua a ser um 500. O reason registado é essa mesma mensagem fixa, porque a mensagem do próprio parser cita o corpo (o JSON.parse ecoa um pedaço dele, palavras-passe incluídas).
Correlação de pedidos
Cada pedido carrega um requestId e um correlationId no contexto, o que significa que aparecem em cada linha de log e podem ser propagados entre serviços. Reencaminha os cabeçalhos x-request-id / x-correlation-id de entrada nas chamadas de saída e um identificador acompanha uma ação do utilizador em cada salto; junta-lhe o traceparent que o tracingPlugin ecoa e consegues saltar de uma linha de log para o trace.
Um id de entrada só é adotado quando corresponde a ^[A-Za-z0-9._:-]{1,128}$; qualquer coisa mais longa, ou com espaços, aspas ou caracteres de controlo, é substituída por um UUID gerado de novo, para que um cliente não consiga forjar nem inchar o id que chega aos teus logs e à trilha de auditoria.
Os mesmos identificadores são o que torna legíveis as superfícies assíncronas: põe o teu logger por trás dos callbacks onBridgeError / onDeliveryError do realtime (Realtime), do onDead / onFlushError do outbox (Persistence) e do onError / onJobFailed das filas (Filas) em vez de os deixares no console.error. O onError do adapter HTTP (acima) é da mesma família.
Referência de opções
metricsPlugin(options):
| Opção | Tipo | Predefinição | Para que serve |
|---|---|---|---|
path | string | '/metrics' | Tira o endpoint de scrape de um caminho que o teu ingress exponha publicamente |
registry | MetricsRegistry | um novo | Partilha um registry com código fora do HTTP (workers, jobs) para tudo renderizar num só endpoint |
instrumentHttp | boolean | true | false mantém o /metrics mas larga as séries http_* automáticas |
Instrumentos do MetricsRegistry — counter(name, opts), gauge(name, opts), histogram(name, opts):
| Opção | Tipo | Predefinição | Para que serve |
|---|---|---|---|
help | string | o nome da métrica | A linha # HELP na exposição |
labelNames | string[] | [] | Chaves de label declaradas; mantém o espaço de valores limitado |
buckets | number[] | DEFAULT_BUCKETS (0,005 … 10) | Só histogramas — ajusta ao teu intervalo real de latências |
healthPlugin(options):
| Opção | Tipo | Predefinição | Para que serve |
|---|---|---|---|
checks | Record<string, () => HealthReport | Promise<HealthReport>> | {} | Checks de readiness, corridos em paralelo; um report é { ok, detail? } e só o ok é serializado |
livePath | string | '/livez' | Corresponde ao caminho da sonda de liveness do teu orquestrador |
readyPath | string | '/readyz' | Corresponde ao caminho da sonda de readiness do teu orquestrador |
tracingPlugin(options):
| Opção | Tipo | Predefinição | Para que serve |
|---|---|---|---|
serviceName | string | 'basalt' | Nomeia o Tracer. O service.name do OTLP vem do exporter — define os dois |
exporter | SpanExporter | nenhum | Para onde vão os spans concluídos; sem um, os spans são registados e descartados |
tracer | Tracer | construído com as opções acima | Traz o teu próprio Tracer (amostragem, relógio, gerador de ids) |
flushIntervalMs | number | 5000 | Cadência de exportação; o temporizador tem unref() e há um flush final no encerramento |
new OtlpHttpExporter(options):
| Opção | Tipo | Predefinição | Para que serve |
|---|---|---|---|
url | string | — (obrigatório) | URL base do collector; o /v1/traces é acrescentado e a barra final removida |
serviceName | string | 'basalt' | O resource.service.name reportado ao collector |
headers | Record<string, string> | {} | Autenticação para um collector alojado |
maxBatch | number | 100 | Faz flush assim que o buffer atinge este tamanho |
fetchImpl | typeof fetch | fetch global | Injeta um cliente (proxy, testes) |
new Tracer(options):
| Opção | Tipo | Predefinição | Para que serve |
|---|---|---|---|
exporter | SpanExporter | nenhum | Destino dos spans concluídos |
serviceName | string | 'basalt' | Nome levado pelo tracer |
sampled | boolean | true | false emite traceflags: 00, dizendo aos serviços a jusante para não amostrarem |
clock | () => number | Date.now | Relógio injetável (testes) |
idGenerator | { traceId(): string; spanId(): string } | hex aleatório | Ids determinísticos em testes |
loggerPlugin(options):
| Opção | Tipo | Predefinição | Para que serve |
|---|---|---|---|
level | LogLevel | 'info' | Severidade mínima; 'silent' desliga a saída por completo |
pretty | boolean | false | Saída legível para dev — requer o pino-pretty instalado |
redact | string[] | [] | Caminhos somados à lista de redação embutida, para os teus próprios campos com segredos |
base | Bindings | {} | Campos fixos em cada linha (service, version, região…) |
destination | DestinationStream | stdout | Encaminha a saída para outro sítio — um ficheiro, um transport, um buffer de teste |
Modos de falha e resolução de problemas
| Erro | Código | HTTP | Quando |
|---|---|---|---|
UnknownTokenError | DI_UNKNOWN_TOKEN | boot | metricsPlugin / healthPlugin / tracingPlugin registados sem adaptador, ou METRICS / TRACER / LOGGER resolvidos sem o seu plugin |
| — (falha de readiness) | — | 503 | Um ou mais checks do /readyz devolveram ok: false ou lançaram; o corpo diz quais |
| Check lançou | registado [basalt:health] readiness check "<name>" failed: | 503 | A causa fica só no servidor — a resposta diz { ok: false } e mais nada |
Error: unable to determine transport target for "pino-pretty" | — | boot | pretty: true sem o pino-pretty instalado |
| Perda silenciosa de spans | — | — | O exporter OTLP engole erros de transporte por design; um collector morto custa spans, nunca pedidos |
- O
/metricsdevolve 404 — não foi registado adaptador nenhum, oboot()nunca foi chamado, ou opathfoi alterado. A ordem dos plugins não é a causa: cadaregistercorre antes de qualquerboot. - Os spans chegam atribuídos a
basalt— oserviceNamefoi definido notracingPluginmas não no exporter. É oserviceNamedo exporter que preenche oresource.service.name. - Os traces param na fronteira do serviço — quem chamou não reencaminhou o
traceparent. O plugin ecoa-o nas respostas, mas os pedidos de saída são da tua responsabilidade. - O Prometheus vai abaixo depois de um deploy — um novo label leva um valor ilimitado (id de utilizador, caminho, mensagem de erro). As séries HTTP embutidas são seguras porque usam o template de rota; os instrumentos personalizados não são policiados.
- As linhas de log não têm
tenantId/userId— o log foi emitido fora de um contexto de pedido, ou a tenancy/auth ainda não o tinham preenchido. O mixin só contribui com o que o contexto já tem. - Apareceu um segredo nos logs — o nome da chave não é reconhecido como segredo, estava dentro de um valor string, ou estava num objeto de pedido em bruto registado na chave de topo
reqsem serializer. Acrescenta o caminho explícito comredact, ou regista campos simples; e se era um corpo de email, isso é ologBodydo mailer, em Notificações. - O
/readyzfica pendurado — um check não tem timeout próprio. Envolve as dependências lentas com um; ohealthPluginaguarda o que lhe deres.
Para a checklist de deployment que junta tudo isto, vê Ir para Produção.