Un alumno hace el test psicométrico de KeepCoding desde Brave y reporta
un error que nunca llega a Sentry. El equipo de soporte mira el panel,
no encuentra nada, y le pide que repita el test. Mientras tanto, los
smoke tests con Playwright contra producción salen verdes: el SDK
carga, los eventos se procesan, los Sentry.flush() devuelven true.
Todo parecía correcto.
Las decisiones de producto basadas en datos parciales son peores que las basadas en intuición, porque se toman con falsa seguridad.
Contexto: por qué Brave es el caso que importa
Brave tiene un sistema de bloqueo integrado en el navegador, los llamados Shields, que filtra trackers y publicidad por defecto sin necesidad de extensiones. Según datos públicos de Brave Software de 2024, el navegador supera los 80 millones de usuarios activos mensuales. Esa cifra es relevante porque la distribución no es uniforme: la base de usuarios está fuertemente sesgada hacia perfiles técnicos, desarrolladores y early adopters.
Para una aplicación dirigida al público general, ese sesgo se diluye. Para una aplicación de evaluación técnica destinada a aspirantes a un bootcamp de desarrollo, no. La probabilidad de que el primer usuario en reportar un fallo lo haga desde Brave es desproporcionadamente alta.
Lo mismo aplica a usuarios con uBlock Origin instalado en Chrome o Firefox, a configuraciones de Firefox con Strict Tracking Protection, o a redes corporativas con bloqueo DNS tipo Pi-hole. La intersección de todas esas categorías —usuarios que activan algún mecanismo de bloqueo agresivo de trackers— supera fácilmente el 15-20% del tráfico técnico según los estudios de cobertura de las propias listas de bloqueo (EasyPrivacy reporta más de 4 millones de suscriptores entre las extensiones que la integran).
El problema: los Shields bloquean dos cosas, no una
El SDK de Sentry para navegador funciona en dos fases. En la primera,
el navegador descarga el bundle de JavaScript del SDK desde una CDN de
Sentry, normalmente bajo js-de.sentry-cdn.com o
browser.sentry-cdn.com. En la segunda, una vez inicializado, el SDK
envía los eventos capturados a un endpoint de ingestión bajo
*.ingest.sentry.io.
Los Shields de Brave consultan dos listas: la lista global del navegador y la lista activada para cada sitio. Desactivar los Shields solo para tu dominio no quita la lista global. Y la lista global incluye, entre otros, los siguientes dominios:
*.sentry-cdn.com
*.ingest.sentry.io
*.ingest.de.sentry.io
Esto está documentado en hilos públicos de la comunidad Brave («Brave blocks sentry endpoint even with shield down for website»), pero no en la documentación oficial de Sentry como advertencia explícita. La consecuencia práctica es que un usuario con Brave verá dos errores en consola:
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
El primer error impide que el SDK siquiera se inicialice. El segundo, en caso de que el bundle haya cargado por otra vía, impide que los eventos lleguen al panel.
Por qué los smoke tests engañan
El primer instinto de un equipo es confiar en sus tests E2E. Si los smoke tests con Playwright pasan, la integración debería funcionar.
El problema es que Playwright lanza Chromium sin Shields. Brave
funciona sobre Chromium, pero los Shields son una capa propietaria de
Brave Software que no se instala en el binario de Chromium estándar.
Lo mismo aplica si los tests usan Firefox via Playwright: Firefox
sí trae Tracking Protection, pero no la versión Strict por defecto.
El resultado es un punto ciego de testing especialmente difícil de diagnosticar: cualquier test automatizado contra producción reportará que Sentry funciona, porque desde el punto de vista del navegador headless funciona. El bug solo se manifiesta cuando un usuario real con un navegador real con bloqueo agresivo toca la aplicación.
Para reproducir el escenario fielmente, el equipo tendría que
descargar Brave en el entorno de CI, configurar Shields, y orquestar
los tests con playwright._impl._browser_type apuntando al ejecutable
de Brave. Es factible, pero ningún proyecto que conozca lo hace por
defecto.
La solución: tres capas que tienen que funcionar a la vez
La estrategia oficial recomendada por Sentry para esquivar adblockers es un tunnel, descrito brevemente en su documentación de troubleshooting. La idea es que el navegador envíe los eventos a un endpoint bajo el mismo dominio que la aplicación, y el servidor los reenvíe a Sentry. Desde el punto de vista del navegador, todo el tráfico de Sentry queda oculto bajo un origen propio que los adblockers no reconocen.
La documentación oficial cubre el concepto. No cubre los detalles que hacen que una integración funcione en producción contra navegadores reales.
Capa 1: servir el bundle del SDK desde tu propio dominio
El primer paso es descargar el bundle de Sentry y servirlo como un recurso estático más:
curl -L -o app/static/js/vendor/sentry-browser.min.js \
https://browser.sentry-cdn.com/10.53.1/bundle.min.js
En la plantilla HTML, sustituir el script del loader CDN por una referencia al bundle local:
<script src="/static/js/vendor/sentry-browser.min.js"></script>
<script
src="/static/js/sentry-init.js"
data-dsn="https://[email protected]/..."
data-environment="production"
data-release="2026.05.18.001"
></script>
Con esto, el bundle se descarga de un origen que el adblocker no tiene
en su lista. Si la aplicación ya pasa por una Content Security
Policy, este cambio permite endurecerla: el script-src puede
quedarse como 'self' sin necesidad de añadir excepciones para
*.sentry-cdn.com.
Capa 2: tunnel server-side en cincuenta líneas de Flask
Antes de entrar al código, un detalle de vocabulario. El SDK de Sentry
no envía los eventos en una petición HTTP convencional con un cuerpo
JSON. Los empaqueta en un formato propio llamado envelope: un
paquete de texto plano en el que la primera línea es una cabecera JSON
con metadatos del paquete (incluido el dsn del proyecto), y las
líneas siguientes son los items —eventos, attachments, sessions,
transactions— que el SDK quiere subir en esa misma petición. El
endpoint de ingestión de Sentry espera siempre ese formato. El tunnel
se limita a aceptar el envelope tal como viene del navegador y
reenviarlo a Sentry sin tocarlo, salvo por una reescritura puntual que
veremos en el primer gotcha.
El segundo paso es el tunnel propiamente dicho. La implementación mínima en 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)
# Reescritura siempre del DSN al esperado por el servidor.
# Ver gotcha 1 más abajo: el SDK browser con `tunnel:` configurado
# a veces omite el campo `dsn` y Sentry responde 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)
# Reenvío al cliente del body, status y headers de coordinación.
# Ver gotcha 2 más abajo.
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)
En el cliente, el cambio es una línea adicional en la configuración:
window.Sentry.init({
dsn: dsn,
tunnel: "/api/sentry-tunnel",
environment: environment,
release: release,
tracesSampleRate: 0,
sendDefaultPii: false,
});
Con esto, el navegador POST-ea los envelopes a /api/sentry-tunnel en
el mismo dominio que la aplicación. El adblocker ve tráfico al
servidor propio y no lo bloquea.
Los tres gotchas que la documentación no cubre
Aquí es donde la diferencia entre una integración que parece funcionar y una que funciona de verdad se hace explícita.
Gotcha 1: el SDK con tunnel: omite el campo dsn del envelope
Como vimos antes, la cabecera del envelope incluye el dsn del
proyecto. Sentry lo valida en el endpoint de ingestión y responde con
401 Bad envelope authentication header — missing field "dsn" si
falta.
El SDK de Sentry para navegador, cuando se configura con tunnel:,
a veces omite ese campo porque asume que el tunnel ya sabe a qué
proyecto reenviar. Otras veces lo incluye. El comportamiento varía
entre versiones del SDK y entre rutas internas de generación de
envelopes.
La solución correcta es reescribir el campo dsn siempre en el
tunnel, antes de reenviar al endpoint real. Esto cumple dos funciones
de un solo trazo:
- Garantiza que Sentry encuentre el campo que espera.
- Impide que el tunnel se use como open proxy hacia otros proyectos de Sentry. Cualquier DSN que llegue del cliente se sobrescribe con el configurado en el servidor.
El código de arriba lo hace en la línea header["dsn"] = ....
Gotcha 2: hay que propagar X-Sentry-Rate-Limits y Retry-After
El SDK de Sentry para navegador implementa un sistema de rate-limiting
local que respeta las indicaciones del servidor de ingestión. Cuando
recibe un 429, un 4xx o ciertos códigos de error, lee los headers
X-Sentry-Rate-Limits y Retry-After y entra en un periodo de
backoff durante el cual descarta eventos silenciosamente.
Si el tunnel no propaga esos headers del upstream, el SDK ve solo
el código de estado. Y peor todavía: si en algún momento el tunnel
devuelve un 4xx propio —por ejemplo, durante un despliegue donde la
configuración no es válida temporalmente— el SDK interpreta ese 4xx
como una señal de rate-limit y se mete en backoff por defecto durante
60 segundos o más.
Esto produce un fallo persistente y difícil de diagnosticar: los eventos dejan de salir, el SDK reporta que están saliendo, y solo se puede salir del estado cerrando la pestaña. La solución es propagar los headers exactos del upstream tal como salen de Sentry, sin inventar valores ni filtrar.
Gotcha 3: una de las integraciones por defecto descarta eventos en mi entorno
Este es el más esquivo de los tres. El SDK de Sentry para navegador
trae un conjunto de integraciones activadas por defecto:
GlobalHandlers, BrowserApiErrors, Breadcrumbs, LinkedErrors,
Dedupe, InboundFilters y varias más.
Observación, no diagnóstico cerrado: en mi entorno —Brave sobre
Chromium para el navegador real, Playwright headless sobre Chromium
para tests, Sentry browser SDK 10.53.1, tunnel propio— con todas
las integraciones por defecto activadas el evento se descarta entre
la construcción y el envío. El SDK ejecuta el beforeSend, emite
el hook afterSendEvent con un event_id y una respuesta vacía, pero
el beforeEnvelope nunca se invoca. El envelope no se construye. No
sale nada por la red.
Aislamiento por exclusión, no análisis interno del SDK: deshabilitar
todas las integraciones por defecto y mantener únicamente
globalHandlersIntegration resuelve el síntoma. Eso no significa
que las demás integraciones estén rotas en general, ni señala a una
culpable concreta. La causa real puede estar en InboundFilters,
BrowserApiErrors, Dedupe, la interacción entre alguna de ellas y
el stacktrace del envelope con marcadores de mismo origen, o algún
camino de clasificación del SDK que considere el error como originado
en su propio bundle. Saber cuál exactamente requiere reintroducir las
integraciones una a una con un test E2E que verifique el envío real,
y queda como trabajo pendiente.
El comportamiento es especialmente confuso porque Sentry.flush(timeout)
devuelve true —la cola está vacía, lo cual es cierto, pero porque
el evento se descartó, no porque se enviase— y la consola del
desarrollador reporta que el evento fue procesado.
La configuración que verifiqué contra producción y que entrega los eventos de forma consistente:
window.Sentry.init({
dsn: dsn,
tunnel: "/api/sentry-tunnel",
environment: environment,
release: release,
tracesSampleRate: 0,
sendDefaultPii: false,
defaultIntegrations: false,
integrations: [window.Sentry.globalHandlersIntegration()],
});
Los costes de esta configuración mínima son mayores de los que parece
a primera vista. Pierdes Breadcrumbs (el trail automático de
clics, llamadas a console, peticiones fetch/xhr y navegaciones
previas al error). Pierdes HttpContext (URL, user-agent y referer
adjuntos al contexto del evento). Pierdes LinkedErrors (cadenas de
cause: de errores anidados, fundamentales para errores que envuelven
otros errores). Pierdes BrowserApiErrors (envoltorios sobre
setTimeout, setInterval, event listeners y promise rejections
explícitas en callbacks asíncronos).
GlobalHandlers cubre lo principal —window.onerror y
unhandledrejection globales— pero no cubre todo lo que normalmente
esperas del SDK de navegador. Algunos unhandled rejections en
caminos específicos pueden no capturarse según la versión y el
contexto. Esta no es la configuración objetivo a largo plazo; es la
configuración aceptable mientras se investiga cuál integración
exactamente descarta y por qué.
Implicaciones para la Content Security Policy
Un efecto secundario interesante de esta arquitectura es que la CSP queda más estricta, no más laxa. La versión final tras aplicar las tres capas queda como:
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
Sin *.sentry.io en connect-src. Sin *.sentry-cdn.com en
script-src. Cualquier intento de código futuro de conectar
directamente a Sentry —por ejemplo, un dependiente nuevo que active
una integración que use el endpoint original— fallará con una
violación de CSP visible en la consola del desarrollador. Es un fail
loud que actúa como canary contra regresiones.
Esto contrasta con la práctica habitual de relajar la CSP cada vez que se añade una nueva herramienta de tracking. Tunneling permite mantener una CSP mínima y trasladar la superficie de tráfico a componentes auditables del propio servidor.
Lección operativa
La integración por defecto de Sentry para navegador, instalada siguiendo la documentación oficial, no funciona para un porcentaje no trivial de usuarios técnicos. Esto se traduce en datos de incidentes sesgados: los errores reportados son los de usuarios sin medidas de bloqueo, no los de la base completa.
Cualquier equipo que tome decisiones de roadmap basándose en su panel de Sentry está trabajando con una muestra no representativa. Los usuarios que activamente protegen su privacidad tienden a ser también los más exigentes, los más propensos a reportar bugs por canales formales y los que más amplifican fallos en redes sociales. Perder esa señal es especialmente costoso.
Acciones inmediatas:
- Audita tu integración con un navegador real. Instala Brave o uBlock Origin sobre Chrome, ejecuta el flujo crítico y verifica que los errores llegan al panel de Sentry. Los smoke tests con Chromium headless no son suficientes.
- Implementa tunneling si todavía no lo has hecho. Cincuenta líneas de servidor. La CSP queda mejor, los datos quedan completos.
- Verifica que el cliente reduce las integraciones al mínimo funcional y añade un test E2E que dispare un error real desde un script de mismo origen y verifique vía API de Sentry que el evento aparece en el panel.
- Revisa qué porcentaje de tu tráfico viene de Brave o navegadores con tracking protection estricto en tus logs de servidor. Si pasa del 10%, este trabajo no es opcional.
El código completo del tunnel y los smoke tests E2E queda como referencia en el repositorio interno donde se desarrolló. Los detalles están en este artículo: tres capas que tienen que coordinarse y dos horas de migración para un equipo que ya conozca su stack.
Read this article in English.



