Un utilisateur fait un test psychométrique de KeepCoding depuis Brave et signale une erreur qui n’arrive jamais sur Sentry. L’équipe de support regarde le tableau de contrôle, ne trouve rien et lui demande de répéter le test. Pendant ce temps, les tests de fumée Playwright en production passent au vert : le SDK se charge, les événements sont traités, et les Sentry.flush() retournent true. Tout semblait correct.

Les décisions produits basées sur des données partielles sont pires que celles basées sur l’intuition, car elles créent un faux sentiment de sécurité.

Contexte : pourquoi Brave est un cas important

Brave intègre un système de blocage natif, appelé Shields, qui filtre les trackers et les publicités par défaut, sans nécessiter d’extensions. Selon les données publiques de Brave Software de 2024, le navigateur dépasse les 80 millions d’utilisateurs actifs mensuels. Ce chiffre est pertinent parce que la distribution des utilisateurs n’est pas uniforme : sa base est fortement biaisée vers des profils techniques, des développeurs et des early adopters.

Pour une application destinée au grand public, ce biais est négligeable. Mais pour une application d’évaluation technique adressée à des candidats à un bootcamp de développement, il ne l’est pas. La probabilité que le premier utilisateur signalant un bug le fasse depuis Brave est disproportionnellement élevée.

Ce constat s’applique également aux utilisateurs de uBlock Origin sur Chrome ou Firefox, aux paramètres Firefox avec Strict Tracking Protection ou aux réseaux d’entreprise avec un blocage DNS type Pi-hole. L’intersection de toutes ces catégories — les utilisateurs activant un mécanisme agressif de blocage des trackers — dépasse facilement 15-20 % du trafic technique, selon les études sur la couverture des listes de blocage (EasyPrivacy rapporte plus de 4 millions d’abonnés via les extensions qui l’intègrent).

Le problème : les Shields bloquent deux points, pas un

Le SDK navigateur de Sentry fonctionne en deux étapes. Premièrement, le navigateur télécharge le bundle JavaScript du SDK depuis une CDN de Sentry, habituellement sous js-de.sentry-cdn.com ou browser.sentry-cdn.com. Deuxièmement, une fois initialisé, le SDK envoie les événements capturés à un endpoint d’ingestion sous *.ingest.sentry.io.

Les Shields de Brave consultent deux listes : la liste globale du navigateur et la liste spécifique à chaque site. Désactiver les Shields uniquement pour votre domaine ne désactive pas la liste globale. Et la liste globale inclut notamment les domaines suivants :

*.sentry-cdn.com
*.ingest.sentry.io
*.ingest.de.sentry.io

Ce comportement est documenté dans des sujets publics de la communauté Brave (“Brave blocks sentry endpoint even with shield down for website”), mais pas comme avertissement explicite dans la documentation officielle de Sentry. La conséquence pratique est qu’un utilisateur Brave verra deux erreurs dans la 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

La première erreur empêche le SDK de s’initialiser. La seconde, si le bundle a été chargé par un autre moyen, bloque l’arrivée des événements au tableau de bord.

Pourquoi les tests de fumée induisent en erreur

Le premier réflexe d’une équipe est de faire confiance à ses tests E2E. Si les tests de fumée Playwright passent, l’intégration devrait fonctionner.

Le problème est que Playwright lance Chromium sans Shields. Brave est basé sur Chromium, mais les Shields forment une couche propriétaire de Brave Software qui n’est pas installée dans le binaire standard de Chromium. Il en va de même si les tests utilisent Firefox via Playwright : Firefox inclut la Protection contre le pistage, mais pas en mode Strict par défaut.

Cela crée un angle mort dans les tests particulièrement difficile à diagnostiquer : tout test automatisé contre la production indiquera que Sentry fonctionne, car du point de vue du navigateur headless, tout fonctionne. Le bug se manifeste uniquement avec un utilisateur réel, un navigateur réel et un blocage agressif activé.

Pour reproduire fidèlement le scénario, l’équipe devrait télécharger Brave dans l’environnement CI, configurer les Shields et orchestrer les tests avec playwright._impl._browser_type pointant vers l’exécutable de Brave. C’est faisable, mais aucun projet connu ne le fait par défaut.

La solution : trois couches qui doivent fonctionner ensemble

La stratégie officielle recommandée par Sentry pour contourner les bloqueurs de publicités est un tunnel, décrit brièvement dans leur documentation de dépannage. L’idée est que le navigateur envoie les événements à un endpoint sous le même domaine que l’application, et que le serveur les redirige vers Sentry. Pour le navigateur, tout le trafic Sentry semble provenir d’un seul domaine que les bloqueurs ne reconnaissent pas.

La documentation officielle décrit le concept mais ne couvre pas les détails nécessaires pour qu’une intégration fonctionne réellement en production contre des navigateurs réels.

Couche 1 : servir le bundle SDK depuis votre propre domaine

La première étape consiste à télécharger le bundle de Sentry et à le servir comme une ressource statique :

curl -L -o app/static/js/vendor/sentry-browser.min.js \
  https://browser.sentry-cdn.com/10.53.1/bundle.min.js

Dans le modèle HTML, remplacez le script du CDN par une référence au 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>

Ainsi, le bundle est téléchargé depuis une source que le bloqueur de publicités ne liste pas. Si l’application utilise déjà une Content Security Policy, ce changement permet de la durcir : la directive script-src peut rester 'self' sans ajouter d’exceptions pour *.sentry-cdn.com.

Couche 2 : tunnel côté serveur en cinquante lignes de Flask

Avant d’entrer dans le code, un détail de vocabulaire. Le SDK Sentry n’envoie pas les événements dans une requête HTTP classique avec un corps JSON. Ils sont emballés dans un format spécifique appelé envelope : un paquet texte où la première ligne est un en-tête JSON contenant des métadonnées (dont le dsn du projet) et où les lignes suivantes contiennent les items (événements, attachments, sessions, transactions). L’endpoint d’ingestion de Sentry attend toujours ce format. Le tunnel se contente d’accepter l’envelope tel que le navigateur l’a envoyé, puis de le rediriger vers Sentry, à part une réécriture spécifique que nous verrons dans le premier piège.

Le deuxième pas est donc d’implémenter le tunnel. Voici une version minimale 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)

    # Réécriture systématique du champ DSN à la valeur attendue.
    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: échec avec upstream")
        return ("", 502)

    # Propagation des headers et status du upstream vers le client.
    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)

Côté client, le changement est une seule ligne supplémentaire dans la configuration :

window.Sentry.init({
  dsn: dsn,
  tunnel: "/api/sentry-tunnel",
  environment: environment,
  release: release,
  tracesSampleRate: 0,
  sendDefaultPii: false,
});

Ainsi, le navigateur envoie les envelopes à /api/sentry-tunnel sous le même domaine que l’application. Le bloqueur de publicités considère ce trafic comme interne et ne le bloque pas.

Les trois pièges que la documentation ne couvre pas

C’est ici que se fait la différence entre une intégration qui semble fonctionner et une intégration qui fonctionne réellement.

Piège 1 : le SDK avec tunnel: omet parfois le champ dsn dans les envelopes

Comme mentionné plus haut, l’en-tête des envelopes inclut le dsn du projet. Sentry le valide à l’endpoint d’ingestion et répond avec 401 Bad envelope authentication header — missing field "dsn" s’il est absent.

Le SDK navigateur de Sentry, lorsqu’il est configuré avec tunnel:, omet parfois ce champ parce qu’il suppose que le tunnel sait déjà quel projet cibler. Parfois, il le fournit. Le comportement varie selon les versions du SDK et les chemins internes de création d’envelopes.

La solution correcte est de réécrire systématiquement le champ dsn côté serveur, avant de le rediriger vers l’endpoint réel. Cela a deux avantages :

  1. Garantir que Sentry trouve bien le champ qu’il attend.
  2. Empêcher l’utilisation abusive du tunnel comme open proxy vers d’autres projets Sentry.

Le code ci-dessus effectue cette réécriture dans la ligne header["dsn"] = ....

Piège 2 : il faut propager X-Sentry-Rate-Limits et Retry-After

Le SDK navigateur de Sentry implémente un système de rate limiting local qui respecte les indications du serveur d’ingestion. Lorsqu’il reçoit un 429, un 4xx ou certains codes d’erreur, il lit les en-têtes X-Sentry-Rate-Limits et Retry-After et entre dans une période de backoff durant laquelle il ignore les événements.

Si le tunnel ne propage pas ces en-têtes du upstream, le SDK ne voit que le code. Pire encore : si le tunnel retourne un 4xx propre (par exemple, lors d’un déploiement temporaire où la configuration est invalide), le SDK interprète cela comme une directive de rate-limit par défaut de 60 secondes ou plus.

Cela peut provoquer un échec persistant et difficile à diagnostiquer : les événements cessent d’être envoyés, le SDK indique qu’ils le sont, et seule la fermeture de l’onglet met fin à l’état de backoff. La solution est de propager les en-têtes exacts sortant de Sentry, sans valeur inventée ni filtre.

Piège 3 : une intégration par défaut du SDK ignore les événements

Ce dernier point est le plus subtil. Par défaut, le SDK navigateur Sentry active plusieurs intégrations : GlobalHandlers, BrowserApiErrors, Breadcrumbs, LinkedErrors, Dedupe, InboundFilters, et d’autres.

Observation (non diagnostic) dans notre environnement : Brave sur Chromium pour le navigateur réel, Playwright pour les tests, Sentry SDK navigateur 10.53.1, avec un tunnel propriétaire, certaines intégrations standard semblent bloquer les événements entre leur création et leur tentative d’envoi.

La configuration minimale testée comme fonctionnelle :

window.Sentry.init({
  dsn: dsn,
  tunnel: "/api/sentry-tunnel",
  environment: environment,
  release: release,
  tracesSampleRate: 0,
  sendDefaultPii: false,
  defaultIntegrations: false,
  integrations: [window.Sentry.globalHandlersIntegration()],
});

Cependant, cela désactive Breadcrumbs, HttpContext et d’autres bénéfices intégrés, à long terme non viable. Une analyse plus approfondie doit identifier l’impact exact de chaque intégration.

Répercussions sur la Content Security Policy

Par effet secondaire, la CSP est renforcée, et non assouplie :

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';

Sans *.sentry.io dans connect-src. Tous flux hors tunnel violera clairement la CSP.

Conclusion pratique

  1. Testez avec Brave et uBlock quels flux critiques passent réellement.
  2. Implémentez le tunneling si absent. (2H max pour équipe expérimentée.)
  3. Ajoutez E2E simulant cet usage avec CI. 🌍

Cet article a été publié en espagnol et traduit avec l’aide de l’IA.