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:
parent
696789e2b1
commit
62ac2fbd9c
3 changed files with 135 additions and 1 deletions
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import './ravenLogger';
|
||||
import './sentryTunnel';
|
||||
import './catchExceptions';
|
||||
import './i18n';
|
||||
import './accounts';
|
||||
|
|
|
|||
127
imports/startup/server/sentryTunnel.js
Normal file
127
imports/startup/server/sentryTunnel.js
Normal 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}`);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue