From 6877b0ac7d746e4c46d03855388d5913b8d3ccc4 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 4 May 2026 21:43:50 +0200 Subject: [PATCH 1/2] feat: add Sentry tunnel and route error tracking through s.archgate.dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a lightweight Sentry envelope tunnel server (services/sentry-tunnel/) that receives envelopes from the CLI and forwards them to Sentry's ingest endpoint. This mirrors the PostHog managed proxy at n.archgate.dev — routing through our own domain improves reliability behind corporate proxies and ad-blockers. Tunnel server: - Bun HTTP server with envelope header parsing and DSN extraction - Project ID allowlist via ALLOWED_PROJECT_IDS env var - Health check at /health, accepts POST at / and /tunnel - Dockerfile for Railway deployment CLI change: - Add tunnel: "https://s.archgate.dev/tunnel" to Sentry.init() so the SDK sends envelopes to our tunnel instead of directly to ingest.de.sentry.io Signed-off-by: Rhuan Barreto --- services/sentry-tunnel/Dockerfile | 6 + services/sentry-tunnel/package.json | 10 ++ services/sentry-tunnel/server.ts | 188 ++++++++++++++++++++++++++++ src/helpers/sentry.ts | 9 ++ 4 files changed, 213 insertions(+) create mode 100644 services/sentry-tunnel/Dockerfile create mode 100644 services/sentry-tunnel/package.json create mode 100644 services/sentry-tunnel/server.ts diff --git a/services/sentry-tunnel/Dockerfile b/services/sentry-tunnel/Dockerfile new file mode 100644 index 00000000..5cf4c867 --- /dev/null +++ b/services/sentry-tunnel/Dockerfile @@ -0,0 +1,6 @@ +FROM oven/bun:1.3-slim +WORKDIR /app +COPY package.json server.ts ./ +RUN bun install --production +EXPOSE 3000 +CMD ["bun", "run", "server.ts"] diff --git a/services/sentry-tunnel/package.json b/services/sentry-tunnel/package.json new file mode 100644 index 00000000..b83a026a --- /dev/null +++ b/services/sentry-tunnel/package.json @@ -0,0 +1,10 @@ +{ + "name": "@archgate/sentry-tunnel", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch run server.ts", + "start": "bun run server.ts" + } +} diff --git a/services/sentry-tunnel/server.ts b/services/sentry-tunnel/server.ts new file mode 100644 index 00000000..dca4bf1c --- /dev/null +++ b/services/sentry-tunnel/server.ts @@ -0,0 +1,188 @@ +/** + * Sentry Tunnel — A lightweight reverse proxy for Sentry event ingestion. + * + * Receives Sentry envelopes from the CLI, validates the project ID against + * an allowlist, and forwards them to the real Sentry ingest endpoint. This + * avoids the CLI hitting sentry.io directly, which improves reliability + * behind corporate proxies and ad-blockers. + * + * Deploy on Railway with a custom domain (e.g. s.archgate.dev). + * + * Environment variables: + * PORT — HTTP port (default: 3000, Railway sets this) + * ALLOWED_PROJECT_IDS — Comma-separated Sentry project IDs to accept + * ALLOWED_ORIGINS — Comma-separated allowed Origin headers (optional, for CORS) + */ + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const PORT = parseInt(Bun.env.PORT ?? "3000", 10); + +/** Only forward envelopes destined for these Sentry project IDs. */ +const ALLOWED_PROJECT_IDS = new Set( + (Bun.env.ALLOWED_PROJECT_IDS ?? "").split(",").filter(Boolean) +); + +/** Allowed origins for CORS preflight (empty = allow all). */ +const ALLOWED_ORIGINS = new Set( + (Bun.env.ALLOWED_ORIGINS ?? "").split(",").filter(Boolean) +); + +// --------------------------------------------------------------------------- +// Envelope parsing +// --------------------------------------------------------------------------- + +interface EnvelopeHeader { + dsn: string; +} + +/** + * Parse the first line of a Sentry envelope to extract the DSN. + * + * Envelope format (newline-delimited): + * line 0: JSON header {"event_id":"...","dsn":"https://key@host/project_id",...} + * line 1+: item headers + payloads + * + * @see https://develop.sentry.dev/sdk/envelopes/ + */ +function parseEnvelopeHeader(body: string): EnvelopeHeader | null { + const newlineIndex = body.indexOf("\n"); + const headerLine = newlineIndex === -1 ? body : body.slice(0, newlineIndex); + + try { + const header = JSON.parse(headerLine) as Record; + if (typeof header.dsn !== "string") return null; + return { dsn: header.dsn }; + } catch { + return null; + } +} + +/** + * Extract the ingest host and project ID from a Sentry DSN. + * + * DSN format: https://@/ + * Example: https://abc123@o123.ingest.de.sentry.io/456 + * → host: o123.ingest.de.sentry.io + * → projectId: 456 + */ +function parseDsn(dsn: string): { host: string; projectId: string } | null { + try { + const url = new URL(dsn); + const projectId = url.pathname.slice(1); // remove leading "/" + if (!projectId || !url.hostname) return null; + return { host: url.hostname, projectId }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// CORS helpers +// --------------------------------------------------------------------------- + +function corsHeaders(origin: string | null): Record { + const headers: Record = { + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, X-Sentry-Auth", + "Access-Control-Max-Age": "86400", + }; + + if (ALLOWED_ORIGINS.size === 0) { + // No allowlist → reflect any origin (fine for a write-only ingest proxy) + headers["Access-Control-Allow-Origin"] = origin ?? "*"; + } else if (origin && ALLOWED_ORIGINS.has(origin)) { + headers["Access-Control-Allow-Origin"] = origin; + } + + return headers; +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +const server = Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + const origin = req.headers.get("Origin"); + + // Health check + if (url.pathname === "/health" && req.method === "GET") { + return new Response("ok", { status: 200 }); + } + + // CORS preflight + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders(origin) }); + } + + // Only accept POST to /tunnel (or root /) + if ( + req.method !== "POST" || + (url.pathname !== "/tunnel" && url.pathname !== "/") + ) { + return new Response("Not Found", { status: 404 }); + } + + // --- Read and validate the envelope --- + + let body: string; + try { + body = await req.text(); + } catch { + return new Response("Bad Request", { status: 400 }); + } + + if (!body) { + return new Response("Empty body", { status: 400 }); + } + + const envelope = parseEnvelopeHeader(body); + if (!envelope) { + return new Response("Invalid envelope header", { status: 400 }); + } + + const dsn = parseDsn(envelope.dsn); + if (!dsn) { + return new Response("Invalid DSN in envelope", { status: 400 }); + } + + // Validate project ID against allowlist (if configured) + if ( + ALLOWED_PROJECT_IDS.size > 0 && + !ALLOWED_PROJECT_IDS.has(dsn.projectId) + ) { + return new Response("Project not allowed", { status: 403 }); + } + + // --- Forward to Sentry --- + + const upstreamUrl = `https://${dsn.host}/api/${dsn.projectId}/envelope/`; + + try { + const upstreamResponse = await fetch(upstreamUrl, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body, + }); + + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + headers: { + ...corsHeaders(origin), + "Content-Type": + upstreamResponse.headers.get("Content-Type") ?? "application/json", + }, + }); + } catch (err) { + console.error("Failed to forward to Sentry:", String(err)); + return new Response("Upstream error", { status: 502 }); + } + }, +}); + +console.log(`Sentry tunnel listening on port ${server.port}`); diff --git a/src/helpers/sentry.ts b/src/helpers/sentry.ts index eabba7ea..2fd4e569 100644 --- a/src/helpers/sentry.ts +++ b/src/helpers/sentry.ts @@ -31,6 +31,14 @@ import { getInstallId, isTelemetryEnabled } from "./telemetry-config"; const SENTRY_DSN = "https://bb693c2cbc4238dbcd6efac609062402@o4511085517340672.ingest.de.sentry.io/4511085521469520"; +/** + * Sentry tunnel — routes envelopes through our own domain instead of + * hitting ingest.de.sentry.io directly. Better reputation with corporate + * proxies and ad-blockers, matching the PostHog proxy at n.archgate.dev. + * The tunnel server lives in services/sentry-tunnel/ and runs on Railway. + */ +const SENTRY_TUNNEL = "https://s.archgate.dev/tunnel"; + // --------------------------------------------------------------------------- // Install method detection // --------------------------------------------------------------------------- @@ -78,6 +86,7 @@ export async function initSentry(): Promise { Sentry = await import("@sentry/node-core/light"); Sentry.init({ dsn: SENTRY_DSN, + tunnel: SENTRY_TUNNEL, release: cliVersion, environment: Bun.env.NODE_ENV ?? "production", // Disable sending events in test environments From 5300d76e4607574b956a284b9cbce13bdcc140b0 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 4 May 2026 21:47:28 +0200 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20remove=20tunnel=20service=20?= =?UTF-8?q?=E2=80=94=20moved=20to=20archgate/services=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rhuan Barreto --- services/sentry-tunnel/Dockerfile | 6 - services/sentry-tunnel/package.json | 10 -- services/sentry-tunnel/server.ts | 188 ---------------------------- 3 files changed, 204 deletions(-) delete mode 100644 services/sentry-tunnel/Dockerfile delete mode 100644 services/sentry-tunnel/package.json delete mode 100644 services/sentry-tunnel/server.ts diff --git a/services/sentry-tunnel/Dockerfile b/services/sentry-tunnel/Dockerfile deleted file mode 100644 index 5cf4c867..00000000 --- a/services/sentry-tunnel/Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM oven/bun:1.3-slim -WORKDIR /app -COPY package.json server.ts ./ -RUN bun install --production -EXPOSE 3000 -CMD ["bun", "run", "server.ts"] diff --git a/services/sentry-tunnel/package.json b/services/sentry-tunnel/package.json deleted file mode 100644 index b83a026a..00000000 --- a/services/sentry-tunnel/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@archgate/sentry-tunnel", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "bun --watch run server.ts", - "start": "bun run server.ts" - } -} diff --git a/services/sentry-tunnel/server.ts b/services/sentry-tunnel/server.ts deleted file mode 100644 index dca4bf1c..00000000 --- a/services/sentry-tunnel/server.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Sentry Tunnel — A lightweight reverse proxy for Sentry event ingestion. - * - * Receives Sentry envelopes from the CLI, validates the project ID against - * an allowlist, and forwards them to the real Sentry ingest endpoint. This - * avoids the CLI hitting sentry.io directly, which improves reliability - * behind corporate proxies and ad-blockers. - * - * Deploy on Railway with a custom domain (e.g. s.archgate.dev). - * - * Environment variables: - * PORT — HTTP port (default: 3000, Railway sets this) - * ALLOWED_PROJECT_IDS — Comma-separated Sentry project IDs to accept - * ALLOWED_ORIGINS — Comma-separated allowed Origin headers (optional, for CORS) - */ - -// --------------------------------------------------------------------------- -// Configuration -// --------------------------------------------------------------------------- - -const PORT = parseInt(Bun.env.PORT ?? "3000", 10); - -/** Only forward envelopes destined for these Sentry project IDs. */ -const ALLOWED_PROJECT_IDS = new Set( - (Bun.env.ALLOWED_PROJECT_IDS ?? "").split(",").filter(Boolean) -); - -/** Allowed origins for CORS preflight (empty = allow all). */ -const ALLOWED_ORIGINS = new Set( - (Bun.env.ALLOWED_ORIGINS ?? "").split(",").filter(Boolean) -); - -// --------------------------------------------------------------------------- -// Envelope parsing -// --------------------------------------------------------------------------- - -interface EnvelopeHeader { - dsn: string; -} - -/** - * Parse the first line of a Sentry envelope to extract the DSN. - * - * Envelope format (newline-delimited): - * line 0: JSON header {"event_id":"...","dsn":"https://key@host/project_id",...} - * line 1+: item headers + payloads - * - * @see https://develop.sentry.dev/sdk/envelopes/ - */ -function parseEnvelopeHeader(body: string): EnvelopeHeader | null { - const newlineIndex = body.indexOf("\n"); - const headerLine = newlineIndex === -1 ? body : body.slice(0, newlineIndex); - - try { - const header = JSON.parse(headerLine) as Record; - if (typeof header.dsn !== "string") return null; - return { dsn: header.dsn }; - } catch { - return null; - } -} - -/** - * Extract the ingest host and project ID from a Sentry DSN. - * - * DSN format: https://@/ - * Example: https://abc123@o123.ingest.de.sentry.io/456 - * → host: o123.ingest.de.sentry.io - * → projectId: 456 - */ -function parseDsn(dsn: string): { host: string; projectId: string } | null { - try { - const url = new URL(dsn); - const projectId = url.pathname.slice(1); // remove leading "/" - if (!projectId || !url.hostname) return null; - return { host: url.hostname, projectId }; - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// CORS helpers -// --------------------------------------------------------------------------- - -function corsHeaders(origin: string | null): Record { - const headers: Record = { - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, X-Sentry-Auth", - "Access-Control-Max-Age": "86400", - }; - - if (ALLOWED_ORIGINS.size === 0) { - // No allowlist → reflect any origin (fine for a write-only ingest proxy) - headers["Access-Control-Allow-Origin"] = origin ?? "*"; - } else if (origin && ALLOWED_ORIGINS.has(origin)) { - headers["Access-Control-Allow-Origin"] = origin; - } - - return headers; -} - -// --------------------------------------------------------------------------- -// Server -// --------------------------------------------------------------------------- - -const server = Bun.serve({ - port: PORT, - async fetch(req) { - const url = new URL(req.url); - const origin = req.headers.get("Origin"); - - // Health check - if (url.pathname === "/health" && req.method === "GET") { - return new Response("ok", { status: 200 }); - } - - // CORS preflight - if (req.method === "OPTIONS") { - return new Response(null, { status: 204, headers: corsHeaders(origin) }); - } - - // Only accept POST to /tunnel (or root /) - if ( - req.method !== "POST" || - (url.pathname !== "/tunnel" && url.pathname !== "/") - ) { - return new Response("Not Found", { status: 404 }); - } - - // --- Read and validate the envelope --- - - let body: string; - try { - body = await req.text(); - } catch { - return new Response("Bad Request", { status: 400 }); - } - - if (!body) { - return new Response("Empty body", { status: 400 }); - } - - const envelope = parseEnvelopeHeader(body); - if (!envelope) { - return new Response("Invalid envelope header", { status: 400 }); - } - - const dsn = parseDsn(envelope.dsn); - if (!dsn) { - return new Response("Invalid DSN in envelope", { status: 400 }); - } - - // Validate project ID against allowlist (if configured) - if ( - ALLOWED_PROJECT_IDS.size > 0 && - !ALLOWED_PROJECT_IDS.has(dsn.projectId) - ) { - return new Response("Project not allowed", { status: 403 }); - } - - // --- Forward to Sentry --- - - const upstreamUrl = `https://${dsn.host}/api/${dsn.projectId}/envelope/`; - - try { - const upstreamResponse = await fetch(upstreamUrl, { - method: "POST", - headers: { "Content-Type": "application/x-sentry-envelope" }, - body, - }); - - return new Response(upstreamResponse.body, { - status: upstreamResponse.status, - headers: { - ...corsHeaders(origin), - "Content-Type": - upstreamResponse.headers.get("Content-Type") ?? "application/json", - }, - }); - } catch (err) { - console.error("Failed to forward to Sentry:", String(err)); - return new Response("Upstream error", { status: 502 }); - } - }, -}); - -console.log(`Sentry tunnel listening on port ${server.port}`);