Skip to content

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:

PluginRespondeRegistaToken
metricsPluginQuanto, quão rápido, quantos em voo?GET /metrics + um par de hooks pre/afterMETRICS
healthPluginO processo está de pé? Está pronto para tráfego?GET /livez, GET /readyz—
tracingPluginOnde é que este pedido gastou o tempo, entre serviços?um par de hooks pre/after + um temporizador de flushTRACER
loggerPluginO que aconteceu, com que identificadores?nada de HTTPLOGGER

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 ​

ts
// 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:

ts
import { metricsPlugin } from '@basaltkit/fastify'

metricsPlugin() // serve GET /metrics como text/plain; version=0.0.4

Logo à partida obténs:

MétricaTipoLabels
http_requests_totalcountermethod, route, status
http_request_duration_secondshistogrammethod, route
http_requests_in_flightgauge—

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:

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

ts
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 devolve 200 só se todos passarem; caso contrário 503 com uma discriminação por check, para que um load balancer drene a instância em vez de lhe enviar tráfego.
json
// 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.

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

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

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

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

ts
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çãoTipoPredefiniçãoPara que serve
pathstring'/metrics'Tira o endpoint de scrape de um caminho que o teu ingress exponha publicamente
registryMetricsRegistryum novoPartilha um registry com código fora do HTTP (workers, jobs) para tudo renderizar num só endpoint
instrumentHttpbooleantruefalse 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çãoTipoPredefiniçãoPara que serve
helpstringo nome da métricaA linha # HELP na exposição
labelNamesstring[][]Chaves de label declaradas; mantém o espaço de valores limitado
bucketsnumber[]DEFAULT_BUCKETS (0,005 … 10)Só histogramas — ajusta ao teu intervalo real de latências

healthPlugin(options):

OpçãoTipoPredefiniçãoPara que serve
checksRecord<string, () => HealthReport | Promise<HealthReport>>{}Checks de readiness, corridos em paralelo; um report é { ok, detail? } e só o ok é serializado
livePathstring'/livez'Corresponde ao caminho da sonda de liveness do teu orquestrador
readyPathstring'/readyz'Corresponde ao caminho da sonda de readiness do teu orquestrador

tracingPlugin(options):

OpçãoTipoPredefiniçãoPara que serve
serviceNamestring'basalt'Nomeia o Tracer. O service.name do OTLP vem do exporter — define os dois
exporterSpanExporternenhumPara onde vão os spans concluídos; sem um, os spans são registados e descartados
tracerTracerconstruído com as opções acimaTraz o teu próprio Tracer (amostragem, relógio, gerador de ids)
flushIntervalMsnumber5000Cadência de exportação; o temporizador tem unref() e há um flush final no encerramento

new OtlpHttpExporter(options):

OpçãoTipoPredefiniçãoPara que serve
urlstring— (obrigatório)URL base do collector; o /v1/traces é acrescentado e a barra final removida
serviceNamestring'basalt'O resource.service.name reportado ao collector
headersRecord<string, string>{}Autenticação para um collector alojado
maxBatchnumber100Faz flush assim que o buffer atinge este tamanho
fetchImpltypeof fetchfetch globalInjeta um cliente (proxy, testes)

new Tracer(options):

OpçãoTipoPredefiniçãoPara que serve
exporterSpanExporternenhumDestino dos spans concluídos
serviceNamestring'basalt'Nome levado pelo tracer
sampledbooleantruefalse emite traceflags: 00, dizendo aos serviços a jusante para não amostrarem
clock() => numberDate.nowRelógio injetável (testes)
idGenerator{ traceId(): string; spanId(): string }hex aleatórioIds determinísticos em testes

loggerPlugin(options):

OpçãoTipoPredefiniçãoPara que serve
levelLogLevel'info'Severidade mínima; 'silent' desliga a saída por completo
prettybooleanfalseSaída legível para dev — requer o pino-pretty instalado
redactstring[][]Caminhos somados à lista de redação embutida, para os teus próprios campos com segredos
baseBindings{}Campos fixos em cada linha (service, version, região…)
destinationDestinationStreamstdoutEncaminha a saída para outro sítio — um ficheiro, um transport, um buffer de teste

Modos de falha e resolução de problemas ​

ErroCódigoHTTPQuando
UnknownTokenErrorDI_UNKNOWN_TOKENbootmetricsPlugin / healthPlugin / tracingPlugin registados sem adaptador, ou METRICS / TRACER / LOGGER resolvidos sem o seu plugin
— (falha de readiness)—503Um ou mais checks do /readyz devolveram ok: false ou lançaram; o corpo diz quais
Check lançouregistado [basalt:health] readiness check "<name>" failed:503A causa fica só no servidor — a resposta diz { ok: false } e mais nada
Error: unable to determine transport target for "pino-pretty"—bootpretty: 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 /metrics devolve 404 — não foi registado adaptador nenhum, o boot() nunca foi chamado, ou o path foi alterado. A ordem dos plugins não é a causa: cada register corre antes de qualquer boot.
  • Os spans chegam atribuídos a basalt — o serviceName foi definido no tracingPlugin mas não no exporter. É o serviceName do exporter que preenche o resource.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 req sem serializer. Acrescenta o caminho explícito com redact, ou regista campos simples; e se era um corpo de email, isso é o logBody do mailer, em Notificações.
  • O /readyz fica pendurado — um check não tem timeout próprio. Envolve as dependências lentas com um; o healthPlugin aguarda o que lhe deres.

Para a checklist de deployment que junta tudo isto, vê Ir para Produção.

Publicado sob a licença MIT.