ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
/* Error reporting to a GlitchTip (Sentry-protocol) project.
|
||||
*
|
||||
* 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_.
|
||||
*/
|
||||
|
||||
/* The default lives in lib/hosted-defaults.ts (blank in the Community edition); an explicit
|
||||
NEXT_PUBLIC_GLITCHTIP_DSN wins. The key is public by protocol design. */
|
||||
import { HOSTED_GLITCHTIP_DSN } from "./hosted-defaults";
|
||||
const DSN = process.env.NEXT_PUBLIC_GLITCHTIP_DSN || HOSTED_GLITCHTIP_DSN;
|
||||
|
||||
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 default DSN is the hosted service's. It 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);
|
||||
// On the server only a real deploy reports: a deploy compiles NEXT_PUBLIC_RELEASE into the
|
||||
// build, local `next start`/e2e runs never set it. Without this, every suite run at home
|
||||
// filed its scaffolding failures as production issues (44 of them, none from a customer).
|
||||
if (!process.env.NEXT_PUBLIC_RELEASE) return false;
|
||||
return process.env.EDITION !== "community";
|
||||
}
|
||||
|
||||
/* Browser "Script error." is the cross-origin placeholder the browser substitutes for an error
|
||||
thrown by a third-party script (Turnstile, the analytics beacon): no message, no stack, no
|
||||
location. It cannot be acted on, so it is dropped rather than paged. */
|
||||
function ignorable(err: Error): boolean {
|
||||
return /^Script error\.?$/.test((err.message || "").trim());
|
||||
}
|
||||
|
||||
/* Prisma errors begin with a blank line and an engine id, which the scrubber turns into "[id]:",
|
||||
so the issue title says nothing. Use the first line that carries words instead. */
|
||||
function headline(value: string): string {
|
||||
const lines = value.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
return lines.find((l) => !/^\[id\]:?$/.test(l) && !/^Invalid `.*` invocation:?$/.test(l)) || lines[0] || value;
|
||||
}
|
||||
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));
|
||||
if (ignorable(err)) return;
|
||||
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: headline(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