fix(sentry): a comunidade recebe erro, não sessão — o 429 que reprovava o e2e

O `olhar-telas-do-epico` reprovava com 429 em todas as 7 telas, e a pista que eu
mesmo deixei no workflow apontava para o limitador em memória. Medido: errado.

No job real, 120 quedas para memória, 103 no bucket `auth:login:ip` contra teto de
1000 — o limitador não barrou nada. O 429 vinha do túnel `/monitoring`, e o
instrumento jogava fora justamente a URL que identificava o dono (a mensagem do
browser para requisição barrada não diz quem respondeu, e o relatório não guarda
trace). Com a URL capturada, o percurso de 7 telas mediu: 19 requisições, 17
respostas, TODAS 429, com `x-sentry-rate-limits: 60::organization:suspended` —
categoria vazia, isto é, todas. E o corpo de cada envelope era `{"type":"session"}`
com `errors: 0`.

A política de `isCommunityDsn` estava declarada e não estava em vigor: as duas
amostragens foram a zero e a terceira torneira ficou fora da conta.
`browserSessionIntegration` é DEFAULT do @sentry/browser com `lifecycle: "route"`,
então cada navegação fechava uma sessão e abria outra. Passou porque
`integrations: [x]` SOMA aos defaults do SDK — só a forma de função os substitui, e
a diferença não aparece em tipo, em lint nem em teste que não abra um browser.

Consertos:
- `integracoesDoCliente` tira a `BrowserSession` quando o DSN é o da comunidade, e
  mantém tudo para quem aponta o próprio Sentry (mesma assimetria das amostragens).
- o `init` passa `integrations` como função.
- o gerador do `.env.e2e` escreve `SENTRY_DSN=off`: a suíte não manda dado para o
  Sentry de produção do projeto, e a cor do CI não depende do estado de cobrança de
  um terceiro. Consequência aceita: a suíte deixa de exercitar a política, então
  quem a guarda é o gate.
- a spec registra a URL do erro de console.

Medido depois, mesmo percurso, DSN da comunidade ainda ativo: 0 requisições ao
túnel, spec verde em 44s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7
This commit is contained in:
Rafael Melgaço
2026-08-10 09:53:25 -03:00
co-authored by Claude Opus 5
parent dc2f9f96df
commit 77b4a4863b
12 changed files with 254 additions and 6 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

+13 -5
View File
@@ -3,7 +3,7 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
import { resolveSentryDsn, isCommunityDsn } from "./lib/sentry/dsn";
import { resolveSentryDsn, isCommunityDsn, integracoesDoCliente } from "./lib/sentry/dsn";
import { sentryScrubHooks } from "./lib/sentry/scrub";
const sentryDsn = resolveSentryDsn(
@@ -14,11 +14,19 @@ const community = isCommunityDsn(sentryDsn);
Sentry.init({
dsn: sentryDsn,
integrations: [Sentry.replayIntegration()],
// FORMA DE FUNÇÃO, não de array: array SOMA aos defaults do SDK, e era assim
// que a `BrowserSession` (default) seguia ligada apesar da política abaixo. A
// função RECEBE os defaults e o retorno os substitui — é o único jeito de tirar
// uma integração default sem enumerar as outras dez à mão.
integrations: (padraoDoSdk) => [
...integracoesDoCliente(padraoDoSdk, community),
Sentry.replayIntegration(),
],
// No Sentry da comunidade, só erro (issue #100): sem trace e sem replay de
// sessão. O replay DE ERRO continua, porque é o que explica o stack trace —
// e o replayIntegration() sem argumentos já aplica maskAllText/blockAllMedia.
// No Sentry da comunidade, só erro (issue #100): sem trace, sem replay de
// sessão e sem sessão de release health (ver integracoesDoCliente). O replay DE
// ERRO continua, porque é o que explica o stack trace — e o replayIntegration()
// sem argumentos já aplica maskAllText/blockAllMedia.
tracesSampleRate: community ? 0 : 1,
enableLogs: true,
+35
View File
@@ -34,3 +34,38 @@ export function resolveSentryDsn(value: string | undefined | null): string | und
export function isCommunityDsn(dsn: string | undefined): boolean {
return dsn === DEFAULT_SENTRY_DSN;
}
/** Integração default do SDK que emite as sessões de release health do browser. */
export const INTEGRACAO_DE_SESSAO = "BrowserSession";
/**
* Quais integrações do browser valem para o DSN em uso.
*
* A política de `isCommunityDsn` estava DECLARADA e não estava em vigor. As duas
* amostragens foram a zero (`tracesSampleRate`, `replaysSessionSampleRate`) e o
* fluxo de SESSÃO ficou de fora da conta: `browserSessionIntegration` entra por
* default no `@sentry/browser` e o `lifecycle` dela é `"route"`, então cada troca
* de rota fecha uma sessão e abre outra — duas por navegação, `errors: 0`.
*
* Sessão não é stack trace: ela não explica bug de ninguém, e é exatamente o que o
* comentário do `isCommunityDsn` diz não querer ("não 100% das transações nem 10%
* das sessões de um CRM que não é nosso"). O custo era invisível porque o dado ia
* embora sozinho.
*
* Medido em 2026-08-10 sobre `dc2f9f96`, um percurso de 7 telas: 17 respostas do
* ingest, TODAS `429`, com `x-sentry-rate-limits: 60::organization:suspended` —
* lista de categorias vazia, isto é, todas as categorias. A organização estava
* suspensa por cota, então nem o erro real de instalação real entrava; e cada
* tentativa barrada virava erro de console no browser de quem hospeda.
*
* Quem aponta para o PRÓPRIO Sentry continua recebendo tudo, sessão inclusive: lá
* o dado não sai da infraestrutura de quem é dono dele, e release health é
* legítimo. A assimetria é a mesma das amostragens.
*/
export function integracoesDoCliente<T extends { name: string }>(
padraoDoSdk: readonly T[],
paraAComunidade: boolean,
): T[] {
if (!paraAComunidade) return [...padraoDoSdk];
return padraoDoSdk.filter((i) => i.name !== INTEGRACAO_DE_SESSAO);
}
+12
View File
@@ -107,6 +107,18 @@ WAHA_WEBHOOK_BASE_URL=http://127.0.0.1:3001
UPSTASH_REDIS_REST_URL=http://127.0.0.1:3998
UPSTASH_REDIS_REST_TOKEN=e2e-placeholder-nao-e-segredo
NEXT_TELEMETRY_DISABLED=1
# Telemetria DESLIGADA na suíte, e não é preferência: sem isto o SDK do browser
# assume o DSN da comunidade (\`lib/sentry/dsn.ts\` → DEFAULT_SENTRY_DSN) e a suíte
# MANDA DADO para o Sentry de produção do projeto — mesma família do e2e que
# escrevia no banco de produção. E o inverso morde igual: em 2026-08-10 a
# organização do Sentry estava suspensa por cota, o ingest respondeu 429 a tudo, o
# SDK cuspiu erro de console em toda tela e \`olhar-telas-do-epico\` reprovou. A cor
# do CI não pode depender do estado de cobrança de um terceiro.
#
# Consequência aceita: com \`off\` o cliente não inicializa, então a suíte NÃO
# exercita a política do DSN da comunidade — quem a guarda é
# \`tests/unit/sentry-comunidade-so-erro.test.ts\`.
SENTRY_DSN=off
EOF
echo "==> .env.e2e gerado, apontando para $API_URL"
+8 -1
View File
@@ -53,8 +53,15 @@ test.describe("as telas do épico abrem para uma pessoa", () => {
test("cada tela abre, tem conteúdo e não cospe erro no console", async ({ page }) => {
const erros: string[] = [];
// A URL entra no registro porque sem ela o achado é indiagnosticável: a
// mensagem do browser para uma requisição barrada é "Failed to load resource:
// the server responded with a status of 429", sem dizer QUEM respondeu. Uma
// reprovação assim custou uma investigação inteira no CI — o relatório não
// guarda trace, então a única cópia do endereço morria aqui.
page.on("console", (m) => {
if (m.type() === "error") erros.push(m.text().slice(0, 200));
if (m.type() !== "error") return;
const onde = m.location().url;
erros.push(`${m.text().slice(0, 200)}${onde ? ` @ ${onde}` : ""}`);
});
page.on("pageerror", (e) => erros.push(`PAGEERROR: ${String(e).slice(0, 200)}`));
@@ -0,0 +1,186 @@
/**
* O SENTRY DA COMUNIDADE RECEBE ERRO, E SÓ ERRO.
*
* ## O que se pagava
*
* A política estava escrita em `isCommunityDsn` e não estava em vigor. As duas
* amostragens foram a zero (`tracesSampleRate`, `replaysSessionSampleRate`) e a
* terceira torneira ficou fora da conta: `browserSessionIntegration` entra por
* DEFAULT no `@sentry/browser`, com `lifecycle: "route"` — a cada troca de rota ela
* fecha uma sessão e abre outra.
*
* Medido em 2026-08-10 sobre `dc2f9f96`, um percurso de 7 telas com o DSN da
* comunidade: 19 requisições ao túnel `/monitoring`, 17 respostas, TODAS `429`, e o
* corpo de cada envelope era `{"type":"session"}` com `errors: 0`. Nenhum erro,
* nenhum stack trace — exatamente o dado que a política diz não querer, ocupando a
* cota de que o stack trace precisa. Depois do conserto, no mesmo percurso: 0.
*
* O cabeçalho da recusa era `x-sentry-rate-limits: 60::organization:suspended` —
* lista de categorias VAZIA, isto é, todas. A organização estava suspensa por cota,
* então nem o erro real de instalação real entrava, e cada tentativa barrada virava
* erro de console no browser de quem hospeda.
*
* ## Por que o defeito passou
*
* `integrations: [x]` SOMA aos defaults do SDK; só `integrations: (defaults) => [...]`
* os substitui. Quem escreveu o array leu "estas são as integrações" e o SDK leu
* "estas, MAIS as dez de sempre". A diferença não aparece em nenhum tipo, em nenhum
* lint, e o efeito ia para a rede — nunca para a tela de quem escreveu.
*/
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
INTEGRACAO_DE_SESSAO,
integracoesDoCliente,
isCommunityDsn,
resolveSentryDsn,
} from "@/lib/sentry/dsn";
const PADRAO_FALSO = [
{ name: "InboundFilters" },
{ name: "Breadcrumbs" },
{ name: INTEGRACAO_DE_SESSAO },
{ name: "GlobalHandlers" },
] as const;
describe("integracoesDoCliente", () => {
it("no DSN da comunidade a sessão de release health NÃO vai", () => {
const saida = integracoesDoCliente(PADRAO_FALSO, true).map((i) => i.name);
expect(saida).not.toContain(INTEGRACAO_DE_SESSAO);
});
it("no DSN da comunidade o RESTO continua — não é desligar telemetria, é escolher o quê", () => {
// Sem este caso, uma função que devolve `[]` passaria no de cima e mataria o
// stack trace junto com a sessão, que é o oposto do desenho.
const saida = integracoesDoCliente(PADRAO_FALSO, true).map((i) => i.name);
expect(saida).toEqual(["InboundFilters", "Breadcrumbs", "GlobalHandlers"]);
});
it("no Sentry do próprio operador vai TUDO, sessão inclusive", () => {
// A assimetria é deliberada e é a mesma das amostragens: lá o dado não sai da
// infraestrutura de quem é dono dele, e release health é legítimo.
const saida = integracoesDoCliente(PADRAO_FALSO, false).map((i) => i.name);
expect(saida).toEqual(PADRAO_FALSO.map((i) => i.name));
});
it("não muta o array que recebeu — o SDK reusa a lista de defaults", () => {
const entrada = [...PADRAO_FALSO];
integracoesDoCliente(entrada, true);
expect(entrada).toHaveLength(PADRAO_FALSO.length);
});
});
/**
* O NOME É UM ACOPLAMENTO COM O SDK, e o modo de falha dele é o silêncio.
*
* `integracoesDoCliente` filtra por `name`. Se o Sentry renomear a integração, o
* filtro deixa de casar, para de filtrar e nada reprova: a suíte segue verde, os
* quatro casos acima seguem verdes (eles usam um default FALSO, construído com a
* própria constante), e as sessões voltam a sair sem ninguém notar.
*
* Este caso lê o nome no pacote instalado. Se o arquivo não for achado ele reprova
* pedindo manutenção, em vez de passar vazio — a alternativa era uma sonda que
* devolve "não achei" e "está tudo bem" com a mesma cara.
*/
describe("o nome da integração casa com o SDK instalado", () => {
it("o @sentry/browser instalado ainda chama a integração de sessão assim", () => {
const store = path.join(process.cwd(), "node_modules", ".pnpm");
const pastas = readdirSync(store).filter((d) => d.startsWith("@sentry+browser@"));
expect(
pastas.length,
"não achei o @sentry/browser no store do pnpm — ENSINE ESTE TESTE",
).toBeGreaterThan(0);
const fonte = pastas
.map((d) =>
path.join(
store,
d,
"node_modules/@sentry/browser/build/npm/cjs/prod/integrations/browsersession.js",
),
)
.map((p) => {
try {
return readFileSync(p, "utf8");
} catch {
return "";
}
})
.find((c) => c.length > 0);
expect(
fonte,
"não achei o módulo da integração de sessão do @sentry/browser — ENSINE ESTE TESTE",
).toBeTruthy();
expect(fonte).toContain(`name: "${INTEGRACAO_DE_SESSAO}"`);
});
});
/**
* O CALL SITE — porque a função pode estar perfeita e o `init` não usá-la.
*
* Guarda estática, e a fraqueza é conhecida: mede a FORMA do código, então um
* refactor que preserve o comportamento e mude o texto reprova aqui. É o mesmo
* trade-off de `operador-enfileiramento.test.ts`, e vale pelo mesmo motivo — o
* defeito real que se caça (voltar ao `integrations: [...]`) é invisível a
* typecheck, a lint e a todo teste que não abra um browser.
*/
describe("o init do cliente honra a política", () => {
const fonte = readFileSync(path.join(process.cwd(), "instrumentation-client.ts"), "utf8");
it("passa as integrações como FUNÇÃO, não como array", () => {
const i = fonte.indexOf("integrations:");
expect(i, "não achei a chave `integrations` no init — ENSINE ESTE TESTE").toBeGreaterThan(0);
const trecho = fonte.slice(i, i + 220);
expect(
/integrations:\s*\(/.test(trecho),
"o init voltou a passar `integrations` como ARRAY. Array SOMA aos defaults do " +
"SDK, então a BrowserSession volta ligada e o Sentry da comunidade recebe " +
"duas sessões por navegação de cada instalação — que é o defeito medido em " +
"2026-08-10, com o ingest respondendo 429 a tudo.",
).toBe(true);
expect(trecho).toContain("integracoesDoCliente(");
});
it("decide pela mesma pergunta que rege as amostragens", () => {
// `community` é o que liga a política das três torneiras. Se o call site passar
// um literal, uma instalação que aponta para o próprio Sentry perde release
// health sem ter pedido.
expect(fonte).toContain("integracoesDoCliente(padraoDoSdk, community)");
expect(fonte).toContain("isCommunityDsn(sentryDsn)");
});
});
/**
* A SUÍTE NÃO FALA COM O SENTRY DE PRODUÇÃO.
*
* Mesma família do e2e que escrevia no banco de produção: o default é a coisa
* mais perigosa quando o default é "manda para o nosso servidor de verdade".
* `resolveSentryDsn("")` cai no DSN da comunidade — então a AUSÊNCIA da chave no
* ambiente da suíte é o que mandava dado de teste para lá.
*/
describe("o ambiente da suíte desliga a telemetria", () => {
it("o gerador do .env.e2e escreve SENTRY_DSN=off", () => {
const gerador = readFileSync(
path.join(process.cwd(), "scripts/gerar-env-e2e.sh"),
"utf8",
);
expect(
/^SENTRY_DSN=off$/m.test(gerador),
"o ambiente da suíte voltou a ficar sem SENTRY_DSN. Sem a chave, " +
"`resolveSentryDsn` cai no DSN da comunidade e a suíte passa a MANDAR DADO " +
"para o Sentry de produção do projeto — e a cor do CI volta a depender do " +
"estado de cobrança de um terceiro.",
).toBe(true);
});
it("e `off` de fato desliga — a guarda acima não vale nada se o valor não desligasse", () => {
expect(resolveSentryDsn("off")).toBeUndefined();
// Controle: o vazio NÃO desliga, e é por isso que a linha acima é obrigatória.
expect(isCommunityDsn(resolveSentryDsn(""))).toBe(true);
});
});