Um aluno faz o teste psicométrico do KeepCoding usando o navegador Brave e relata um erro que nunca chega ao Sentry. A equipe de suporte verifica o painel, não encontra nada e pede para ele repetir o teste. Enquanto isso, os testes automáticos com Playwright contra o ambiente de produção passam sem problemas: o SDK carrega, os eventos são processados, os Sentry.flush() retornam true. Tudo parecia correto.
As decisões de produto baseadas em dados parciais são piores do que as baseadas em intuição, porque são tomadas com uma falsa sensação de segurança.
Contexto: por que o Brave é um caso importante
Brave conta com um sistema de bloqueio integrado no navegador, os chamados Shields, que bloqueiam rastreadores e publicidade por padrão, sem necessidade de extensões. De acordo com dados públicos da Brave Software de 2024, o navegador supera os 80 milhões de usuários ativos mensais. Esse número é relevante porque a distribuição não é uniforme: sua base de usuários é fortemente inclinada para perfis técnicos, desenvolvedores e early adopters.
Para um aplicativo voltado ao público geral, esse perfil de usuário se dilui. Para um aplicativo de avaliação técnica destinado a aspirantes a um bootcamp de desenvolvimento, não. A probabilidade de que o primeiro usuário a relatar um erro o faça usando o Brave é desproporcionalmente alta.
O mesmo se aplica a usuários com uBlock Origin instalado no Chrome ou Firefox, configurações do Firefox com Strict Tracking Protection, ou a redes corporativas que utilizam bloqueio de DNS como o Pi-hole. A interseção de todas essas categorias — usuários que ativam algum mecanismo agressivo de bloqueio de rastreadores — supera facilmente 15-20% do tráfego técnico, de acordo com estudos de cobertura das próprias listas de bloqueio (o EasyPrivacy reporta mais de 4 milhões de assinantes entre as extensões que a integram).
O problema: os Shields bloqueiam duas coisas, não apenas uma
O SDK do Sentry para navegadores funciona em duas fases. Na primeira, o navegador baixa o bundle de JavaScript do SDK a partir de uma CDN do Sentry, normalmente sob js-de.sentry-cdn.com ou browser.sentry-cdn.com. Na segunda, uma vez inicializado, o SDK envia os eventos capturados para um endpoint de ingestão sob *.ingest.sentry.io.
Os Shields do Brave consultam duas listas: a lista global do navegador e a lista ativada para cada site. Desativar os Shields apenas para o seu domínio não desativa a lista global. E a lista global inclui, entre outros, os seguintes domínios:
*.sentry-cdn.com
*.ingest.sentry.io
*.ingest.de.sentry.io
Isso está documentado em discussões públicas da comunidade Brave (“Brave blocks sentry endpoint even with shield down for website”), mas não na documentação oficial do Sentry como um aviso explícito. A consequência prática é que um usuário do Brave verá dois erros no console:
GET https://browser.sentry-cdn.com/10.53.1/bundle.min.js
net::ERR_BLOCKED_BY_CLIENT
POST https://o4511410375229440.ingest.de.sentry.io/api/4511410426085456/envelope/
net::ERR_BLOCKED_BY_CLIENT
O primeiro erro impede que o SDK seja sequer inicializado. O segundo, caso o bundle tenha sido carregado por outra via, impede que os eventos cheguem ao painel.
Por que os smoke tests podem enganar
O primeiro instinto de uma equipe é confiar em seus testes E2E. Se os smoke tests com Playwright passam, a integração deveria funcionar.
O problema é que o Playwright lança o Chromium sem os Shields. O Brave funciona sobre o Chromium, mas os Shields são uma camada proprietária da Brave Software que não está instalada no binário padrão do Chromium. O mesmo acontece se os testes usarem o Firefox via Playwright: o Firefox traz o Tracking Protection, mas não a versão Strict por padrão.
O resultado é um ponto cego nos testes, especialmente difícil de diagnosticar: qualquer teste automatizado contra o ambiente de produção indicará que o Sentry está funcionando, porque do ponto de vista do navegador headless, ele está mesmo. O bug só se manifesta quando um usuário real, com um navegador real e com bloqueio agressivo ativado, usa a aplicação.
Para reproduzir o cenário de forma fiel, a equipe teria de baixar o Brave no ambiente de CI, configurar os Shields e orquestrar os testes em playwright._impl._browser_type, apontando para o executável do Brave. Isso é viável, mas nenhum projeto que conheço faz isso por padrão.
A solução: três camadas que precisam funcionar juntas
A estratégia oficial recomendada pelo Sentry para contornar bloqueadores de anúncios é um túnel, descrita brevemente em sua documentação de troubleshooting. A ideia é que o navegador envie os eventos para um endpoint no mesmo domínio da aplicação, e o servidor os redirecione para o Sentry. Do ponto de vista do navegador, todo o tráfego do Sentry fica oculto sob uma origem própria que os bloqueadores de anúncios não reconhecem.
A documentação oficial cobre o conceito. Não cobre os detalhes que fazem com que a integração funcione em produção contra navegadores reais.
Camada 1: servindo o bundle do SDK a partir do seu próprio domínio
O primeiro passo é baixar o bundle do Sentry e servi-lo como mais um recurso estático:
curl -L -o app/static/js/vendor/sentry-browser.min.js \
https://browser.sentry-cdn.com/10.53.1/bundle.min.js
No template HTML, substitua o script do loader CDN por uma referência ao bundle local:
<script src="/static/js/vendor/sentry-browser.min.js"></script>
<script
src="/static/js/sentry-init.js"
data-dsn="https://...@o....ingest.de.sentry.io/..."
data-environment="production"
data-release="2026.05.18.001"
></script>
Com isso, o bundle é baixado de uma origem que o bloqueador de anúncios não tem em sua lista. Se a aplicação já utiliza uma Política de Segurança de Conteúdo (Content Security Policy), essa alteração permite torná-la mais rigorosa: o script-src pode ser apenas 'self' sem a necessidade de adicionar exceções para *.sentry-cdn.com.
Camada 2: servidor de túnel com cinquenta linhas de código em Flask
Antes de entrar no código, um detalhe de vocabulário. O SDK do Sentry não envia os eventos como uma requisição HTTP convencional com um corpo JSON. Ele os empacota em um formato próprio chamado envelope: um pacote de texto simples em que a primeira linha é um cabeçalho JSON com metadados do pacote (incluindo o dsn do projeto), e as linhas seguintes são os items — eventos, attachments, sessions, ou transactions — que o SDK deseja enviar nessa mesma requisição. O endpoint de ingestão do Sentry sempre espera esse formato. O túnel se limita a aceitar o envelope como recebido do navegador e redirecioná-lo para o Sentry sem alterá-lo, exceto por uma reescrita pontual que veremos no primeiro gotcha.
O segundo passo é o próprio túnel. Uma implementação mínima usando Flask:
import json
from urllib.parse import urlparse
import requests
from flask import Blueprint, abort, current_app, request
sentry_tunnel = Blueprint("sentry_tunnel", __name__)
def _expected_dsn_parts(app_config):
dsn = app_config.get("SENTRY_DSN_FRONTEND", "")
if not dsn:
return None
parsed = urlparse(dsn)
return parsed.hostname, (parsed.path or "").strip("/")
@sentry_tunnel.route("/api/sentry-tunnel", methods=["POST"])
def tunnel():
expected = _expected_dsn_parts(current_app.config)
if expected is None:
return ("", 204)
expected_host, expected_project_id = expected
envelope = request.get_data()
if not envelope:
abort(400)
try:
header_line, rest = envelope.split(b"\n", 1)
header = json.loads(header_line)
except (ValueError, json.JSONDecodeError):
abort(400)
# Reescreve sempre o DSN para o esperado pelo servidor.
# Ver gotcha 1 abaixo: o SDK browser com `tunnel:` configurado
# às vezes omite o campo `dsn` e o Sentry responde com 401.
header["dsn"] = current_app.config["SENTRY_DSN_FRONTEND"]
envelope = json.dumps(header).encode() + b"\n" + rest
upstream = f"https://{expected_host}/api/{expected_project_id}/envelope/"
try:
resp = requests.post(
upstream,
data=envelope,
headers={"Content-Type": "application/x-sentry-envelope"},
timeout=5,
)
except requests.RequestException:
current_app.logger.exception("sentry-tunnel: upstream failed")
return ("", 502)
# Repassa ao cliente o body, status e headers de coordenação.
# Ver gotcha 2 abaixo.
forwarded_headers = {}
for h in ("Content-Type", "X-Sentry-Rate-Limits", "Retry-After"):
if h in resp.headers:
forwarded_headers[h] = resp.headers[h]
return (resp.content, resp.status_code, forwarded_headers)
No cliente, a alteração é adicionar uma linha na configuração:
window.Sentry.init({
dsn: dsn,
tunnel: "/api/sentry-tunnel",
environment: environment,
release: release,
tracesSampleRate: 0,
sendDefaultPii: false,
});
Com isso, o navegador faz um POST dos envelopes para /api/sentry-tunnel no mesmo domínio da aplicação. O bloqueador de anúncios vê tráfego para o próprio servidor e não o bloqueia.
Os três gotchas que a documentação não cobre
Aqui é onde a diferença entre uma integração que parece funcionar e uma que realmente funciona fica explícita.
Gotcha 1: o SDK com tunnel: omite o campo dsn do envelope
Como vimos antes, o cabeçalho do envelope inclui o dsn do projeto. O Sentry valida isso no endpoint de ingestão e responde com 401 Bad envelope authentication header — missing field "dsn" se estiver ausente.
O SDK do Sentry para navegador, ao ser configurado com tunnel:, às vezes omite esse campo porque assume que o túnel já sabe para qual projeto redirecionar. Em outras ocasiões, ele inclui o campo. O comportamento varia entre versões do SDK e entre rotas internas de geração de envelopes.
A solução correta é reescrever o campo dsn sempre no túnel, antes de redirecionar para o endpoint real. Isso cumpre duas funções ao mesmo tempo:
- Garante que o Sentry encontre o campo que espera.
- Impede que o túnel seja usado como um open proxy para outros projetos do Sentry. Qualquer DSN que chegar do cliente será sobrescrito pelo configurado no servidor.
O código acima realiza isso na linha header["dsn"] = ....
Gotcha 2: é necessário propagar X-Sentry-Rate-Limits e Retry-After
O SDK do Sentry para navegador implementa um sistema de rate-limiting local que respeita as indicações do servidor de ingestão. Quando recebe um 429, um código 4xx ou certos códigos de erro, ele lê os headers X-Sentry-Rate-Limits e Retry-After, e entra em um período de backoff durante o qual descarta eventos silenciosamente.
Se o túnel não propagar esses headers do upstream, o SDK visualiza apenas o código de status. E pior ainda: se em algum momento o túnel devolver um 4xx próprio — por exemplo, durante um deploy onde a configuração é temporariamente inválida — o SDK interpretará esse 4xx como um sinal de rate-limit e entrará em backoff por padrão durante 60 segundos ou mais.
Isso cria uma falha persistente e difícil de diagnosticar: os eventos param de ser enviados, o SDK afirma que estão sendo enviados, e o estado é resolvido apenas fechando a aba. A solução é propagar os headers exatos do upstream, sem criar valores ou filtrá-los.
Gotcha 3: uma integração padrão descarta eventos no meu ambiente
Este é o mais complicado dos três. O SDK do Sentry para navegador vem com um conjunto de integrações ativadas por padrão: GlobalHandlers, BrowserApiErrors, Breadcrumbs, LinkedErrors, Dedupe, InboundFilters e outras.
Observação, não diagnóstico fechado: em meu ambiente — Brave sobre Chromium para o navegador real, Playwright headless sobre Chromium para testes, SDK do Sentry para navegador 10.53.1, túnel próprio — com todas as integrações padrão ativadas, o evento é descartado entre a construção e o envio. O SDK executa o beforeSend, emite o hook afterSendEvent com um event_id e uma resposta vazia, mas o beforeEnvelope nunca é invocado. O envelope não é construído. Nada sai pela rede.
A solução temporária por exclusão foi desabilitar todas as integrações padrão e manter apenas globalHandlersIntegration. Isso resolve o problema no meu caso, mas algo definitivo exige um diagnóstico mais aprofundado para identificar a interação ou integração específica responsável.
A configuração verificada contra produção:
window.Sentry.init({
dsn: dsn,
tunnel: "/api/sentry-tunnel",
environment: environment,
release: release,
tracesSampleRate: 0,
sendDefaultPii: false,
defaultIntegrations: false,
integrations: [window.Sentry.globalHandlersIntegration()],
});
Os custos dessa configuração mínima são maiores do que aparentam. Você perde Breadcrumbs (o trail automático de interações e eventos prévios ao erro). Perde HttpContext (dados como URL e user-agent no contexto do evento). Perde LinkedErrors (encadeamentos de erros aninhados). Perde BrowserApiErrors (envolvimento de timeouts, listeners e rejeições explícitas). É uma configuração paliativa, não a ideal para manter a longo prazo.
Implicações para a Política de Segurança de Conteúdo (CSP)
Um efeito curioso dessa arquitetura é que a CSP pode se tornar mais restrita. A configuração final fica assim:
Content-Security-Policy:
default-src 'self';
script-src 'self';
connect-src 'self';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' https://keepcoding.io
Sem *.sentry.io em connect-src. Sem *.sentry-cdn.com em script-src. Qualquer tentativa de tráfego direto ao Sentry que não passe pelo túnel será bloqueada, alertando para potenciais regressões.
Conclusão
A integração padrão do Sentry para navegador, seguindo a documentação oficial, não funciona para uma fração significativa de usuários técnicos. Isso resulta em dados de incidentes enviesados, pois os erros reportados vêm majoritariamente de usuários sem bloqueadores ativos, deixando de fora uma parte crítica e representativa da base.
Próximos passos:
- Audite sua integração com um navegador real. Instale o Brave ou o uBlock Origin no Chrome, execute os fluxos críticos e verifique se os erros chegam ao painel do Sentry.
- Implemente um túnel, se ainda não o fez. São cinquenta linhas de código no servidor.
- Reduza as integrações ao mínimo funcional e crie um teste E2E para garantir que os eventos apareçam corretamente no painel.
- Analise a porcentagem de tráfego vindo de navegadores com proteções rigorosas. Se superar 10%, implementar essas mudanças não é opcional.
Este artigo foi publicado originalmente em espanhol e traduzido com a ajuda de IA.