sentry: tunnel client events through the app to dodge Cloudflare's 503

GlitchTip is behind Cloudflare, which answers the browser's cross-site ingest
POSTs with 503 (no CORS headers -> 'Failed to fetch'); server-side requests
pass. The browser SDK now posts envelopes same-origin to /sentry-tunnel, and
the server forwards them to GlitchTip:
- imports/startup/server/sentryTunnel.js: WebApp handler that relays the raw
  envelope to <dsn>/api/<project>/envelope/?sentry_key=<key> over IPv4
  (family:4 avoids Happy-Eyeballs picking Cloudflare's unroutable IPv6 on
  IPv4-only hosts). GlitchTip authenticates by the sentry_key query, so the
  key is derived from the DSN and passed explicitly.
- client ravenLogger: tunnel: '/sentry-tunnel'.
Verified: POST /sentry-tunnel -> 200 (envelope reaches GlitchTip).
This commit is contained in:
vjrj 2026-07-17 18:17:47 +02:00
parent 696789e2b1
commit 62ac2fbd9c
3 changed files with 135 additions and 1 deletions

View file

@ -13,7 +13,13 @@ if (enabled) {
Sentry.init({
dsn,
environment: Meteor.isDevelopment ? 'development' : 'production',
tracesSampleRate: 0
tracesSampleRate: 0,
// Send events same-origin to our own server, which forwards them to
// GlitchTip. GlitchTip is fronted by Cloudflare, whose bot protection
// returns 503 to the browser's cross-site ingest beacons (server-side
// requests pass fine). The tunnel sidesteps that entirely (and ad-blockers).
// See imports/startup/server/sentryTunnel.js.
tunnel: '/sentry-tunnel'
});
}

View file

@ -1,4 +1,5 @@
import './ravenLogger';
import './sentryTunnel';
import './catchExceptions';
import './i18n';
import './accounts';

View file

@ -0,0 +1,127 @@
/* eslint-disable import/no-absolute-path */
import https from 'https';
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
/*
* Sentry tunnel: /sentry-tunnel receives the browser SDK's envelopes
* same-origin and forwards them to GlitchTip server-side.
*
* Why: GlitchTip lives behind Cloudflare (sentry.comunes.org). Cloudflare's
* bot protection answers the browser's cross-site ingest POSTs with 503 (no
* CORS headers -> "Failed to fetch"), while identical server-side requests get
* 200. Routing events through our own origin avoids the cross-site request
* (and ad-blockers) completely. The browser SDK opts in via `tunnel` in
* imports/startup/client/ravenLogger.js.
*
* Single-DSN app: the target is derived from sentryPublicDSN, so we never have
* to decompress/parse the envelope body we just relay the raw bytes to the
* right project's ingest endpoint.
*/
const MAX_BODY = 1024 * 1024; // 1 MB: Sentry envelopes are small; cap abuse.
function ingestTargetFromDsn(dsn) {
// DSN: https://<publicKey>@<host>/<projectId>
const u = new URL(dsn);
const projectId = u.pathname.replace(/^\//, '');
const publicKey = u.username;
if (!projectId || !publicKey) throw new Error('DSN missing project id or public key');
// GlitchTip authenticates the ingest by the `sentry_key` query param; when the
// SDK tunnels, that key lives inside the envelope header, which GlitchTip does
// not read here -> it would answer 403 "Denied". Pass the key explicitly.
const path = `/api/${projectId}/envelope/?sentry_key=${publicKey}&sentry_version=7`;
return { hostname: u.hostname, port: u.port || 443, path };
}
/*
* Forward the raw envelope to GlitchTip over IPv4 (`family: 4`). GlitchTip is
* behind Cloudflare, which is dual-stack; on IPv4-only hosts Node's fetch/
* Happy-Eyeballs may pick the unroutable IPv6 and fail. Cloudflare always has
* IPv4, so pinning the family is safe everywhere and avoids that flakiness.
*/
function forwardToIngest(target, headers, body) {
return new Promise((resolve, reject) => {
const upstream = https.request({
hostname: target.hostname,
port: target.port,
path: target.path,
method: 'POST',
family: 4,
headers
}, (r) => {
r.resume(); // drain the response
if (r.statusCode >= 400) {
console.error(`sentry-tunnel: upstream returned ${r.statusCode}`);
}
resolve(r.statusCode);
});
upstream.on('error', reject);
upstream.end(body);
});
}
const publicDsn = Meteor.settings.public && Meteor.settings.public.sentryPublicDSN;
if (publicDsn) {
let ingestTarget;
try {
ingestTarget = ingestTargetFromDsn(publicDsn);
} catch (e) {
console.error(`sentry-tunnel: invalid sentryPublicDSN, tunnel disabled: ${e.message}`);
}
if (ingestTarget) {
WebApp.connectHandlers.use('/sentry-tunnel', (req, res) => {
if (req.method !== 'POST') {
res.writeHead(405, { Allow: 'POST' });
res.end();
return;
}
const chunks = [];
let size = 0;
let aborted = false;
req.on('data', (chunk) => {
if (aborted) return;
size += chunk.length;
if (size > MAX_BODY) {
aborted = true;
res.writeHead(413);
res.end();
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', async () => {
if (aborted) return;
try {
const body = Buffer.concat(chunks);
const headers = { 'Content-Type': 'application/x-sentry-envelope' };
if (req.headers['content-encoding']) {
headers['Content-Encoding'] = req.headers['content-encoding'];
}
// Preserve the reporter's IP so GlitchTip attributes the event correctly.
const fwd = req.headers['x-forwarded-for'];
headers['X-Forwarded-For'] = fwd
? `${fwd}, ${req.socket.remoteAddress}`
: req.socket.remoteAddress;
const status = await forwardToIngest(ingestTarget, headers, body);
res.writeHead(status);
res.end();
} catch (err) {
// Never let a reporting failure surface to the user; just log it.
console.error(`sentry-tunnel: forward failed: ${err.message}`);
res.writeHead(502);
res.end();
}
});
});
console.log(`sentry-tunnel enabled -> https://${ingestTarget.hostname}${ingestTarget.path}`);
}
}