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
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 495 KiB After Width: | Height: | Size: 526 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
@@ -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,
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||