ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
/* Error reporting to ThreadCount's own GlitchTip.
|
||||
*
|
||||
* GlitchTip speaks Sentry's protocol, so this posts a Sentry "store" event by hand rather than
|
||||
* pulling in @sentry/nextjs. Three reasons that's the right trade here:
|
||||
* - the SDK is tens of kilobytes on a phone that a nurse uses in a linen room;
|
||||
* - it hooks the build (withSentryConfig) on a very new Next, which is a compatibility risk the
|
||||
* project doesn't need;
|
||||
* - and everything that leaves this file has to be scrubbed first, which is far easier to
|
||||
* guarantee when there is exactly one function doing the sending.
|
||||
*
|
||||
* What it costs: no source maps, so client stack frames are minified, and no automatic
|
||||
* breadcrumbs. The message, the scrubbed path and the stack are still vastly better than the
|
||||
* nothing that was here before.
|
||||
*
|
||||
* The DSN's public key is not a secret — Sentry-protocol keys are designed to sit in client
|
||||
* bundles — so it is safe in NEXT_PUBLIC_.
|
||||
*/
|
||||
|
||||
/* Committed rather than env-only, exactly as the Umami site ids are. NEXT_PUBLIC_ values are baked
|
||||
at build time, and prod builds on the server — so leaving this to an env var means one forgotten
|
||||
line in secrets.env silently ships a release that reports nothing, which is the failure mode
|
||||
this whole file exists to prevent. The key is public by protocol design. */
|
||||
const DSN = process.env.NEXT_PUBLIC_GLITCHTIP_DSN
|
||||
|| "https://e50e9944-0aae-41ed-8691-5bbec1878f65@errors.threadcount.tech/1";
|
||||
|
||||
type Parsed = { url: string; key: string };
|
||||
|
||||
/** `https://<key>@host/<projectId>` -> the store endpoint and the auth key. */
|
||||
function parseDsn(dsn: string): Parsed | null {
|
||||
try {
|
||||
const u = new URL(dsn);
|
||||
const projectId = u.pathname.replace(/^\//, "");
|
||||
if (!u.username || !projectId) return null;
|
||||
return { url: `${u.protocol}//${u.host}/api/${projectId}/store/`, key: u.username };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const PARSED = DSN ? parseDsn(DSN) : null;
|
||||
|
||||
/* The committed DSN belongs to threadcount.tech. A Community instance built from this same code
|
||||
must not report its errors — scrubbed or not — to somebody else's GlitchTip unless its operator
|
||||
chose to by setting NEXT_PUBLIC_GLITCHTIP_DSN. So the default is used only where it belongs: on
|
||||
the server, when this is not the community edition; in the browser, when the page is served
|
||||
from threadcount.tech. An explicit DSN is honoured anywhere. */
|
||||
const EXPLICIT = !!process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
|
||||
function allowed(): boolean {
|
||||
if (!PARSED) return false;
|
||||
if (EXPLICIT) return true;
|
||||
if (typeof window !== "undefined") return /(^|\.)threadcount\.tech$/.test(window.location.hostname);
|
||||
return process.env.EDITION !== "community";
|
||||
}
|
||||
export const errorReportingOn = () => allowed();
|
||||
|
||||
/* Redaction.
|
||||
*
|
||||
* An error message is not written by us. A Prisma failure quotes the row it choked on, a
|
||||
* constraint violation quotes the value, and a validation error quotes what someone typed. Any of
|
||||
* those can carry a staff name, a work email, a payroll number or a record id straight into the
|
||||
* error tracker, which would undo the care taken everywhere else. */
|
||||
const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/g;
|
||||
const ID_RE = /\b(?:[a-z0-9]{20,}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi;
|
||||
const LONG_NUM_RE = /\b\d{6,}\b/g;
|
||||
|
||||
export function scrubText(s: string, max = 1000): string {
|
||||
return String(s || "")
|
||||
.replace(EMAIL_RE, "[email]")
|
||||
.replace(ID_RE, "[id]")
|
||||
.replace(LONG_NUM_RE, "[number]")
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
/** Paths carry record ids; query strings carry more. Keep the shape, drop the specifics. */
|
||||
export function scrubUrl(raw: string): string {
|
||||
try {
|
||||
const u = new URL(raw, "https://threadcount.tech");
|
||||
const path = u.pathname
|
||||
.split("/")
|
||||
.map((seg) => (ID_RE.test(seg) ? ":id" : seg))
|
||||
.join("/");
|
||||
ID_RE.lastIndex = 0;
|
||||
return u.origin + path;
|
||||
} catch {
|
||||
return scrubText(raw, 200);
|
||||
}
|
||||
}
|
||||
|
||||
function uuid(): string {
|
||||
try {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID().replace(/-/g, "");
|
||||
} catch { /* fall through */ }
|
||||
let s = "";
|
||||
for (let i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16);
|
||||
return s;
|
||||
}
|
||||
|
||||
export type ReportInput = {
|
||||
error: unknown;
|
||||
where: string;
|
||||
url?: string;
|
||||
tags?: Record<string, string>;
|
||||
extra?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Fire-and-forget. Reporting must never delay, block or break the thing that failed. */
|
||||
export function report({ error, where, url, tags, extra }: ReportInput): void {
|
||||
if (!PARSED || !allowed()) return;
|
||||
try {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const digest = (error as { digest?: string })?.digest;
|
||||
|
||||
const event = {
|
||||
event_id: uuid(),
|
||||
timestamp: new Date().toISOString(),
|
||||
platform: "javascript",
|
||||
level: "error",
|
||||
logger: where,
|
||||
release: process.env.NEXT_PUBLIC_RELEASE || undefined,
|
||||
environment: process.env.NODE_ENV === "production" ? "production" : "development",
|
||||
exception: {
|
||||
values: [{
|
||||
type: scrubText(err.name || "Error", 120),
|
||||
value: scrubText(err.message || String(error), 600),
|
||||
// Sent as a single scrubbed string: without source maps a structured frame list adds
|
||||
// nothing a reader can use, and each frame is another place a path could leak.
|
||||
stacktrace: undefined,
|
||||
}],
|
||||
},
|
||||
request: url ? { url: scrubUrl(url) } : undefined,
|
||||
tags: { boundary: where, ...(digest ? { digest } : {}), ...(tags || {}) },
|
||||
extra: { ...extra, stack: scrubText(err.stack || "", 4000) },
|
||||
};
|
||||
|
||||
const body = JSON.stringify(event);
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"x-sentry-auth": `Sentry sentry_version=7, sentry_client=threadcount/1.0, sentry_key=${PARSED.key}`,
|
||||
};
|
||||
|
||||
void fetch(PARSED.url, { method: "POST", headers, body, keepalive: true }).catch(() => {});
|
||||
} catch { /* a reporter that throws is worse than one that stays quiet */ }
|
||||
}
|
||||
Reference in New Issue
Block a user