diff --git a/imports/startup/client/ravenLogger.js b/imports/startup/client/ravenLogger.js index df34170..8e8efb7 100644 --- a/imports/startup/client/ravenLogger.js +++ b/imports/startup/client/ravenLogger.js @@ -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' }); } diff --git a/imports/startup/server/index.js b/imports/startup/server/index.js index 0e69fa7..b267122 100644 --- a/imports/startup/server/index.js +++ b/imports/startup/server/index.js @@ -1,4 +1,5 @@ import './ravenLogger'; +import './sentryTunnel'; import './catchExceptions'; import './i18n'; import './accounts'; diff --git a/imports/startup/server/sentryTunnel.js b/imports/startup/server/sentryTunnel.js new file mode 100644 index 0000000..9ffd5ba --- /dev/null +++ b/imports/startup/server/sentryTunnel.js @@ -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://@/ + 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}`); + } +}