A student takes the psychometric test on KeepCoding using Brave and reports an error that never reaches Sentry. The support team checks the dashboard, finds nothing, and asks them to retake the test. Meanwhile, the smoke tests with Playwright against production show green results: the SDK loads, events are processed, and Sentry.flush() returns true. Everything seemed fine.
Product decisions based on partial data are worse than those based on intuition, as they create a false sense of security.
Context: Why Brave Matters
Brave has a built-in blocking system, called Shields, which filters trackers and ads by default without requiring extensions. According to Brave Software’s 2024 public data, the browser has over 80 million monthly active users. This number is significant because the user base is heavily skewed towards tech-savvy individuals, developers, and early adopters.
For applications targeting the general public, this skew is diluted. However, for a technical evaluation platform aimed at prospective coding bootcamp candidates, it isn’t. The likelihood of the first user reporting an issue doing so from Brave is disproportionately high.
The same is true for users with uBlock Origin on Chrome or Firefox, Firefox with Strict Tracking Protection, or those on corporate networks with DNS blockers like Pi-hole. The overlap of these groups—users who enable aggressive tracker-blocking mechanisms—accounts for 15–20% of the technical traffic according to studies of blocklist coverage (EasyPrivacy reports over 4 million subscribers across integrated extensions).
The Problem: Shields Block Two Things, Not One
The Sentry browser SDK works in two phases. First, the browser downloads the SDK’s JavaScript bundle from a Sentry CDN, typically hosted under js-de.sentry-cdn.com or browser.sentry-cdn.com. Second, after initialization, the SDK sends captured events to an ingestion endpoint under *.ingest.sentry.io.
Brave Shields consult the browser’s global blocklist and the one activated for each site. Disabling Shields for your domain only bypasses the site-specific list but leaves the global list active. The global list includes these domains, among others:
*.sentry-cdn.com
*.ingest.sentry.io
*.ingest.de.sentry.io
This is documented in public Brave community threads (“Brave blocks sentry endpoint even with shield down for website”), but it isn’t explicitly mentioned as a warning in Sentry’s official documentation. The practical result is that a Brave user will see two console errors:
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
The first error prevents the SDK from initializing. The second, if the bundle somehow loads through another route, stops events from reaching the dashboard.
Why Smoke Tests Can Be Misleading
A team’s first instinct is to trust their E2E tests. If Playwright smoke tests pass, the integration must be working.
The issue is that Playwright launches Chromium without Shields. Brave is Chromium-based, but Shields is a proprietary layer from Brave Software that isn’t part of standard Chromium builds. The same happens if tests use Firefox with Playwright; Firefox includes Tracking Protection, but not the Strict version by default.
The result is a testing blind spot that’s particularly hard to diagnose: any automated production test will report that Sentry works, because it does under a standard headless browser. The bug surfaces only when a real user with a real browser and aggressive blocking interacts with the app.
To faithfully reproduce the scenario, the team would need to install Brave in the CI environment, configure Shields, and orchestrate tests using playwright._impl._browser_type pointing to the Brave executable. While possible, no project defaults to this setup.
The Solution: Three Layers That Must Work Together
The official strategy Sentry recommends for avoiding ad blockers is a tunnel, described briefly in their troubleshooting documentation. The idea is for the browser to send events to an endpoint under the same domain as the application, and for the server to forward these to Sentry. From the browser’s perspective, all Sentry traffic is hidden under a custom origin that ad blockers don’t recognize.
The official docs cover the concept but not the details that ensure a functional integration in production against real browsers.
Layer 1: Host the SDK Bundle Locally
The first step is to download the Sentry bundle and serve it as a static resource:
curl -L -o app/static/js/vendor/sentry-browser.min.js \
https://browser.sentry-cdn.com/10.53.1/bundle.min.js
In the HTML template, replace the CDN loader script with a reference to the local bundle:
<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>
With this change, the bundle loads from an origin not included in ad blockers’ lists. If your application uses a Content Security Policy, this change also allows stricter enforcement: the script-src can remain 'self' without exceptions for *.sentry-cdn.com.
Layer 2: Server-Side Tunnel in 50 Lines of Flask
Before diving into the code, let’s clarify terminology. The Sentry SDK doesn’t send events as regular HTTP requests with JSON bodies. Instead, it packages them in a proprietary format called an envelope: a plaintext package where the first line is a JSON header with metadata (including the project’s dsn), followed by lines containing the items—events, attachments, sessions, or transactions—the SDK wants to submit in the same request. Sentry’s ingestion endpoint always expects this format.
The tunnel’s job is to accept the envelope from the browser and forward it to Sentry as-is, with one critical exception we’ll address in the first gotcha.
Here’s the basic implementation with 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)
# Always rewrite the DSN to the server-side one.
# See Gotcha 1 below: the browser SDK omits `dsn` sometimes.
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)
# Forward response body, status, and relevant coordination headers.
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)
On the client side, make one additional configuration change:
window.Sentry.init({
dsn: dsn,
tunnel: "/api/sentry-tunnel",
environment: environment,
release: release,
tracesSampleRate: 0,
sendDefaultPii: false,
});
With these changes, the browser sends POST requests containing envelopes to /api/sentry-tunnel on the same domain as the app. The ad blocker sees traffic only to the app’s server and doesn’t block it.
The Three Gotchas Not Covered in the Documentation
The difference between an integration that merely appears to work and one that reliably functions lies in these details.
Gotcha 1: The SDK Omits the dsn Field When Using tunnel:
As noted earlier, the envelope header includes the project’s dsn. Sentry’s ingestion endpoint validates it and responds with 401 Bad envelope authentication header — missing field "dsn" if it’s absent.
When configured with tunnel:, the Sentry browser SDK sometimes omits this field, assuming the tunnel already knows which project to forward to. In other cases, it includes it. The behavior varies depending on the SDK version and the internal logic generating envelopes.
The correct solution is to always rewrite the dsn field in the tunnel before forwarding to the actual ingestion endpoint. This achieves two goals:
- Ensures Sentry finds the field it requires.
- Prevents the tunnel from being used as an open proxy to other Sentry projects. Any DSN from the client gets overridden with the server-configured one.
This is implemented above in the line header["dsn"] = ....
Gotcha 2: Propagating X-Sentry-Rate-Limits and Retry-After
The Sentry browser SDK implements a local rate-limiting system based on feedback from the ingestion server. When the server responds with a 429, a 4xx, or similar errors, it parses the X-Sentry-Rate-Limits and Retry-After headers to calculate a backoff period during which events are silently dropped.
If the tunnel doesn’t forward these headers from the upstream response, the SDK only sees the HTTP status code. Moreover, if the tunnel returns a 4xx itself—for example, during a deployment that temporarily invalidates configuration—the SDK might treat the response as a generic rate-limit signal and enter a default backoff of 60 seconds or more.
The result is a persistent, hard-to-debug issue: events stop being sent, the SDK reports success, but nothing reaches Sentry. The solution is to propagate the upstream headers precisely as received from Sentry, without modification.
Gotcha 3: A Default Integration May Be Discarding Events
This issue is harder to pin down. The Sentry browser SDK comes with a set of default integrations enabled: GlobalHandlers, BrowserApiErrors, Breadcrumbs, LinkedErrors, Dedupe, InboundFilters, and more.
In my specific environment—Brave on Chromium for real browsers, Playwright headless on Chromium for tests, Sentry browser SDK 10.53.1, custom tunnel—with all default integrations enabled, events are discarded somewhere between creation and submission. The SDK runs beforeSend, emits the afterSendEvent hook with an event_id and an empty response, but never triggers beforeEnvelope. No envelope is constructed, and nothing is sent over the network.
Through trial and error, I identified that disabling all default integrations except GlobalHandlers resolves the issue. This doesn’t necessarily mean the other integrations are inherently broken. The bug could involve InboundFilters, BrowserApiErrors, or a specific edge case arising from interactions between the stack trace and the SDK’s internal processing. Identifying the exact cause would require systematically re-enabling each integration and running E2E tests that check for real event submissions—a task for the backlog.
The configuration I verified as consistently delivering events to production is minimal:
window.Sentry.init({
dsn: dsn,
tunnel: "/api/sentry-tunnel",
environment: environment,
release: release,
tracesSampleRate: 0,
sendDefaultPii: false,
defaultIntegrations: false,
integrations: [window.Sentry.globalHandlersIntegration()],
});
The trade-off with this minimal configuration is more significant than it initially seems. You lose Breadcrumbs (automatic tracking of clicks, console calls, fetch/xhr requests, and navigations leading up to the error), HttpContext (URL, user-agent, and referer attached to the event context), LinkedErrors (chains of nested errors), and BrowserApiErrors (wrappers for setTimeout, setInterval, event listeners, and explicit promise rejections inside asynchronous callbacks). Some unhandled rejections might also fail to be captured, depending on the SDK version and context.
While this basic configuration reliably delivers events, it’s more of a temporary workaround while investigating which integration is at fault.
Implications for Content Security Policy
An interesting side benefit of this architecture is that the Content Security Policy (CSP) becomes stricter, not looser. After implementing all three layers, the final CSP might look like this:
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
No *.sentry.io in connect-src. No *.sentry-cdn.com in script-src. Any future code attempting to connect directly to Sentry—whether via a new dependency or an activated integration defaulting to the original endpoint—will fail with a visible CSP violation in the developer console. This loud failure acts as an early warning system against regressions.
This stands in contrast to the common practice of loosening the CSP whenever a new tracking tool is added. Tunneling allows you to maintain a minimal CSP while migrating traffic to auditable components within your server infrastructure.
Operational Takeaway
The default Sentry browser SDK integration, set up according to official documentation, doesn’t work for a non-trivial percentage of tech-savvy users. This leads to biased incident data: reported errors reflect only users without blocking measures, not the entire user base.
Teams making roadmap decisions based on Sentry dashboards are working with incomplete data. Users who actively protect their privacy tend to be the most discerning, the most likely to report bugs through official channels, and those who amplify failures on social media. Losing their input can be particularly costly.
Immediate actions:
- Audit your integration with a real browser. Install Brave or uBlock Origin on Chrome, run through critical flows, and confirm that errors show up in the Sentry dashboard. Smoke tests with headless Chromium aren’t sufficient.
- Set up tunneling if you haven’t already. It only takes 50 server-side lines of code. CSP becomes stricter, and you stop losing valuable data.
- Ensure your client uses minimal integrations for consistent event delivery. Also, run an E2E test that triggers a real error from a same-origin script and confirms via Sentry’s API that the event reached the dashboard.
- Check your server logs for the share of traffic from Brave or browsers with strict tracking protection. If it’s over 10%, this work is not optional.
The full code for the tunnel and E2E smoke tests is available in the internal repository where it was developed. These steps require proper coordination: three layers and just a couple of hours of migration work for a team well-versed in their stack.
This article was originally published in Spanish and translated with the help of AI.