scripts/sentry-sink.mjs
Rama main · réplica de lectura del repositorio original.
#!/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:<port>/ .
//
// 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/<project>/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 = `<!doctype html><meta charset="utf-8"><title>Sentry sink</title>
<style>
body{font:14px/1.4 system-ui,sans-serif;margin:2rem;color:#111;background:#fafafa}
h1{font-size:1.1rem;font-weight:600}
.event{border:1px solid #ddd;border-radius:8px;padding:.75rem 1rem;margin:.5rem 0;background:#fff}
.error{border-left:4px solid #d33}.warning{border-left:4px solid #e9a400}.fatal{border-left:4px solid #700}.info{border-left:4px solid #48c}
.title{font-weight:600}.meta{color:#666;font-size:12px}.tags{font-family:ui-monospace,monospace;font-size:12px;margin-top:.25rem}
.tag{display:inline-block;background:#eef;border-radius:4px;padding:0 .35rem;margin:.1rem .2rem 0 0}
details{margin-top:.35rem}pre{font-size:11px;overflow:auto;max-height:20rem;background:#f4f4f4;padding:.5rem}
</style>
<h1>Sentry sink — <span id="count">0</span> events (newest first, refreshes every 2s)</h1>
<div id="list"></div>
<script>
async function refresh(){
const events = await (await fetch('/events')).json();
document.getElementById('count').textContent = events.length;
document.getElementById('list').innerHTML = events.map(e => {
const tags = Object.entries(e.tags).map(([k,v]) => '<span class="tag">'+esc(k)+'='+esc(v)+'</span>').join('');
return '<div class="event '+esc(e.level)+'"><div class="title">'+esc(e.title)+'</div>'+
'<div class="meta">'+esc(e.received_at)+' · project '+esc(e.project)+' · '+esc(e.level)+' · '+esc(e.platform||'')+' · '+esc(e.environment||'')+(e.where?' · '+esc(e.where):'')+(e.request?' · '+esc(e.request.method)+' '+esc(e.request.url):'')+(e.user?' · user '+esc(JSON.stringify(e.user)):'')+'</div>'+
'<div class="tags">'+tags+'</div>'+
'<details><summary>raw event</summary><pre>'+esc(JSON.stringify(e.raw,null,2))+'</pre></details></div>';
}).join('');
}
function esc(s){return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
refresh(); setInterval(refresh, 2000);
</script>`;
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}/`);
});