mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user