Pular para o conteúdo

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

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, o Bun.SQL e o fetch do Bun ainda não têm nenhuma (ADR-0035), pelo que somos nós a criar esses spans
  • 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

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.

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.

  • 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

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.

  • OTel Node SDK (@opentelemetry/sdk-node) arranca antes do servidor: a imagem Docker arranca a app com bun --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 do require-in-the-middle. (Alterado em 2026-11-13: este ADR usou primeiro NODE_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 em src/start.ts — span SERVER {method} {http.route} para páginas, server functions e rotas da API por igual, traceparent recebido, http.server.request.duration
    Server functions Nosso: traceServerFn, o middleware global de funções (functionMiddleware em src/start.ts) — um span INTERNAL por chamada com o nome da função (serverFnMeta.name), com code.function.name e code.file.path. Os loaders das rotas chamam server functions, por isso o seu trabalho no servidor fica aqui
    Postgres (Bun.SQL) Nosso: o traceSql envolve o cliente que o Drizzle recebe — um span CLIENT por query, também dentro de transações, e db.client.operation.duration
    Valkey (ioredis) @opentelemetry/instrumentation-ioredis, com dbStatementSerializer definido apenas com o nome do comando: as chaves guardam tokens de sessão, IPs e emails
    fetch de saída Nosso: o instrumentation.ts envolve globalThis.fetch — um span CLIENT por chamada, traceparent injetado, http.client.request.duration; as opções init exclusivas do Bun, como proxy e tls, passam sem alteração
    node:http e node:https de saída @opentelemetry/instrumentation-http, para qualquer dependência que ignore o fetch; a instrumentação funciona no Bun desde a 1.4
    Jobs 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 com trace_id e span_id
    Armazenamento de objetos, SMTP Nosso: withSpan(name, fn) de src/server/telemetry.ts, à volta de cada chamada em server/storage/ e server/mail/ — o S3Client do Bun e o Nodemailer não têm instrumentação
    Runtime @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, dns e net (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-sql existe, mas a 0.2.0 fixa o @opentelemetry/core 2.x enquanto o nosso SDK está na 3.x

  • As nossas quatro peças — trace, traceServerFn, traceSql e o wrapper do fetch — seguem as convenções semânticas, registam db.query.text exatamente 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 por fetch, e nenhum valor de parâmetro nem query string em nenhum deles. Cada uma leva um comentário shortcut:: apagá-lo quando o Bun.otel (oven-sh/bun#39965) ou o tracing nativo do Start cobrir o mesmo salto

  • O guia do próprio Start: o trace e o traceServerFn seguem 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 o trace e o traceServerFn. 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 /healthz e o /readyz nã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_ATTRIBUTES com deployment.environment.name e service.version definido como o git SHA. Sampling: parentbased_traceidratio, a começar em 1.0 e reduzido por produto quando o volume o exigir

  • Nenhuma query string sai do processo. O middleware trace regista url.path, nunca url.full nem url.query. Por trás dele, um span processor corta a query de todos os atributos de URL no seu hook onEnding, 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=canary pelo servidor instrumentado, com um exporter em memória, e falha se algum span contiver canary

  • Nomes de rota, nunca URLs em bruto: o middleware trace define http.route com 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 logs continue a funcionar) e para a pipeline de logs OTel através de @opentelemetry/instrumentation-pino, que também acrescenta trace_id e span_id a cada registo. pino-pretty apenas em desenvolvimento

  • Todos os pedidos têm um request ID, definido pelo middleware logo a seguir ao trace em src/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-Id recebido quando presente e bem formado: 1–128 carateres de A–Z a–z 0–9 . _ : -. Caso contrário, gerar um com crypto.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 AsyncLocalStorage durante o pedido. O mixin do pino no logger lê-o, pelo que todas as linhas de log durante o pedido levam request_id sem o ser preciso passar de função em função. Também é definido como atributo request_id no 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 em result.response depois de next()), 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. O traceparent vai em todas as chamadas de saída — só contém IDs aleatórios
  • Nunca registar segredos: o redact do pino cobre password, token, secret, authorization, cookie e set-cookie; nunca registar corpos de pedidos em endpoints de autenticação; nunca ativar a captura de headers HTTP para cookie, authorization ou set-cookie. Registar IDs de utilizador, nunca emails nem nomes

  • Erros no servidor são registados no span ativo (recordException, estado ERROR) e escritos em log ao nível error com 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. O HEALTHCHECK do Docker usa-o
    • GET /readyz — readiness: SELECT 1 no Postgres e PING no Valkey. O reverse proxy encaminha tráfego apenas para instâncias prontas (ADR-0017)
  • Erros no browser: @sentry/react 11.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 em beforeSend e beforeBreadcrumb. Os source maps são enviados para o GlitchTip no CI e removidos da imagem, para nunca serem servidos publicamente

  • 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 /readyz e 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 /readyz a 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.

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, o node:http e as métricas de runtime reportam, e que os registos do pino levam trace_id; manter o ioredis e o pino externos ao bundle do servidor
  • As nossas quatro peças ficam por nossa conta até o Bun.otel e o tracing nativo do Start saírem; o traceSql segue as formas de chamada do Bun.SQL que o driver bun-sql do 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 experimental onEnding — 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.