#!/usr/bin/env node // A local stand-in for Sentry's ingest endpoint, for verifying error tracking // without an account: point any Mike runtime's DSN at it and every event the // SDK would have sent to sentry.io lands here instead, printed to the // terminal and listed at http://localhost:/ . // // node scripts/sentry-sink.mjs # listens on 9999 // SENTRY_DSN=http://mike@localhost:9999/1 (backend) // NEXT_PUBLIC_SENTRY_DSN=http://mike@localhost:9999/2 (web app, browser) // REACT_APP_SENTRY_DSN=http://mike@localhost:9999/3 (Word add-in) // // The project number in the DSN is arbitrary; it only shows up in the path // the SDK posts to (/api//envelope/). No dependencies: Node 22 only. import http from "node:http"; import { createGunzip, createInflate } from "node:zlib"; const port = Number.parseInt(process.env.PORT ?? "9999", 10); const events = []; const MAX_EVENTS = 500; const MAX_REQUEST_BYTES = 1024 * 1024; const MAX_DECOMPRESSED_BYTES = 5 * 1024 * 1024; const ALLOWED_ORIGINS = new Set([ `http://localhost:${port}`, `http://127.0.0.1:${port}`, "http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:3100", "http://127.0.0.1:3100", "https://localhost:3200", "https://127.0.0.1:3200", ]); class PayloadTooLargeError extends Error {} /** Collect a bounded request body while continuing to drain oversized uploads. */ function readRequestBody(req) { return new Promise((resolve, reject) => { const declaredLength = Number(req.headers["content-length"]); if (Number.isFinite(declaredLength) && declaredLength > MAX_REQUEST_BYTES) { req.resume(); reject(new PayloadTooLargeError("compressed request body is too large")); return; } const chunks = []; let received = 0; let oversized = false; req.on("data", (chunk) => { if (oversized) return; received += chunk.length; if (received > MAX_REQUEST_BYTES) { oversized = true; chunks.length = 0; reject(new PayloadTooLargeError("compressed request body is too large")); return; } chunks.push(chunk); }); req.once("end", () => resolve(Buffer.concat(chunks))); req.once("error", reject); req.once("aborted", () => reject(new Error("request aborted"))); }); } /** Decompress without blocking the event loop or retaining oversized output. */ function decompress(raw, encoding) { return new Promise((resolve, reject) => { const decoder = encoding === "gzip" ? createGunzip() : createInflate(); const chunks = []; let decoded = 0; let settled = false; decoder.on("data", (chunk) => { if (settled) return; decoded += chunk.length; if (decoded > MAX_DECOMPRESSED_BYTES) { settled = true; decoder.destroy(); reject(new PayloadTooLargeError("decompressed request body is too large")); return; } chunks.push(chunk); }); decoder.once("end", () => { if (!settled) resolve(Buffer.concat(chunks)); }); decoder.once("error", (error) => { if (!settled) reject(error); }); decoder.end(raw); }); } /** Decode an uncompressed, gzip, or deflate request after enforcing both limits. */ async function decodeBody(req) { const raw = await readRequestBody(req); const encoding = req.headers["content-encoding"]; if (encoding === "gzip" || encoding === "deflate") { return (await decompress(raw, encoding)).toString("utf8"); } return raw.toString("utf8"); } /** Permit browser access only from the sink itself and Mike's local app origins. */ function corsHeaders(req) { const origin = req.headers.origin; if (typeof origin !== "string" || !ALLOWED_ORIGINS.has(origin)) return {}; return { "access-control-allow-origin": origin, "access-control-allow-headers": "content-type, x-sentry-auth, sentry-trace, baggage", "access-control-allow-methods": "POST, GET, DELETE, OPTIONS", vary: "Origin", }; } /** Sentry envelopes are newline-delimited JSON: header, then (item header, item payload) pairs. */ function parseEnvelope(text) { const lines = text.split("\n").filter((line) => line.trim().length > 0); const parsed = lines.map((line) => { try { return JSON.parse(line); } catch { return { _raw: line.slice(0, 200) }; } }); const [header, ...rest] = parsed; const items = []; for (let i = 0; i + 1 < rest.length; i += 2) { items.push({ header: rest[i], payload: rest[i + 1] }); } return { header: header ?? {}, items }; } function summarize(project, event) { const exception = event.exception?.values?.[0]; const title = exception ? `${exception.type}: ${exception.value}` : event.message ?? "(no message)"; const frame = exception?.stacktrace?.frames?.at(-1); return { received_at: new Date().toISOString(), project, level: event.level ?? "error", environment: event.environment ?? null, release: event.release ?? null, platform: event.platform ?? null, title, where: frame ? `${frame.filename ?? frame.abs_path}:${frame.lineno}` : null, tags: event.tags ?? {}, user: event.user ?? null, request: event.request ? { method: event.request.method, url: event.request.url } : null, fingerprint: event.fingerprint ?? null, mechanism: exception?.mechanism?.type ?? null, event_id: event.event_id, raw: event, }; } function record(project, event) { const summary = summarize(project, event); events.unshift(summary); if (events.length > MAX_EVENTS) events.length = MAX_EVENTS; const tags = Object.entries(summary.tags) .map(([key, value]) => `${key}=${value}`) .join(" "); console.log( `[${summary.received_at}] project=${project} level=${summary.level} ` + `${summary.title}${summary.where ? ` @ ${summary.where}` : ""}` + `${tags ? `\n ${tags}` : ""}` + `${summary.request ? `\n ${summary.request.method} ${summary.request.url}` : ""}` + `${summary.user ? `\n user=${JSON.stringify(summary.user)}` : ""}`, ); } const PAGE = `Sentry sink

Sentry sink — 0 events (newest first, refreshes every 2s)

`; const server = http.createServer((req, res) => { const url = new URL(req.url ?? "/", "http://sink.local"); const origin = req.headers.origin; if (typeof origin === "string" && !ALLOWED_ORIGINS.has(origin)) { res.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); res.end("Origin not allowed"); return; } const cors = corsHeaders(req); if (req.method === "OPTIONS") { res.writeHead(204, cors).end(); return; } if (req.method === "GET" && url.pathname === "/events") { res.writeHead(200, { ...cors, "content-type": "application/json" }); res.end(JSON.stringify(events)); return; } if (req.method === "GET" && url.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(PAGE); return; } if (req.method === "DELETE" && url.pathname === "/events") { events.length = 0; res.writeHead(204, cors).end(); return; } const ingest = url.pathname.match(/^\/api\/(\d+)\/(envelope|store)\/?$/); if (req.method === "POST" && ingest) { void (async () => { const project = ingest[1]; try { const text = await decodeBody(req); if (ingest[2] === "store") { record(project, JSON.parse(text)); } else { for (const item of parseEnvelope(text).items) { if (item.header?.type === "event") record(project, item.payload); } } } catch (error) { if (error instanceof PayloadTooLargeError) { res.writeHead(413, { ...cors, "content-type": "application/json" }); res.end('{"error":"payload too large"}'); return; } console.error("[sink] could not parse envelope", error); } res.writeHead(200, { ...cors, "content-type": "application/json" }); res.end("{}"); })(); return; } res.writeHead(404, cors).end(); }); server.listen(port, "127.0.0.1", () => { console.log(`Sentry sink listening on http://localhost:${port}`); console.log(` DSN for any Mike runtime: http://mike@localhost:${port}/1`); console.log(` Events page: http://localhost:${port}/`); });