ADR-0015 Observabilidade
Norma da empresa para observabilidade — OpenTelemetry em todas as apps, exportado por OTLP com os dados sensíveis filtrados antes de saírem do processo, e GlitchTip para erros no browser. Onde a telemetria é guardada, vista e alertada fica fora da app.
- Decisão
- OpenTelemetry + GlitchTipEntregar e operar
- Estado
- Aceite
- Data
- Âmbito
- Predefinição da empresa para todas as novas apps
Ligações
Apoia-se em
Contexto
Seção intitulada “Contexto”Fazemos self-host em Docker (ADR-0003), por isso mais ninguém vê os nossos pedidos, erros ou queries lentas. Cada pedido atravessa vários saltos — middleware do Start (rate limiter, CSRF), server functions ou rotas da API, Better Auth, Valkey, Postgres — e um problema em qualquer um deles parece o mesmo ao utilizador: lento ou avariado.
Precisamos de:
- Traces ao longo desses saltos, para que um pedido lento aponte para a query ou a chamada Valkey que o atrasou
- Métricas para dashboards e alertas: taxa de pedidos, erros, latência, saúde do runtime, 429s
- Logs que ligam ao trace de onde vieram
- Erros no browser com stack traces legíveis — o servidor nunca vê um componente que falhou
- Endpoints de saúde para o Docker e o reverse proxy, que o ADR-0003 deixou a nosso cargo
Onde a telemetria é guardada, vista e alertada fica fora da app. A plataforma dá a cada app um endpoint OTLP, e o que corre por trás dele pode mudar sem que a app saiba. Este ADR cobre o que a app emite, o que nunca pode emitir e para onde vão os erros do browser.
Restrições:
- Instrumentação independente de fornecedor: as apps não devem mudar quando muda o backend
- Dados pessoais: a telemetria transporta IDs de utilizador e URLs; os tokens de magic link e de verificação viajam em query strings (ADR-0009). O que registamos é uma decisão de privacidade, e a app não pode contar com nada a jusante para o filtrar
- Todos os saltos têm de ser registados em trace: o OpenTelemetry tem instrumentações mantidas para ioredis, pino e
node:http, e o BullMQ e o TanStack AI emitem spans eles próprios. O servidor HTTP do Bun, oBun.SQLe ofetchdo Bun ainda não têm nenhuma (ADR-0035), pelo que somos nós a criar esses spans
Opções consideradas
Seção intitulada “Opções consideradas”Instrumentação
Seção intitulada “Instrumentação”- OpenTelemetry — o standard independente de fornecedor; um SDK para traces, métricas e logs; todos os backends correntes aceitam OTLP. Apache-2.0
- Um SDK de fornecedor (Sentry, Datadog) — menos configuração, mas prende cada app a esse fornecedor; o SDK de servidor do Sentry corre a sua própria configuração OTel
Onde se filtram os dados sensíveis
Seção intitulada “Onde se filtram os dados sensíveis”1. Num Collector ao lado da app
Seção intitulada “1. Num Collector ao lado da app”Prós: um só sítio para todos os serviços de um host, incluindo o reverse proxy; a app fica simples. Contras: a privacidade da app depende de infraestrutura fora dela; uma app que exporte para outro lado — um portátil, uma plataforma nova — envia os tokens tal como estão.
2. Na app, antes de exportar
Seção intitulada “2. Na app, antes de exportar”Prós: nada sensível sai do processo, seja quem for que o recebe; um teste na própria suite da app prova-o.
Contras: cada app leva o filtro no seu instrumentation.ts; o hook do SDK que usa ainda é experimental.
Erros no browser
Seção intitulada “Erros no browser”- GlitchTip + Sentry browser SDK — self-hosted, MIT; aceita o protocolo dos SDKs do Sentry; source maps; corre em Postgres com Valkey opcional. Sem session replay
- Sentry SaaS — o melhor agrupamento e replay, mas com preço por evento e dados fora da nossa infraestrutura
- Só OpenTelemetry — os erros no browser ficariam por acompanhar
Decisão
Seção intitulada “Decisão”OpenTelemetry em todas as apps, exportado por OTLP para o endpoint que a plataforma fornece, com os dados sensíveis filtrados na app antes de exportar; GlitchTip para erros no browser. Armazenamento, dashboards e alertas ficam fora da app e não são decididos aqui. Esta é a norma da empresa para todas as novas apps.
Em todas as apps
Seção intitulada “Em todas as apps”-
OTel Node SDK (
@opentelemetry/sdk-node) arranca antes do servidor: a imagem Docker arranca a app combun --preload ./instrumentation.js(ADR-0016), e esse ficheiro arranca o SDK. Nada específico da app corre antes dele. O Bun não tem hooks de loader ESM (module.registeré um no-op), pelo que as instrumentações aplicam patches apenas através dorequire-in-the-middle. (Alterado em 2026-11-13: este ADR usou primeiroNODE_OPTIONS="--import …"em Node; o ADR-0035 moveu o runtime para o Bun.) -
Todos os pontos de entrada o pré-carregam: o servidor web, o worker (ADR-0028) e o migrator, para que os jobs e as migrações sejam registados em trace como os pedidos
-
Instrumentar cada salto que um pedido ou job percorre, com uma instrumentação mantida onde funcione no Bun, e com código nosso onde não exista nenhuma:
Salto Spans e métricas vindos de Pedidos recebidos Nosso: trace, o primeiro middleware emsrc/start.ts— spanSERVER{method} {http.route}para páginas, server functions e rotas da API por igual,traceparentrecebido,http.server.request.durationServer functions Nosso: traceServerFn, o middleware global de funções (functionMiddlewareemsrc/start.ts) — um spanINTERNALpor chamada com o nome da função (serverFnMeta.name), comcode.function.nameecode.file.path. Os loaders das rotas chamam server functions, por isso o seu trabalho no servidor fica aquiPostgres ( Bun.SQL)Nosso: o traceSqlenvolve o cliente que o Drizzle recebe — um spanCLIENTpor query, também dentro de transações, edb.client.operation.durationValkey ( ioredis)@opentelemetry/instrumentation-ioredis, comdbStatementSerializerdefinido apenas com o nome do comando: as chaves guardam tokens de sessão, IPs e emailsfetchde saídaNosso: o instrumentation.tsenvolveglobalThis.fetch— um spanCLIENTpor chamada,traceparentinjetado,http.client.request.duration; as opçõesinitexclusivas do Bun, comoproxyetls, passam sem alteraçãonode:httpenode:httpsde saída@opentelemetry/instrumentation-http, para qualquer dependência que ignore ofetch; a instrumentação funciona no Bun desde a 1.4Jobs bullmq-otel: colocação em fila e processamento num só trace, mais métricas de fila (ADR-0028)Chamadas de IA O middleware OTel do TanStack AI: spans de execução, de volta do modelo e de tools, métricas de tokens, captura de conteúdo desligada (ADR-0025) Logs @opentelemetry/instrumentation-pino: os registos vão para a pipeline de logs OTel comtrace_idespan_idArmazenamento de objetos, SMTP Nosso: withSpan(name, fn)desrc/server/telemetry.ts, à volta de cada chamada emserver/storage/eserver/mail/— oS3Clientdo Bun e o Nodemailer não têm instrumentaçãoRuntime @opentelemetry/instrumentation-runtime-node, mantido apenas se a primeira app mostrar valores reais de event loop e de heap no Bun; o CPU e a memória do processo vêm das métricas do contentor em qualquer dos casos -
Não instrumentados, de propósito:
fs,dnsenet(um span por leitura de ficheiro ou por socket é ruído numa app web), e o browser (exportar de lá exige um endpoint OTLP público; o GlitchTip já cobre os erros no browser). O@8monkey/opentelemetry-instrumentation-bun-sqlexiste, mas a 0.2.0 fixa o@opentelemetry/core2.x enquanto o nosso SDK está na 3.x -
As nossas quatro peças —
trace,traceServerFn,traceSqle o wrapper dofetch— seguem as convenções semânticas, registamdb.query.textexatamente como o Drizzle o envia (placeholders, nunca valores de parâmetros) e são cobertas por um teste de integração: um span por chamada de server function, um por query, também dentro de uma transação, um porfetch, e nenhum valor de parâmetro nem query string em nenhum deles. Cada uma leva um comentárioshortcut:: apagá-lo quando oBun.otel(oven-sh/bun#39965) ou o tracing nativo do Start cobrir o mesmo salto -
O guia do próprio Start: o
tracee otraceServerFnseguem os padrões de request middleware e de function middleware do guia de Observability do TanStack Start, com duas diferenças: os spans têm o nome do padrão da rota e da função, nunca do caminho em bruto, e nenhum span regista um URL completo. O guia chama experimental à sua configuração de OpenTelemetry e anuncia instrumentação nativa para server functions, middleware e loaders de rotas; quando sair, substitui otracee otraceServerFn. O endpoint de métricas em memória e o health check verboso do guia não são usados — as métricas passam pelo OTel, e o/healthze o/readyznão expõem nada -
Os pacotes
@opentelemetry/*movem-se em conjunto — os pacotes do SDK são lançados em simultâneo -
Configuração através de variáveis
OTEL_*standard:OTEL_SERVICE_NAME,OTEL_EXPORTER_OTLP_ENDPOINT(definido pela plataforma; o contrato da app acaba aí),OTEL_RESOURCE_ATTRIBUTEScomdeployment.environment.nameeservice.versiondefinido como o git SHA. Sampling:parentbased_traceidratio, a começar em1.0e reduzido por produto quando o volume o exigir -
Nenhuma query string sai do processo. O middleware
traceregistaurl.path, nuncaurl.fullnemurl.query. Por trás dele, um span processor corta a query de todos os atributos de URL no seu hookonEnding, que corre antes de qualquer exporter ver o span, pelo que um URL que uma biblioteca ou uma alteração futura registe também é cortado:// apps/web/instrumentation.ts (excerto)import { NodeSDK } from "@opentelemetry/sdk-node";import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";import { IORedisInstrumentation } from "@opentelemetry/instrumentation-ioredis";import { PinoInstrumentation } from "@opentelemetry/instrumentation-pino";import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";// Os tokens viajam em query strings (ADR-0009): cortá-los antes de qualquer exporter ver um span.const URL_KEYS = ["url.full", "http.url", "http.target"];const dropQueries = {onStart() {},onEnding(span) {for (const key of URL_KEYS) {const value = span.attributes[key];if (typeof value === "string") span.setAttribute(key, value.split("?")[0]);}if ("url.query" in span.attributes) span.setAttribute("url.query", "");},onEnd() {},forceFlush: async () => {},shutdown: async () => {},};new NodeSDK({spanProcessors: [dropQueries, new BatchSpanProcessor(new OTLPTraceExporter())],instrumentations: [new HttpInstrumentation(),// As chaves guardam tokens de sessão, IPs e emails (ADR-0009, ADR-0010): registar apenas o comando.new IORedisInstrumentation({ dbStatementSerializer: (command) => command }),new PinoInstrumentation(),new RuntimeNodeInstrumentation(),],}).start();// ...depois o wrapper de globalThis.fetch, para que todas as bibliotecas carregadas a seguir o recebam.O
onEndingé experimental no SDK, por isso um teste de guarda (ADR-0014) envia um pedido com?token=canarypelo servidor instrumentado, com um exporter em memória, e falha se algum span contivercanary -
Nomes de rota, nunca URLs em bruto: o middleware
tracedefinehttp.routecom o padrão da rota correspondente, incluindo as rotas da API (ADR-0036). Caminhos em bruto com IDs fariam explodir a cardinalidade das métricas -
Logging com pino 10: um módulo de logger por app, JSON para stdout (para que
docker logscontinue a funcionar) e para a pipeline de logs OTel através de@opentelemetry/instrumentation-pino, que também acrescentatrace_idespan_ida cada registo.pino-prettyapenas em desenvolvimento -
Todos os pedidos têm um request ID, definido pelo middleware logo a seguir ao
traceemsrc/start.ts— antes do rate limiter e da verificação CSRF, para que os pedidos rejeitados também o levem (requestMiddleware: [trace, requestId, rateLimit, csrf, apiCaller],functionMiddleware: [traceServerFn]):- Reutilizar o header
X-Request-Idrecebido quando presente e bem formado: 1–128 carateres deA–Z a–z 0–9 . _ : -. Caso contrário, gerar um comcrypto.randomUUID(). Um valor mal formado é substituído, nunca registado tal como veio — o header é controlado pelo cliente, pelo que validá-lo evita log injection e campos de tamanho excessivo - Mantido num
AsyncLocalStoragedurante o pedido. Omixindo pino no logger lê-o, pelo que todas as linhas de log durante o pedido levamrequest_idsem o ser preciso passar de função em função. Também é definido como atributorequest_idno span do servidor, pelo que um request ID encontrado num log ou num ticket de suporte leva ao trace - Devolvido no header de resposta
X-Request-Id(definido emresult.responsedepois denext()), e mostrado junto da mensagem de erro genérica, para que utilizadores e suporte o possam citar - Um ID por pedido: as rotas da API leem o mesmo store. As chamadas aos nossos próprios serviços reencaminham
X-Request-Id; as chamadas a terceiros não. Otraceparentvai em todas as chamadas de saída — só contém IDs aleatórios
- Reutilizar o header
-
Nunca registar segredos: o
redactdo pino cobrepassword,token,secret,authorization,cookieeset-cookie; nunca registar corpos de pedidos em endpoints de autenticação; nunca ativar a captura de headers HTTP paracookie,authorizationouset-cookie. Registar IDs de utilizador, nunca emails nem nomes -
Erros no servidor são registados no span ativo (
recordException, estadoERROR) e escritos em log ao nívelerrorcom o trace ID; o utilizador vê uma mensagem genérica (ADR-0013). Os erros de servidor saem por OTel com o resto da telemetria do servidor, não para o GlitchTip — uma só instrumentação do lado do servidor -
Métricas de negócio através da API de métricas OTel (meter de
@opentelemetry/api), incluindo um contador de pedidos limitados por scope (ADR-0010) -
Endpoints de saúde como rotas de servidor do Start fora de
/api/*, para que o limiter os ignore (ADR-0010) e nunca sejam registados em trace:GET /healthz— liveness: o processo responde; não verifica mais nada. OHEALTHCHECKdo Docker usa-oGET /readyz— readiness:SELECT 1no Postgres ePINGno Valkey. O reverse proxy encaminha tráfego apenas para instâncias prontas (ADR-0017)
-
Erros no browser:
@sentry/react11.x, inicializado apenas no cliente, com o DSN a apontar para o GlitchTip.releaseé o git SHA;sendDefaultPii: false;tracesSampleRate: 0(os traces pertencem ao OTel); query strings removidas embeforeSendebeforeBreadcrumb. Os source maps são enviados para o GlitchTip no CI e removidos da imagem, para nunca serem servidos publicamente
Para lá da app
Seção intitulada “Para lá da app”- GlitchTip self-hosted com a sua própria base de dados Postgres — nunca a de uma app. Os eventos ficam guardados 90 dias
- Para onde vai o OTLP, durante quanto tempo a telemetria fica guardada, dashboards e alertas pertencem a quem corre o backend, não à app. A parte da app são os sinais: métricas HTTP com nomes de rota, durações de queries e de chamadas de saída, métricas de fila, o
/readyze o contador do rate limit — o suficiente para alertar sobre a taxa de 5xx, a latência p95, queries lentas, uma fila a crescer, um/readyza falhar e um salto de 429s. As métricas do Postgres e do Valkey também são recolhidas fora da app
Os fatores decisivos: instrumentação independente de fornecedor que sobrevive a uma mudança de backend, cada salto de um pedido ou job num só trace — por instrumentação mantida onde funciona no Bun, por quatro pequenas peças nossas onde não funciona — privacidade imposta dentro da app em vez de confiada a quem recebe a sua telemetria, e erros do browser mantidos na nossa infraestrutura. Aceitamos levar o filtro e os wrappers em todas as apps e correr o GlitchTip.
Consequências
Seção intitulada “Consequências”Mais fácil:
- Um pedido ou job lento abre como um só trace: middleware, server function ou rota da API, cada query Postgres e cada comando Valkey, chamadas de saída, voltas do modelo e os jobs que colocou em fila
- Logs, traces e métricas ligam-se entre si através de
trace_id, seja qual for o backend que os recebe - O request ID de um utilizador ou de um ticket de suporte encontra todas as linhas de log desse pedido
- Os crashes no browser chegam com stack traces legíveis, agrupados por release
- Um token num URL nunca sai do processo, seja para onde for que a app exporte — incluindo o portátil de quem desenvolve
- Mudar de backend muda o
OTEL_EXPORTER_OTLP_ENDPOINT; as apps ficam como estão
Mais difícil:
- O GlitchTip é infraestrutura da empresa: alguém é responsável pelas suas atualizações, armazenamento, backups e capacidade
- A instrumentação no Bun é jovem: verificar na primeira app que o
ioredis, onode:httpe as métricas de runtime reportam, e que os registos do pino levamtrace_id; manter oioredise opinoexternos ao bundle do servidor - As nossas quatro peças ficam por nossa conta até o
Bun.otele o tracing nativo do Start saírem; otraceSqlsegue as formas de chamada doBun.SQLque o driverbun-sqldo Drizzle usa, pelo que uma atualização do Drizzle ou do Bun corre primeiro o seu teste de integração - Mais spans custam mais: uma página que corre muitas queries exporta um span por cada uma. Baixar o rácio de sampling antes de retirar instrumentação
- Os pacotes
@opentelemetry/*têm de se mover em conjunto, a linha 0.x do SDK ainda muda entre releases, e o filtro depende do hook experimentalonEnding— o teste de guarda apanha uma release que o parta - A ocultação de dados sensíveis é uma regra a manter: um novo campo de log ou uma captura de headers pode vazar um token ou dados pessoais. Rever alterações de logging como alterações de segurança
- O GlitchTip não tem session replay nem algumas funcionalidades mais recentes do Sentry; as atualizações de SDK têm de ser verificadas contra o GlitchTip
- Dashboards e alertas dependem de um backend que este ADR não escolhe, pelo que um produto não pode assumir nenhuma funcionalidade dele
Do que abdicamos: do replay e da melhor UX de erros do Sentry, do agrupamento de erros do lado do servidor numa só ferramenta, de um só filtro partilhado por todos os serviços de um host e, enquanto o Bun não tiver tracing nativo, de instrumentação mantida para o seu servidor HTTP, o seu cliente Postgres e o fetch. Aceite — as apps ficam independentes de fornecedor, e a sua privacidade não depende de quem recebe a sua telemetria.

