feat(telemetry): readable Sentry errors, Python tracebacks, and diagnostic mode

Keeps a real, redacted error message instead of "Error: Error", surfaces Python tracebacks in Sentry as a vetted context, and adds an opt-in SNAPOTTER_SENTRY_DIAGNOSTIC verbose mode plus SNAPOTTER_SENTRY_DSN_OVERRIDE. The default fleet path ships nothing on the never-collect list; raw detail is reachable only via the opt-in flag. Also classifies Redis OOM/READONLY replies as operational and removes a ReDoS in stack-frame extraction.
This commit is contained in:
SnapOtter
2026-08-03 13:13:38 +08:00
committed by GitHub
parent 865390ce66
commit 04ef1141fb
22 changed files with 593 additions and 178 deletions
+14 -4
View File
@@ -1,5 +1,10 @@
import { ANALYTICS_BAKED } from "@snapotter/shared";
import { analyticsEnabled, gatePrimed, telemetryEnvKilled } from "./lib/analytics-gate.js";
import {
analyticsEnabled,
gatePrimed,
sentryDiagnostic,
telemetryEnvKilled,
} from "./lib/analytics-gate.js";
import { deployMode } from "./lib/deploy-mode.js";
import { buildBeforeSend } from "./lib/sentry-scrub.js";
import { buildTracesSampler } from "./lib/sentry-tracing.js";
@@ -9,7 +14,9 @@ import { buildTracesSampler } from "./lib/sentry-tracing.js";
// so an opted-out instance never reports even a boot-window crash.
const sentryActive = () => gatePrimed() && analyticsEnabled();
if (ANALYTICS_BAKED.sentryDsn && !telemetryEnvKilled()) {
const dsn = process.env.SNAPOTTER_SENTRY_DSN_OVERRIDE || ANALYTICS_BAKED.sentryDsn;
if (dsn && !telemetryEnvKilled()) {
try {
const Sentry = await import("@sentry/node");
const { APP_VERSION } = await import("@snapotter/shared");
@@ -31,7 +38,7 @@ if (ANALYTICS_BAKED.sentryDsn && !telemetryEnvKilled()) {
const tracingEnabled = tracesSampleRate > 0 && tracesSampleRate <= 1;
Sentry.init({
dsn: ANALYTICS_BAKED.sentryDsn,
dsn,
release,
environment: process.env.SNAPOTTER_ENV || "production",
sendDefaultPii: false,
@@ -56,7 +63,10 @@ if (ANALYTICS_BAKED.sentryDsn && !telemetryEnvKilled()) {
// Capture the breadcrumb trail (default 100). beforeSend (sentry-scrub.ts)
// sanitizes each breadcrumb before send: urls/paths redacted, data dropped.
initialScope: { tags: { deploy_mode: deployMode() } },
beforeSend: buildBeforeSend(sentryActive) as unknown as SentryOptions["beforeSend"],
beforeSend: buildBeforeSend(
sentryActive,
sentryDiagnostic(),
) as unknown as SentryOptions["beforeSend"],
});
console.log(
+4
View File
@@ -1119,6 +1119,10 @@ export function startWorkers(): void {
worker.on("failed", (job, err) => {
if (!job) return;
const pf = (err as { pythonFrames?: unknown }).pythonFrames;
if (Array.isArray(pf) && pf.length) {
logger.error({ pool, jobId: job.id, pythonFrames: pf }, "sidecar failure");
}
void reportError(err, {
source: "worker",
pool,
+6
View File
@@ -38,6 +38,12 @@ export function telemetryEnvKilled(): boolean {
return off(process.env.SNAPOTTER_TELEMETRY) || off(process.env.ANALYTICS_ENABLED);
}
/** Opt-in diagnostic verbosity for a single (usually self-hosted) instance. */
export function sentryDiagnostic(): boolean {
const v = process.env.SNAPOTTER_SENTRY_DIAGNOSTIC;
return v === "1" || v === "true" || v === "on";
}
/** Compile-time bake, with a NON-PRODUCTION-only override so tests can force it on. */
export function bakedEnabled(): boolean {
if (telemetryEnvKilled()) return false;
+14 -3
View File
@@ -19,7 +19,7 @@ import {
isSafeMessageError,
isToolInputError,
} from "@snapotter/shared";
import { analyticsEnabled } from "./analytics-gate.js";
import { analyticsEnabled, sentryDiagnostic } from "./analytics-gate.js";
export type ErrorClass = "expected" | "operational" | "bug";
@@ -74,6 +74,13 @@ export function classifyError(err: unknown, source?: ReportContext["source"]): E
if (connectivityClass(err)) return "operational";
if (isEnvironmentalDbError(err)) return "operational";
if (e?.code && OPERATIONAL_CODES.has(e.code)) return "operational";
if (
e?.name === "ReplyError" &&
typeof e.message === "string" &&
/^(OOM|READONLY|MISCONF|NOREPLICAS)\b/.test(e.message)
) {
return "operational";
}
return "bug";
}
@@ -105,7 +112,7 @@ export function errorSignature(err: unknown): string {
let frame = "-";
if (typeof e?.stack === "string") {
const line = e.stack.split("\n").find((l) => l.includes("/apps/") || l.includes("/packages/"));
const m = line?.match(/([^/\\]+\.[cm]?[jt]sx?):(\d+)/);
const m = line?.slice(0, 300).match(/([^/\\\s():]+):(\d+)/);
if (m) frame = `${m[1]}:${m[2]}`;
}
return `${name}:${code}:${frame}`;
@@ -155,7 +162,7 @@ export async function reportError(err: unknown, ctx: ReportContext): Promise<voi
if (!analyticsEnabled()) return;
const cls = classifyError(err, ctx.source);
if (cls === "expected") return;
if (!shouldReport(cls, errorSignature(err))) return;
if (!sentryDiagnostic() && !shouldReport(cls, errorSignature(err))) return;
const Sentry = await import("@sentry/node");
const net = connectivityClass(err);
@@ -189,6 +196,10 @@ export async function reportError(err: unknown, ctx: ReportContext): Promise<voi
const vetted = vetSettings(ctx.settings);
if (vetted) scope.setContext("tool", vetted);
}
const py = err as { pythonType?: string; pythonFrames?: unknown };
if (Array.isArray(py.pythonFrames) && py.pythonFrames.length) {
scope.setContext("python", { type: py.pythonType, frames: py.pythonFrames });
}
Sentry.captureException(err instanceof Error ? err : new Error(String(err)));
});
} catch {
+33 -14
View File
@@ -3,7 +3,7 @@
* event ceiling. Kept pure (factory + injected gate) so it is unit-testable
* without initializing the SDK. See the telemetry overhaul spec for the rules.
*/
import { rebuildErrorValue } from "@snapotter/shared";
import { rebuildErrorValue, redactMessage } from "@snapotter/shared";
// Per-process runaway guard, not a quota lever: under the sponsored plan we want
// real errors, but a single instance stuck in an error loop must not spam. Sentry
@@ -27,17 +27,6 @@ const TAG_ALLOWLIST = new Set([
"instance_id",
]);
const URL_RE = /https?:\/\/[^\s"')]+/g;
const BLOB_RE = /blob:[^\s"')]+/g;
// Absolute paths under roots that can hold user files (uploads live in /data,
// /tmp) or dev machines (/Users, /home). Over-redaction of a benign path is fine.
const PATH_RE = /(?:\/(?:Users|home|root|data|tmp|var|app|opt|mnt|srv)|[A-Za-z]:\\)[^\s"')]*/g;
/** Redact urls, blob refs, and absolute paths from free text (breadcrumb messages). */
function scrubText(s: string): string {
return s.replace(BLOB_RE, "<blob>").replace(URL_RE, "<url>").replace(PATH_RE, "<path>");
}
// Sentry event/hint are typed loosely on purpose: this module must not import
// @sentry/node (instrument.ts loads the SDK lazily and passes events through).
type AnyEvent = Record<string, unknown>;
@@ -60,7 +49,7 @@ function scrubBreadcrumb(entry: unknown): AnyEvent | null {
for (const k of ["type", "category", "level", "timestamp"]) {
if (b[k] !== undefined) out[k] = b[k];
}
if (typeof b.message === "string") out.message = scrubText(b.message);
if (typeof b.message === "string") out.message = redactMessage(b.message);
// For http breadcrumbs keep the non-PII status_code + method (the url is the
// sensitive part, dropped with the rest of `data`): they answer "what request
// failed right before the error".
@@ -135,7 +124,7 @@ function errorDigest(err: unknown): string {
return (h >>> 0).toString(16);
}
export function buildBeforeSend(isActive: () => boolean) {
export function buildBeforeSend(isActive: () => boolean, diagnostic = false) {
let windowStart = 0;
let sentInWindow = 0;
@@ -149,6 +138,20 @@ export function buildBeforeSend(isActive: () => boolean) {
}
if (++sentInWindow > CEILING_PER_HOUR) return null;
if (diagnostic) {
// A consenting instance: keep the raw message, request, and breadcrumb data.
// Still drop identity, and cap message length via redactMessage raw mode.
event.user = undefined;
const values = asObj(event.exception)?.values;
if (Array.isArray(values)) {
for (const entry of values) {
const ex = asObj(entry);
if (ex && typeof ex.value === "string") ex.value = redactMessage(ex.value, { raw: true });
}
}
return event;
}
// Dropped: these surfaces can carry user data or PII.
event.message = undefined;
event.logentry = undefined;
@@ -176,6 +179,22 @@ export function buildBeforeSend(isActive: () => boolean) {
}
if (Object.keys(safe).length) keep.tool = safe;
}
const py = asObj(ctx?.python);
if (py) {
const type = typeof py.type === "string" ? py.type.slice(0, 64) : undefined;
const frames = Array.isArray(py.frames)
? py.frames
.map((f) => asObj(f))
.filter((f): f is AnyEvent => !!f)
.map((f) => ({
file: typeof f.file === "string" ? f.file.slice(0, 64) : "?",
line: typeof f.line === "number" ? f.line : 0,
func: typeof f.func === "string" ? f.func.slice(0, 64) : "?",
}))
.slice(0, 20)
: [];
if (frames.length) keep.python = { type, frames };
}
event.contexts = Object.keys(keep).length ? keep : undefined;
const tags = asObj(event.tags);
+1 -1
View File
@@ -12,7 +12,7 @@ Anonymous Product Analytics is on by default and set for the whole instance by a
- pipeline_executed: step count, tool ids, batch flag, file count, duration, status.
- ai_bundle_action: bundle id, action, duration.
- Frontend usage: which tool pages open, files added (counts only), tool started, downloads, saves, search (result count only), batch processed.
- Crash reports: error type and a source stack with file basenames only.
- Crash reports: error type, a redacted error message (file names, paths, emails, and quoted values removed), a source stack with file basenames, and for AI tools a Python traceback with file basenames only.
## What we never collect {#what-we-never-collect}
+9 -77
View File
@@ -1,11 +1,12 @@
/**
* Sentry beforeSend for the web app. Same allowlist-first stance as the API
* scrubber (apps/api/src/lib/sentry-scrub.ts), adapted for browsers: native
* error messages are usually safe and highly diagnostic, so they pass through
* with url/path redaction; anything else falls back to type-only. Frame paths
* keep the host-less pathname so debug-id source maps still resolve.
* Sentry beforeSend for the web app. Mirrors the API scrubber
* (apps/api/src/lib/sentry-scrub.ts): the shared rebuildErrorValue ladder keeps
* a redacted message for any non-empty error (the rich default), so there is no
* browser-native allowlist here. Breadcrumb text goes through the same shared
* redactMessage. Frame paths keep the host-less pathname so debug-id source maps
* still resolve.
*/
import { rebuildErrorValue } from "@snapotter/shared";
import { rebuildErrorValue, redactMessage } from "@snapotter/shared";
export const IGNORE_ERRORS: (string | RegExp)[] = [
/^AbortError/,
@@ -30,71 +31,11 @@ export const DENY_URLS: RegExp[] = [
/^safari-web-extension:\/\//,
];
const NATIVE_ERRORS = new Set([
"TypeError",
"RangeError",
"SyntaxError",
"ReferenceError",
"DOMException",
// DOMExceptions report their specific name via err.name, not "DOMException",
// so the base entry alone dropped the diagnostic browser message for the
// whole family (WEB-3/4/6 arrived as "NotFoundError: NotFoundError"). This is
// the full WebIDL DOMException name table: every message is browser-authored
// and still passes through scrubText's url/path redaction.
"AbortError",
"ConstraintError",
"DataCloneError",
"DataError",
"EncodingError",
"HierarchyRequestError",
"IndexSizeError",
"InUseAttributeError",
"InvalidAccessError",
"InvalidCharacterError",
"InvalidModificationError",
"InvalidNodeTypeError",
"InvalidStateError",
"NamespaceError",
"NetworkError",
"NoModificationAllowedError",
"NotAllowedError",
"NotFoundError",
"NotReadableError",
"NotSupportedError",
"OperationError",
"QuotaExceededError",
"ReadOnlyError",
"SecurityError",
"TimeoutError",
"TransactionInactiveError",
"UnknownError",
"URLMismatchError",
"VersionError",
"WrongDocumentError",
]);
// Per-session runaway guard (Sentry de-dupes by fingerprint server-side, so 500
// distinct events/hour is ample), not a quota lever under the sponsored plan.
const CEILING_PER_HOUR = 500;
const HOUR_MS = 3600_000;
// BLOB_RE must be applied before URL_RE: "blob:http://..." would otherwise
// partially match URL_RE and leave a dangling "blob:" prefix behind.
const URL_RE = /https?:\/\/[^\s"')]+/g;
const BLOB_RE = /blob:[^\s"')]+/g;
const PATH_RE = /(?:\/Users|\/home|[A-Za-z]:\\)[^\s"')]*/g;
/** Redact urls, blob refs, and absolute paths from free text. */
function scrubText(s: string): string {
return s.replace(BLOB_RE, "<blob>").replace(URL_RE, "<url>").replace(PATH_RE, "<path>");
}
/** Redacted native-error message, or null when the name is not allowlisted. */
export function scrubBrowserMessage(name: string, message: string): string | null {
if (!NATIVE_ERRORS.has(name)) return null;
return scrubText(message);
}
const TAG_ALLOWLIST = new Set(["route", "tool_id", "locale", "error_class"]);
// Sentry event/hint are typed loosely on purpose: this module must not import
@@ -119,7 +60,7 @@ function scrubBreadcrumb(entry: unknown): AnyEvent | null {
for (const k of ["type", "category", "level", "timestamp"]) {
if (b[k] !== undefined) out[k] = b[k];
}
if (typeof b.message === "string") out.message = scrubText(b.message);
if (typeof b.message === "string") out.message = redactMessage(b.message);
// For network breadcrumbs keep the non-PII status_code + method (the url is
// dropped with the rest of `data`): "what request failed before the crash".
if (b.category === "fetch" || b.category === "xhr") {
@@ -176,16 +117,7 @@ export function buildWebBeforeSend(isActive: () => boolean) {
}
}
const orig = hint?.originalException;
let rebuilt = rebuildErrorValue(orig);
if (rebuilt === null) {
const err = asObj(orig);
const name = err?.name;
const message = err?.message;
if (typeof name === "string" && typeof message === "string") {
rebuilt = scrubBrowserMessage(name, message);
}
}
const rebuilt = rebuildErrorValue(hint?.originalException);
const values = asObj(event.exception)?.values;
if (Array.isArray(values)) {