Sentry frontend bloqueado por Brave: cómo arreglarlo con un tunnel

| Última modificación: 18 de mayo de 2026 | Tiempo de Lectura: 10 minutos
Premios Blog KeepCoding 2025

Co-Fundador de KeepCoding

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:

  1. Garantiza que Sentry encuentre el campo que espera.
  2. 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:

  1. 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.
  2. Implementa tunneling si todavía no lo has hecho. Cincuenta líneas de servidor. La CSP queda mejor, los datos quedan completos.
  3. 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.
  4. 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.

Noticias recientes del mundo tech


¡CONVOCATORIA ABIERTA!

Desarrollo de apps móviles ios & Android

Full Stack Bootcamp

Clases en Directo | Profesores en Activo | Temario 100% actualizado

Descárgate también el informe de tendencias en el mercado laboral 2026.

Fórmate con planes adaptados a tus objetivos y logra resultados en tiempo récord.
KeepCoding Bootcamps
Resumen de privacidad

Esta web utiliza cookies para que podamos ofrecerte la mejor experiencia de usuario posible. La información de las cookies se almacena en tu navegador y realiza funciones tales como reconocerte cuando vuelves a nuestra web o ayudar a nuestro equipo a comprender qué secciones de la web encuentras más interesantes y útiles.