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({
|
Sentry.init({
|
||||||
dsn,
|
dsn,
|
||||||
environment: Meteor.isDevelopment ? 'development' : 'production',
|
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 './ravenLogger';
|
||||||
|
import './sentryTunnel';
|
||||||
import './catchExceptions';
|
import './catchExceptions';
|
||||||
import './i18n';
|
import './i18n';
|
||||||
import './accounts';
|
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