mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: error-only Sentry telemetry, storm-proof capture, and crash fixes (#476)
Removes Sentry tracing entirely (BullMQ idle polling burned 4.8M transactions in 2 days at the baked 0.1 rate), decouples PostHog sampling, and replaces the type-only error scrub with a vetted-field sanitizer plus SafeError/ToolInputError contracts. One classified capture path with per-signature throttles and a per-process ceiling makes storms impossible (NODE-1E was 4,541 events from one 30s loop). Browser errors move to a dedicated web Sentry project with their own source maps. Adds the SNAPOTTER_TELEMETRY runtime kill switch and silences test fleets. Crash fixes: remote 204/304 SSRF process kill (NODE-20), conversion-preset boot crash loop (NODE-21), Redis version preflight + unhandled subscribe rejection (NODE-1T), Sign PDF on plain-http origins (NODE-1K/1M), wavesurfer/pdf.js teardown rejections (NODE-1P/1N), bundle-import ZlibError to 400 (NODE-1Z), chart-maker input errors declassified (NODE-1H/1J), asset requests skip the session DB lookup (NODE-1D).
This commit is contained in:
@@ -81,6 +81,12 @@ LOG_DIR=./data/logs # rotating log ring for support bundles
|
||||
# --- Analytics ---
|
||||
# Basic analytics are included by default. SnapOtter works normally without them.
|
||||
# To disable: docker compose build --build-arg SNAPOTTER_ANALYTICS=off
|
||||
# Runtime kill switch: set to 0 to disable ALL telemetry (Sentry + PostHog)
|
||||
# for this instance without rebuilding. The in-app admin toggle does the same
|
||||
# from Settings; this env var also covers boot-time crashes and CI fleets.
|
||||
# SNAPOTTER_TELEMETRY=1
|
||||
# Label this instance's error reports (shows as the Sentry environment).
|
||||
# SNAPOTTER_ENV=production
|
||||
|
||||
# One-time SQLite import on first boot (1.x upgrade path). Leave unset normally.
|
||||
# SQLITE_MIGRATE_PATH=/data/snapotter.db
|
||||
|
||||
@@ -238,6 +238,7 @@ jobs:
|
||||
SNAPOTTER_ANALYTICS=on
|
||||
SNAPOTTER_POSTHOG_PROJECT_ID=${{ secrets.SNAPOTTER_POSTHOG_KEY }}
|
||||
SNAPOTTER_SENTRY_DSN=${{ secrets.SNAPOTTER_SENTRY_DSN }}
|
||||
SNAPOTTER_SENTRY_DSN_WEB=${{ secrets.SNAPOTTER_SENTRY_DSN_WEB }}
|
||||
SENTRY_RELEASE=${{ needs.release.outputs.new_version }}
|
||||
secrets: |
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
+20
-4
@@ -5,21 +5,22 @@ import cors from "@fastify/cors";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { getDispatcherStatus, initDispatcher, isGpuAvailable } from "@snapotter/ai";
|
||||
import { APP_VERSION } from "@snapotter/shared";
|
||||
import { APP_VERSION, SafeError } from "@snapotter/shared";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import Fastify from "fastify";
|
||||
import { env } from "./config.js";
|
||||
import { closeDb, db, schema } from "./db/index.js";
|
||||
import { runMigrations } from "./db/migrate.js";
|
||||
import { startCancelListener, stopCancelListener } from "./jobs/cancel.js";
|
||||
import { closeRedis, pingRedis } from "./jobs/connection.js";
|
||||
import { assertRedisCompatible, closeRedis, pingRedis } from "./jobs/connection.js";
|
||||
import { closeFlowProducer, closeQueueEvents, warmQueueEvents } from "./jobs/enqueue.js";
|
||||
import { closeQueues, perPoolHealth, queueCounts } from "./jobs/queues.js";
|
||||
import { enqueueSystemJob, SYSTEM_JOBS, scheduleSystemJobs } from "./jobs/system-jobs.js";
|
||||
import { closeWorkers, startWorkers } from "./jobs/worker.js";
|
||||
import { captureException, initAnalytics, shutdownAnalytics } from "./lib/analytics.js";
|
||||
import { initAnalytics, shutdownAnalytics } from "./lib/analytics.js";
|
||||
import { shouldRunStartupCleanup } from "./lib/cleanup.js";
|
||||
import { buildCsp } from "./lib/csp.js";
|
||||
import { reportError } from "./lib/error-report.js";
|
||||
import { stripInternalPaths } from "./lib/errors.js";
|
||||
import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js";
|
||||
import { logger } from "./lib/logger.js";
|
||||
@@ -89,6 +90,16 @@ try {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// BullMQ v5 requires Redis >= 6.2. Fail fast with an actionable message instead
|
||||
// of crash-looping later on ReplyErrors from an incompatible server.
|
||||
try {
|
||||
await assertRedisCompatible();
|
||||
} catch (err) {
|
||||
const detected = err instanceof SafeError && err.code ? ` (detected ${err.code})` : "";
|
||||
console.error(`FATAL: ${(err as Error).message}${detected}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Redis connected");
|
||||
|
||||
// Verify the local storage directories are writable before serving. A non-root
|
||||
@@ -276,7 +287,12 @@ app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) =>
|
||||
{ err: error, url: request.url, method: request.method },
|
||||
"Unhandled request error",
|
||||
);
|
||||
captureException(error);
|
||||
void reportError(error, {
|
||||
source: "http",
|
||||
route: request.routeOptions?.url ?? undefined,
|
||||
method: request.method,
|
||||
statusCode,
|
||||
});
|
||||
} else {
|
||||
request.log.warn({ err: error, url: request.url, method: request.method }, "Request error");
|
||||
}
|
||||
|
||||
+26
-46
@@ -1,19 +1,25 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { ANALYTICS_BAKED } from "@snapotter/shared";
|
||||
import { analyticsEnabled, gatePrimed } from "./lib/analytics-gate.js";
|
||||
import { analyticsEnabled, gatePrimed, telemetryEnvKilled } from "./lib/analytics-gate.js";
|
||||
import { buildBeforeSend } from "./lib/sentry-scrub.js";
|
||||
|
||||
// Sentry inits at process load, before the gate cache is primed. Until the
|
||||
// first successful read, stay silent rather than emit on the default-ON cache,
|
||||
// so an opted-out instance never reports even a boot-window crash.
|
||||
const sentryActive = () => gatePrimed() && analyticsEnabled();
|
||||
|
||||
// Collapse any absolute path in a stack frame filename to its basename, so
|
||||
// even our own source paths never carry a workspace or job directory.
|
||||
function basename(p: string): string {
|
||||
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
// All-in-one detection: docker/entrypoint.sh exports EMBEDDED_MODE=1 before
|
||||
// exec'ing s6-overlay, and the snapotter service run script is with-contenv,
|
||||
// so the marker reaches this process. URL absence is not a usable signal:
|
||||
// embedded mode sets loopback DATABASE_URL/REDIS_URL before boot, and native
|
||||
// dev commonly leaves DATABASE_URL unset (config.ts defaults it).
|
||||
function deployMode(): string {
|
||||
if (process.env.EMBEDDED_MODE) return "embedded";
|
||||
if (existsSync("/.dockerenv")) return "external";
|
||||
return "native";
|
||||
}
|
||||
|
||||
if (ANALYTICS_BAKED.sentryDsn) {
|
||||
if (ANALYTICS_BAKED.sentryDsn && !telemetryEnvKilled()) {
|
||||
try {
|
||||
const Sentry = await import("@sentry/node");
|
||||
const { APP_VERSION } = await import("@snapotter/shared");
|
||||
@@ -21,53 +27,27 @@ if (ANALYTICS_BAKED.sentryDsn) {
|
||||
// attribute to a build; falls back to APP_VERSION for non-image runs.
|
||||
const release = process.env.SENTRY_RELEASE || APP_VERSION;
|
||||
|
||||
// buildBeforeSend is typed on loose Record shapes so sentry-scrub.ts never
|
||||
// imports @sentry/node; cast at this one boundary to the SDK callback type.
|
||||
type SentryOptions = NonNullable<Parameters<typeof Sentry.init>[0]>;
|
||||
|
||||
Sentry.init({
|
||||
dsn: ANALYTICS_BAKED.sentryDsn,
|
||||
release,
|
||||
environment: process.env.NODE_ENV || "production",
|
||||
tracesSampleRate: ANALYTICS_BAKED.sampleRate,
|
||||
environment: process.env.SNAPOTTER_ENV || "production",
|
||||
sendDefaultPii: false,
|
||||
// Release-health request-sessions and client-report envelopes are sent outside
|
||||
// beforeSend/beforeSendTransaction, so the runtime opt-out below would not stop
|
||||
// them. Disable both so an opted-out instance truly stops phoning home.
|
||||
// Errors only. No traces options are set at all, so the SDK never
|
||||
// starts traces and BullMQ/pg idle polling can't become transactions
|
||||
// again (the July 2026 quota incident).
|
||||
integrations: [Sentry.httpIntegration({ trackIncomingRequestsAsSessions: false })],
|
||||
sendClientReports: false,
|
||||
// Runtime opt-out: drop the whole transaction when analytics is off.
|
||||
tracesSampler: () => (sentryActive() ? ANALYTICS_BAKED.sampleRate : 0),
|
||||
beforeSend(event) {
|
||||
if (!sentryActive()) return null; // kill switch (covers auto-captured errors)
|
||||
// Allow-list: emit only error type + a basename-collapsed stack.
|
||||
event.message = undefined;
|
||||
event.logentry = undefined; // structured twin of message (captureMessage path)
|
||||
event.server_name = undefined; // hostname is not anonymous
|
||||
event.request = undefined;
|
||||
event.extra = undefined;
|
||||
event.contexts = undefined;
|
||||
event.breadcrumbs = undefined;
|
||||
event.user = undefined;
|
||||
if (event.exception?.values) {
|
||||
for (const ex of event.exception.values) {
|
||||
ex.value = ex.type; // never the raw message body
|
||||
if (ex.stacktrace?.frames) {
|
||||
for (const frame of ex.stacktrace.frames) {
|
||||
if (frame.filename) frame.filename = basename(frame.filename);
|
||||
frame.abs_path = undefined;
|
||||
frame.vars = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return event;
|
||||
},
|
||||
beforeBreadcrumb() {
|
||||
return null; // breadcrumbs can carry URLs/messages with content; drop them
|
||||
},
|
||||
beforeSendTransaction(event) {
|
||||
return sentryActive() ? event : null;
|
||||
},
|
||||
maxBreadcrumbs: 0,
|
||||
beforeBreadcrumb: () => null,
|
||||
initialScope: { tags: { deploy_mode: deployMode() } },
|
||||
beforeSend: buildBeforeSend(sentryActive) as unknown as SentryOptions["beforeSend"],
|
||||
});
|
||||
|
||||
console.log("[sentry] initialized, release:", release);
|
||||
console.log("[sentry] initialized (errors only), release:", release);
|
||||
} catch {
|
||||
// @sentry/node not available
|
||||
}
|
||||
|
||||
@@ -16,7 +16,25 @@ import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { getSettingString } from "../lib/settings-helpers.js";
|
||||
|
||||
/**
|
||||
* In-memory license check, no DB access. This evaluator runs every 60s on
|
||||
* every instance and previously queried settings even when unlicensed, where
|
||||
* no supported path can create alert destinations. Gated for licensing parity
|
||||
* with the SIEM job's NODE-1E fix (that job's unguarded settings read is what
|
||||
* stormed Sentry, not this one).
|
||||
*/
|
||||
async function alertsLicensed(): Promise<boolean> {
|
||||
try {
|
||||
const { isFeatureEnabled } = await import("@snapotter/enterprise");
|
||||
return isFeatureEnabled("admin_alerts");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluateAlerts(): Promise<void> {
|
||||
if (!(await alertsLicensed())) return;
|
||||
|
||||
// 1. Read webhook destinations from settings
|
||||
const destJson = await getSettingString("webhook_destinations", "[]");
|
||||
let destinations: { url: string; authHeader: string; enabled: boolean; type: string }[];
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* Uses ioredis with settings compatible with BullMQ's requirements
|
||||
* (maxRetriesPerRequest: null for blocking commands).
|
||||
*/
|
||||
|
||||
import { SafeError } from "@snapotter/shared";
|
||||
import type { ConnectionOptions } from "bullmq";
|
||||
import Redis from "ioredis";
|
||||
import { env } from "../config.js";
|
||||
@@ -64,3 +66,29 @@ export async function closeRedis(): Promise<void> {
|
||||
_shared = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure check: returns a SafeError for known-incompatible versions, else null. */
|
||||
export function checkRedisInfoCompatible(info: string): SafeError | null {
|
||||
const m = info.match(/redis_version:(\d+)\.(\d+)/);
|
||||
if (!m) return null; // managed Redis may hide INFO details; do not block boot
|
||||
const major = Number(m[1]);
|
||||
const minor = Number(m[2]);
|
||||
if (major > 6 || (major === 6 && minor >= 2)) return null;
|
||||
return new SafeError("Redis 6.2 or newer is required. Point REDIS_URL at Redis 8.", {
|
||||
kind: "operational",
|
||||
code: `redis-${major}.${minor}`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Boot preflight: BullMQ v5 needs Redis >= 6.2. Fails fast with a clear message. */
|
||||
export async function assertRedisCompatible(): Promise<void> {
|
||||
let info: string;
|
||||
try {
|
||||
info = await sharedRedis().info("server");
|
||||
} catch {
|
||||
console.warn("[redis] INFO not permitted; skipping version preflight");
|
||||
return;
|
||||
}
|
||||
const err = checkRedisInfoCompatible(info);
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,24 @@ async function readSettingValue(key: string): Promise<string | null> {
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory license check, no DB access. This job fires every 30s on every
|
||||
* instance; before this gate existed, the readSiemConfig settings read below
|
||||
* ran on unlicensed instances too and turned every DB outage into a Sentry
|
||||
* event storm (NODE-1E, July 2026).
|
||||
*/
|
||||
async function siemLicensed(): Promise<boolean> {
|
||||
try {
|
||||
const { isFeatureEnabled } = await import("@snapotter/enterprise");
|
||||
return isFeatureEnabled("siem_forwarding");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSiemForward(): Promise<{ forwarded: number } | undefined> {
|
||||
if (!(await siemLicensed())) return;
|
||||
|
||||
// 1. Read SIEM config
|
||||
const config = await readSiemConfig();
|
||||
if (!config?.enabled || !config.webhookUrl) {
|
||||
|
||||
+18
-13
@@ -25,14 +25,15 @@ import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { context, propagation, ROOT_CONTEXT, SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import { ANALYTICS_EVENTS, getBundleForTool, TOOLS } from "@snapotter/shared";
|
||||
import { ANALYTICS_EVENTS, getBundleForTool, isToolInputError, TOOLS } from "@snapotter/shared";
|
||||
import { type Job, UnrecoverableError, Worker } from "bullmq";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { captureException, trackEvent } from "../lib/analytics.js";
|
||||
import { trackEvent } from "../lib/analytics.js";
|
||||
import { analyticsEnabled } from "../lib/analytics-gate.js";
|
||||
import { resolveConcurrency } from "../lib/env.js";
|
||||
import { reportError } from "../lib/error-report.js";
|
||||
import { friendlyError } from "../lib/errors.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
import { jobDuration, jobsTotal } from "../lib/metrics.js";
|
||||
@@ -375,7 +376,8 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
||||
// friendlyError(finalError)). Expected validation rejections -- bad user
|
||||
// input, not a server fault -- would otherwise flood error logs, so skip
|
||||
// them here; they still reach the OTel span recorded below.
|
||||
const isValidationError = err instanceof Error && err.name === "InputValidationError";
|
||||
const isValidationError =
|
||||
err instanceof Error && (err.name === "InputValidationError" || isToolInputError(err));
|
||||
if (!isCanceled && !isTimeout && !isValidationError) {
|
||||
logger.error({ err, jobId, toolId: data.toolId }, "tool job failed");
|
||||
}
|
||||
@@ -383,7 +385,7 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
||||
// Record error on the OTel span
|
||||
if (span) {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: finalError });
|
||||
span.recordException(err instanceof Error ? err : new Error(String(err)));
|
||||
span.recordException(err instanceof Error ? err : String(err));
|
||||
span.addEvent("job.failed");
|
||||
}
|
||||
|
||||
@@ -447,9 +449,6 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
||||
},
|
||||
data.analyticsDistinctId,
|
||||
);
|
||||
if (!isCanceled && !isTimeout) {
|
||||
void captureException(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
if (isCanceled) throw new UnrecoverableError("Canceled");
|
||||
@@ -888,9 +887,12 @@ export function startWorkers(): void {
|
||||
});
|
||||
|
||||
worker.on("failed", (job, err) => {
|
||||
if (analyticsEnabled() && job) {
|
||||
void captureException(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
if (!job) return;
|
||||
void reportError(err, {
|
||||
source: "worker",
|
||||
pool,
|
||||
toolId: (job.data as ToolJobData | undefined)?.toolId,
|
||||
});
|
||||
});
|
||||
|
||||
workers.push(worker);
|
||||
@@ -916,9 +918,12 @@ export function startWorkers(): void {
|
||||
});
|
||||
|
||||
worker.on("failed", (job, err) => {
|
||||
if (analyticsEnabled() && job) {
|
||||
void captureException(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
if (!job) return;
|
||||
void reportError(err, {
|
||||
source: "worker",
|
||||
pool,
|
||||
toolId: (job.data as ToolJobData | undefined)?.toolId,
|
||||
});
|
||||
});
|
||||
|
||||
workers.push(worker);
|
||||
|
||||
@@ -25,8 +25,15 @@ async function defaultReader(): Promise<boolean | undefined> {
|
||||
return rows[0].value !== "false";
|
||||
}
|
||||
|
||||
/** Runtime kill switch honored in ALL builds: SNAPOTTER_TELEMETRY=0|false|off. */
|
||||
export function telemetryEnvKilled(): boolean {
|
||||
const v = process.env.SNAPOTTER_TELEMETRY;
|
||||
return v === "0" || v === "false" || v === "off";
|
||||
}
|
||||
|
||||
/** Compile-time bake, with a NON-PRODUCTION-only override so tests can force it on. */
|
||||
export function bakedEnabled(): boolean {
|
||||
if (telemetryEnvKilled()) return false;
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const o = process.env.ANALYTICS_BAKED_OVERRIDE;
|
||||
if (o === "on") return true;
|
||||
|
||||
@@ -59,13 +59,10 @@ export async function initAnalytics(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function captureException(error: unknown): Promise<void> {
|
||||
try {
|
||||
if (!analyticsEnabled()) return;
|
||||
const Sentry = await import("@sentry/node");
|
||||
Sentry.captureException(error);
|
||||
} catch {
|
||||
// analytics must never throw
|
||||
}
|
||||
// Deprecated shim: route through the classified path. New code calls
|
||||
// reportError directly with a source.
|
||||
const { reportError } = await import("./error-report.js");
|
||||
await reportError(error, { source: "boot" });
|
||||
}
|
||||
|
||||
export async function shutdownAnalytics(): Promise<void> {
|
||||
@@ -90,8 +87,13 @@ export async function trackEvent(
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!analyticsEnabled() || !posthogClient) return;
|
||||
if (ANALYTICS_BAKED.sampleRate < 1.0) {
|
||||
if (ANALYTICS_BAKED.sampleRate <= 0.0 || Math.random() >= ANALYTICS_BAKED.sampleRate) return;
|
||||
if (ANALYTICS_BAKED.posthogSampleRate < 1.0) {
|
||||
if (
|
||||
ANALYTICS_BAKED.posthogSampleRate <= 0.0 ||
|
||||
Math.random() >= ANALYTICS_BAKED.posthogSampleRate
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
posthogClient.capture({
|
||||
distinctId: distinctId ?? (await getInstanceId()),
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* The single deliberate Sentry capture path for the API.
|
||||
*
|
||||
* Classes:
|
||||
* - expected: user input / client aborts / cancels. Never sent.
|
||||
* - operational: someone's environment is broken (db down, disk full).
|
||||
* Sent once per signature per hour, level=warning, fingerprinted per class.
|
||||
* - bug: our fault. Sent up to 10 per signature per hour.
|
||||
*
|
||||
* State is per-process, so a crash-looping instance always reports its first
|
||||
* event after each restart. The beforeSend ceiling (sentry-scrub.ts) is the
|
||||
* final backstop and also covers SDK-captured uncaught exceptions.
|
||||
*/
|
||||
import {
|
||||
connectivityClass,
|
||||
isClientAbort,
|
||||
isSafeMessageError,
|
||||
isToolInputError,
|
||||
} from "@snapotter/shared";
|
||||
import { analyticsEnabled } from "./analytics-gate.js";
|
||||
|
||||
export type ErrorClass = "expected" | "operational" | "bug";
|
||||
|
||||
const HOUR_MS = 3600_000;
|
||||
const LIMITS: Record<Exclude<ErrorClass, "expected">, number> = { operational: 1, bug: 10 };
|
||||
const OPERATIONAL_CODES = new Set(["ENOSPC", "EACCES", "EROFS", "EMFILE", "ENFILE"]);
|
||||
|
||||
export interface ReportContext {
|
||||
source: "http" | "worker" | "cron" | "boot";
|
||||
toolId?: string;
|
||||
pool?: string;
|
||||
route?: string;
|
||||
method?: string;
|
||||
statusCode?: number;
|
||||
subsystem?: string;
|
||||
}
|
||||
|
||||
export function classifyError(err: unknown, source?: ReportContext["source"]): ErrorClass {
|
||||
if (isToolInputError(err)) return "expected";
|
||||
const e = err as { name?: string; message?: string; code?: string } | null;
|
||||
if (e && typeof e.message === "string" && /^(Canceled$|Timed out after )/.test(e.message)) {
|
||||
return "expected";
|
||||
}
|
||||
// The next two shortcuts only make sense at the HTTP boundary (undefined
|
||||
// keeps the http-ish default for direct calls). Off the request path a bare
|
||||
// ECONNRESET is an upstream socket loss, not a client abort, and a ZodError
|
||||
// means schema drift: settings were already validated at the boundary, so a
|
||||
// worker-side parse failure is our bug.
|
||||
if (source === "http" || source === undefined) {
|
||||
if (isClientAbort(err)) return "expected";
|
||||
// ZodError = settings validation; InputValidationError = upload validation
|
||||
// (apps/api/src/modality/contract.ts). Both are user-input problems.
|
||||
if (e?.name === "ZodError" || e?.name === "InputValidationError") return "expected";
|
||||
}
|
||||
if (isSafeMessageError(err)) return err.kind === "bug" ? "bug" : "operational";
|
||||
if (connectivityClass(err)) return "operational";
|
||||
if (e?.code && OPERATIONAL_CODES.has(e.code)) return "operational";
|
||||
return "bug";
|
||||
}
|
||||
|
||||
const seen = new Map<string, { count: number; windowStart: number }>();
|
||||
|
||||
export function shouldReport(
|
||||
cls: Exclude<ErrorClass, "expected">,
|
||||
signature: string,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const key = `${cls}:${signature}`;
|
||||
const entry = seen.get(key);
|
||||
if (!entry || now - entry.windowStart > HOUR_MS) {
|
||||
seen.set(key, { count: 1, windowStart: now });
|
||||
return true;
|
||||
}
|
||||
entry.count++;
|
||||
return entry.count <= LIMITS[cls];
|
||||
}
|
||||
|
||||
export function resetThrottleForTests(): void {
|
||||
seen.clear();
|
||||
}
|
||||
|
||||
export function errorSignature(err: unknown): string {
|
||||
const e = err as { name?: string; code?: string; stack?: string } | null;
|
||||
const name = e?.name ?? "Unknown";
|
||||
const code = e?.code ?? "-";
|
||||
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+)/);
|
||||
if (m) frame = `${m[1]}:${m[2]}`;
|
||||
}
|
||||
return `${name}:${code}:${frame}`;
|
||||
}
|
||||
|
||||
/** Fire-and-forget; never throws, never blocks. */
|
||||
export async function reportError(err: unknown, ctx: ReportContext): Promise<void> {
|
||||
try {
|
||||
if (!analyticsEnabled()) return;
|
||||
const cls = classifyError(err, ctx.source);
|
||||
if (cls === "expected") return;
|
||||
if (!shouldReport(cls, errorSignature(err))) return;
|
||||
|
||||
const Sentry = await import("@sentry/node");
|
||||
const net = connectivityClass(err);
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setLevel(cls === "operational" ? "warning" : "error");
|
||||
scope.setTag("source", ctx.source);
|
||||
scope.setTag("error_class", cls);
|
||||
const code = (err as { code?: string } | null)?.code;
|
||||
if (code) scope.setTag("error_code", code);
|
||||
if (ctx.toolId) scope.setTag("tool_id", ctx.toolId);
|
||||
if (ctx.pool) scope.setTag("pool", ctx.pool);
|
||||
if (ctx.route) scope.setTag("route", ctx.route);
|
||||
if (ctx.method) scope.setTag("method", ctx.method);
|
||||
if (ctx.statusCode) scope.setTag("status_code", String(ctx.statusCode));
|
||||
if (ctx.subsystem) scope.setTag("subsystem", ctx.subsystem);
|
||||
if (net) scope.setFingerprint(["connectivity", net]);
|
||||
Sentry.captureException(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
} catch {
|
||||
// telemetry must never throw
|
||||
}
|
||||
}
|
||||
@@ -741,6 +741,24 @@ export async function importBundleArchive(
|
||||
extractor.on("finish", () => res());
|
||||
extractor.on("error", rej);
|
||||
stream.on("error", rej);
|
||||
}).catch((err: unknown) => {
|
||||
// Malformed uploads (non-gzip data, corrupt/truncated gzip, garbage
|
||||
// tar) surface as ZlibError or tar parse errors here. Map them to a
|
||||
// 400-able validation error instead of letting them escape as a 500
|
||||
// (Sentry NODE-1Z). Fatal node-tar parse errors always carry tarCode
|
||||
// (TAR_ABORT, TAR_BAD_ARCHIVE); recoverable ones never reach "error".
|
||||
if (err instanceof ImportValidationError) throw err;
|
||||
const name = (err as Error | null)?.name ?? "";
|
||||
const msg = String((err as Error | null)?.message ?? "");
|
||||
const tarCode = (err as { tarCode?: unknown } | null)?.tarCode;
|
||||
if (
|
||||
name === "ZlibError" ||
|
||||
typeof tarCode === "string" ||
|
||||
/unexpected end of (file|data)|invalid tar|incorrect header check|zlib/i.test(msg)
|
||||
) {
|
||||
throw new ImportValidationError("Not a valid bundle archive");
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Read and validate bundle.json
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mkdir, readFile, statfs, unlink, writeFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
import type { S3StorageModule } from "@snapotter/enterprise";
|
||||
import { SafeError } from "@snapotter/shared";
|
||||
import { env } from "../config.js";
|
||||
|
||||
const MIN_FREE_BYTES = 100 * 1024 * 1024;
|
||||
@@ -13,9 +14,12 @@ async function assertDiskSpace(dir: string): Promise<void> {
|
||||
const stats = await statfs(dir);
|
||||
const freeBytes = stats.bfree * stats.bsize;
|
||||
if (freeBytes < MIN_FREE_BYTES) {
|
||||
const err = new Error("Insufficient disk space") as Error & { statusCode: number };
|
||||
err.statusCode = 507;
|
||||
throw err;
|
||||
// ENOSPC here is synthesized from the free-space floor check, not a syscall errno.
|
||||
throw new SafeError("Insufficient disk space", {
|
||||
kind: "operational",
|
||||
code: "ENOSPC",
|
||||
statusCode: 507,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && (e as Error & { statusCode?: number }).statusCode === 507) throw e;
|
||||
@@ -102,11 +106,11 @@ export async function ensureStorageDir(): Promise<void> {
|
||||
await mkdir(env.FILES_STORAGE_PATH, { recursive: true });
|
||||
} catch (e) {
|
||||
if (e instanceof Error && (e as NodeJS.ErrnoException).code === "EACCES") {
|
||||
const err = new Error("Storage directory is not writable") as Error & {
|
||||
statusCode: number;
|
||||
};
|
||||
err.statusCode = 503;
|
||||
throw err;
|
||||
throw new SafeError("Storage directory is not writable", {
|
||||
kind: "operational",
|
||||
code: (e as NodeJS.ErrnoException).code,
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
@@ -126,11 +130,11 @@ export async function saveFile(buffer: Buffer, originalName: string): Promise<st
|
||||
await writeFile(join(env.FILES_STORAGE_PATH, storedName), buffer);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && (e as NodeJS.ErrnoException).code === "EACCES") {
|
||||
const err = new Error("Storage directory is not writable") as Error & {
|
||||
statusCode: number;
|
||||
};
|
||||
err.statusCode = 503;
|
||||
throw err;
|
||||
throw new SafeError("Storage directory is not writable", {
|
||||
kind: "operational",
|
||||
code: (e as NodeJS.ErrnoException).code,
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
@@ -185,7 +189,11 @@ async function ensureThumbDir(): Promise<void> {
|
||||
await mkdir(join(env.FILES_STORAGE_PATH, THUMB_DIR), { recursive: true });
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EACCES") {
|
||||
throw Object.assign(new Error("Storage directory is not writable"), { statusCode: 503 });
|
||||
throw new SafeError("Storage directory is not writable", {
|
||||
kind: "operational",
|
||||
code: (err as NodeJS.ErrnoException).code,
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Sentry beforeSend for the API: allowlist-first scrubbing plus a per-process
|
||||
* 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";
|
||||
|
||||
const CEILING_PER_HOUR = 20;
|
||||
const HOUR_MS = 3600_000;
|
||||
|
||||
const TAG_ALLOWLIST = new Set([
|
||||
"source",
|
||||
"tool_id",
|
||||
"pool",
|
||||
"route",
|
||||
"method",
|
||||
"error_class",
|
||||
"error_code",
|
||||
"deploy_mode",
|
||||
"subsystem",
|
||||
"status_code",
|
||||
]);
|
||||
|
||||
function basename(p: string): string {
|
||||
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
|
||||
// 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>;
|
||||
type AnyHint = { originalException?: unknown };
|
||||
|
||||
/** Narrow to a plain mutable object, or null for anything else (fail-closed). */
|
||||
function asObj(value: unknown): AnyEvent | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as AnyEvent)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function buildBeforeSend(isActive: () => boolean) {
|
||||
let windowStart = 0;
|
||||
let sentInWindow = 0;
|
||||
|
||||
return function beforeSend(event: AnyEvent, hint: AnyHint): AnyEvent | null {
|
||||
if (!isActive()) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - windowStart > HOUR_MS) {
|
||||
windowStart = now;
|
||||
sentInWindow = 0;
|
||||
}
|
||||
if (++sentInWindow > CEILING_PER_HOUR) return null;
|
||||
|
||||
event.message = undefined;
|
||||
event.logentry = undefined;
|
||||
event.server_name = undefined;
|
||||
event.request = undefined;
|
||||
event.extra = undefined;
|
||||
event.breadcrumbs = undefined;
|
||||
event.user = undefined;
|
||||
|
||||
const ctx = asObj(event.contexts);
|
||||
const keep: AnyEvent = {};
|
||||
const os = asObj(ctx?.os);
|
||||
if (os?.name) keep.os = { name: os.name, version: os.version };
|
||||
const runtime = asObj(ctx?.runtime);
|
||||
if (runtime?.name) keep.runtime = { name: runtime.name, version: runtime.version };
|
||||
event.contexts = Object.keys(keep).length ? keep : undefined;
|
||||
|
||||
const tags = asObj(event.tags);
|
||||
if (tags) {
|
||||
for (const key of Object.keys(tags)) {
|
||||
if (!TAG_ALLOWLIST.has(key)) delete tags[key];
|
||||
}
|
||||
}
|
||||
|
||||
const rebuilt = rebuildErrorValue(hint?.originalException);
|
||||
const values = asObj(event.exception)?.values;
|
||||
if (Array.isArray(values)) {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const ex = asObj(values[i]);
|
||||
if (!ex) continue;
|
||||
// The last entry is the original error; linked/outer wrappers get type-only.
|
||||
ex.value = i === values.length - 1 && rebuilt ? rebuilt : ex.type;
|
||||
const frames = asObj(ex.stacktrace)?.frames;
|
||||
if (Array.isArray(frames)) {
|
||||
for (const entry of frames) {
|
||||
const frame = asObj(entry);
|
||||
if (!frame) continue;
|
||||
if (typeof frame.filename === "string") frame.filename = basename(frame.filename);
|
||||
frame.abs_path = undefined;
|
||||
frame.vars = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return event;
|
||||
};
|
||||
}
|
||||
@@ -172,6 +172,29 @@ function normalizeSafeFetchOptions(options?: AbortSignal | SafeFetchOptions): Sa
|
||||
return options;
|
||||
}
|
||||
|
||||
// Statuses that undici's Response constructor rejects a body for. An empty
|
||||
// Buffer still counts as a body, so these must pass null explicitly. A remote
|
||||
// server controls this status; before this guard, a 204/304 reply crashed the
|
||||
// whole process from inside the 'end' event handler (Sentry NODE-20).
|
||||
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
||||
|
||||
export function toFetchResponse(
|
||||
// ArrayBuffer-backed (what Buffer.concat/from/alloc return); the Response
|
||||
// constructor's BodyInit does not accept SharedArrayBuffer-backed views.
|
||||
body: Buffer<ArrayBuffer>,
|
||||
statusCode: number | undefined,
|
||||
statusText: string,
|
||||
headers: Headers,
|
||||
): Response {
|
||||
const status =
|
||||
statusCode !== undefined && statusCode >= 200 && statusCode <= 599 ? statusCode : 502;
|
||||
return new Response(NULL_BODY_STATUSES.has(status) ? null : body, {
|
||||
status,
|
||||
statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function withResponseSizeLimit(response: Response, maxBytes?: number): Response {
|
||||
if (maxBytes === undefined || !response.body) return response;
|
||||
|
||||
@@ -275,13 +298,18 @@ export async function safeFetch(
|
||||
for (const v of vals) headers.append(key, v);
|
||||
}
|
||||
}
|
||||
resolve(
|
||||
new Response(body, {
|
||||
status: incomingMessage.statusCode ?? 500,
|
||||
statusText: incomingMessage.statusMessage ?? "",
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
resolve(
|
||||
toFetchResponse(
|
||||
body,
|
||||
incomingMessage.statusCode,
|
||||
incomingMessage.statusMessage ?? "",
|
||||
headers,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
incomingMessage.on("error", (err) => {
|
||||
if (settled) return;
|
||||
|
||||
@@ -6414,7 +6414,9 @@ paths:
|
||||
type: string
|
||||
sentryDsn:
|
||||
type: string
|
||||
sampleRate:
|
||||
sentryDsnWeb:
|
||||
type: string
|
||||
posthogSampleRate:
|
||||
type: number
|
||||
instanceId:
|
||||
type: string
|
||||
|
||||
@@ -1100,8 +1100,19 @@ function isPublicRoute(url: string): boolean {
|
||||
return PUBLIC_PATHS.some((path) => url.startsWith(path));
|
||||
}
|
||||
|
||||
/** SPA bundle assets are public by definition (the login page needs them). */
|
||||
export function isStaticAssetRequest(method: string, url: string): boolean {
|
||||
return (
|
||||
(method === "GET" || method === "HEAD") && url.startsWith("/assets/") && !url.includes("..")
|
||||
);
|
||||
}
|
||||
|
||||
export async function authMiddleware(app: FastifyInstance): Promise<void> {
|
||||
app.addHook("preHandler", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
// Skip the session DB lookup for bundle assets: they are served on every
|
||||
// page load and an unreachable DB must not 500 them (Sentry NODE-1D).
|
||||
if (isStaticAssetRequest(request.method, request.url)) return;
|
||||
|
||||
if (!env.AUTH_ENABLED) {
|
||||
(request as FastifyRequest & { user?: AuthUser }).user = {
|
||||
id: "anonymous",
|
||||
|
||||
@@ -13,7 +13,8 @@ export async function analyticsRoutes(app: FastifyInstance): Promise<void> {
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
sentryDsnWeb: "",
|
||||
posthogSampleRate: 0,
|
||||
instanceId: "",
|
||||
};
|
||||
}
|
||||
@@ -28,7 +29,8 @@ export async function analyticsRoutes(app: FastifyInstance): Promise<void> {
|
||||
posthogApiKey: ANALYTICS_BAKED.posthogApiKey,
|
||||
posthogHost: ANALYTICS_BAKED.posthogHost,
|
||||
sentryDsn: ANALYTICS_BAKED.sentryDsn,
|
||||
sampleRate: ANALYTICS_BAKED.sampleRate,
|
||||
sentryDsnWeb: ANALYTICS_BAKED.sentryDsnWeb,
|
||||
posthogSampleRate: ANALYTICS_BAKED.posthogSampleRate,
|
||||
instanceId: row?.value ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -238,7 +238,9 @@ function ensureSubscriber(): void {
|
||||
sseSubscriber.on("error", (err) => {
|
||||
console.error("SSE progress subscriber error", err);
|
||||
});
|
||||
void sseSubscriber.subscribe(progressChannel());
|
||||
void sseSubscriber.subscribe(progressChannel()).catch((err) => {
|
||||
console.error("SSE progress subscribe failed", err);
|
||||
});
|
||||
sseSubscriber.on("message", (_channel: string, message: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(message) as { jobId?: string };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ToolInputError } from "@snapotter/shared";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import Papa from "papaparse";
|
||||
import sharp from "sharp";
|
||||
@@ -207,26 +208,26 @@ export function registerChartMaker(app: FastifyInstance) {
|
||||
try {
|
||||
data = parseInput(input.buffer, input.filename);
|
||||
} catch (err) {
|
||||
throw new Error(err instanceof Error ? err.message : "Failed to parse input");
|
||||
throw new ToolInputError(err instanceof Error ? err.message : "Failed to parse input");
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new Error("No data points found in input");
|
||||
throw new ToolInputError("No data points found in input");
|
||||
}
|
||||
if (data.length > 100) {
|
||||
throw new Error("Too many data points (max 100)");
|
||||
throw new ToolInputError("Too many data points (max 100)");
|
||||
}
|
||||
|
||||
// Validate numeric values
|
||||
for (const point of data) {
|
||||
if (Number.isNaN(point.value)) {
|
||||
throw new Error("Column 2 must be numeric");
|
||||
throw new ToolInputError("Column 2 must be numeric");
|
||||
}
|
||||
}
|
||||
// Negative values render as invalid/degenerate SVG (negative bar heights,
|
||||
// backward pie arcs that Sharp silently drops); reject with a clear message.
|
||||
if (data.some((point) => point.value < 0)) {
|
||||
throw new Error("Chart values must be zero or greater");
|
||||
throw new ToolInputError("Chart values must be zero or greater");
|
||||
}
|
||||
|
||||
let svg: string;
|
||||
|
||||
@@ -35,6 +35,7 @@ function presetSchema(base: string) {
|
||||
* own parameterized registrars.
|
||||
*/
|
||||
export function registerConversionPresets(app: FastifyInstance): number {
|
||||
let skipped = 0;
|
||||
for (const preset of CONVERSION_PRESETS) {
|
||||
const cfg = BASE_CONFIG[preset.base];
|
||||
if (cfg.group === "image-to-pdf") {
|
||||
@@ -53,9 +54,16 @@ export function registerConversionPresets(app: FastifyInstance): number {
|
||||
// group "registry": delegate to the base tool's processV2 with locked settings merged in.
|
||||
const baseConfig = getToolConfig(preset.base);
|
||||
if (!baseConfig) {
|
||||
throw new Error(
|
||||
`Preset "${preset.id}" base "${preset.base}" is not registered in the tool registry`,
|
||||
// A missing base must degrade to a disabled preset, not a boot crash:
|
||||
// one bad environment crash-looped an instance 287 times (Sentry
|
||||
// NODE-21). CI still fails hard via the tool-route drift test, so the
|
||||
// official image can't ship with a hole.
|
||||
app.log.error(
|
||||
{ presetId: preset.id, base: preset.base },
|
||||
"conversion preset base tool missing; preset disabled",
|
||||
);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
createToolRoute(app, {
|
||||
toolId: preset.id,
|
||||
@@ -73,5 +81,5 @@ export function registerConversionPresets(app: FastifyInstance): number {
|
||||
});
|
||||
}
|
||||
|
||||
return CONVERSION_PRESETS.length;
|
||||
return CONVERSION_PRESETS.length - skipped;
|
||||
}
|
||||
|
||||
@@ -920,7 +920,8 @@ export function matchDemoRoute(url: string, method: string, body?: unknown): Res
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
sentryDsnWeb: "",
|
||||
posthogSampleRate: 0,
|
||||
instanceId: "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ You will be asked to change your password on first login.
|
||||
::: tip Anonymous Product Analytics
|
||||
SnapOtter includes anonymous product analytics by default. To turn it off, open **Settings → System → Privacy** and switch off **Anonymous Product Analytics**. It stops immediately for the whole instance.
|
||||
|
||||
You can also set the environment variable `SNAPOTTER_TELEMETRY=0` (`false` and `off` work too) to disable all telemetry for the instance without a rebuild.
|
||||
|
||||
Error monitoring is powered by [Sentry](https://sentry.io), which sponsors SnapOtter through its open-source program.
|
||||
|
||||
For details about what is collected, see [What SnapOtter collects](/guide/telemetry).
|
||||
:::
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class ErrorBoundary extends Component<
|
||||
track(ANALYTICS_EVENTS.TOOL_CLIENT_ERROR, { error_name: error.name });
|
||||
import("@sentry/react")
|
||||
.then((Sentry) => {
|
||||
Sentry.captureException(error);
|
||||
Sentry.captureReactException(error, info);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@ export function WaveformPlayer({ src, className }: WaveformPlayerProps) {
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.load(src);
|
||||
// load() rejects with AbortError when destroy() runs mid-decode (route
|
||||
// change / src swap). That rejection is expected teardown, not an error.
|
||||
ws.load(src).catch(() => {});
|
||||
|
||||
ws.on("ready", () => {
|
||||
readyRef.current = true;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "re
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { toNormalizedRect } from "@/lib/sign-geometry";
|
||||
import type { SavedSignature } from "@/lib/signature-store";
|
||||
import { safeRandomUUID } from "@/lib/uuid";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
@@ -90,7 +91,11 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
|
||||
setPageCount(doc.numPages);
|
||||
setPage(0);
|
||||
setDocReady(true);
|
||||
})();
|
||||
})().catch(() => {
|
||||
// loadingTask.destroy() in the cleanup rejects the in-flight promise;
|
||||
// swallowing it is the fix for Sentry NODE-1N. A genuine load failure
|
||||
// leaves docReady false, which the existing UI already handles.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
docRef.current?.loadingTask.destroy();
|
||||
@@ -181,7 +186,10 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
|
||||
}
|
||||
layer.batchDraw();
|
||||
onSelectionChange?.(false);
|
||||
})();
|
||||
})().catch(() => {
|
||||
// getPage()/render() reject when the loadingTask is destroyed mid-render
|
||||
// (file swap or unmount); expected teardown, same as the load effect.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -241,7 +249,7 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
|
||||
layer.add(node);
|
||||
tr.nodes([node]);
|
||||
layer.batchDraw();
|
||||
placementsRef.current.push({ id: crypto.randomUUID(), page, node });
|
||||
placementsRef.current.push({ id: safeRandomUUID(), page, node });
|
||||
onSelectionChange?.(true);
|
||||
emitCount();
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type SavedSignature,
|
||||
} from "@/lib/signature-store";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { safeRandomUUID } from "@/lib/uuid";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import type { SignCanvasRef } from "./sign-canvas";
|
||||
import { SignaturePad } from "./signature-pad";
|
||||
@@ -126,7 +127,7 @@ export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
|
||||
const handleSavePad = (dataUrl: string, remember: boolean) => {
|
||||
const sig: SavedSignature = remember
|
||||
? addSignature(dataUrl)
|
||||
: { id: crypto.randomUUID(), dataUrl, createdAt: Date.now() };
|
||||
: { id: safeRandomUUID(), dataUrl, createdAt: Date.now() };
|
||||
if (remember) refresh();
|
||||
signProps?.canvasRef.current?.addSignature(sig);
|
||||
setPadOpen(false);
|
||||
|
||||
@@ -6,21 +6,6 @@ let posthog: PostHogInstance | null = null;
|
||||
let initialized = false;
|
||||
let enabled = false; // live runtime flag; gates track() and ErrorBoundary capture
|
||||
|
||||
function basename(p: string): string {
|
||||
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
|
||||
// App bundle frames are http(s) URLs. Keep the host-less pathname so Sentry can
|
||||
// match the frame to its uploaded source map, but drop the instance hostname
|
||||
// (not anonymous). Filesystem-style paths collapse to the basename so a local
|
||||
// path or username can never leave the browser.
|
||||
function scrubFramePath(p: string): string {
|
||||
const url = p.match(/^https?:\/\/[^/]+(\/[^?#]*)?/i);
|
||||
if (url) return url[1] ?? "/";
|
||||
return basename(p);
|
||||
}
|
||||
|
||||
// Only these keys may leave the browser per event, and only as primitives.
|
||||
const ALLOWED: Record<string, ReadonlySet<string>> = {
|
||||
tool_opened: new Set(["tool_id", "category", "modality"]),
|
||||
@@ -48,22 +33,30 @@ function sanitize(event: string, properties?: Record<string, unknown>): Record<s
|
||||
export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
if (initialized || !config.enabled) return;
|
||||
|
||||
try {
|
||||
const posthogJs = (await import("posthog-js")).default;
|
||||
posthog =
|
||||
posthogJs.init(config.posthogApiKey, {
|
||||
api_host: config.posthogHost,
|
||||
autocapture: false,
|
||||
capture_pageview: true,
|
||||
disable_session_recording: true,
|
||||
ip: false,
|
||||
persistence: "localStorage",
|
||||
person_profiles: "identified_only",
|
||||
}) ?? null;
|
||||
if (!config.posthogApiKey) {
|
||||
// Web-DSN-only bake: no PostHog key, so skip the PostHog SDK entirely
|
||||
// (mirrors the API guard) instead of feeding it an empty key. Still mark
|
||||
// the module live so the Sentry beforeSend gate below stays active.
|
||||
initialized = true;
|
||||
enabled = true;
|
||||
} catch (err) {
|
||||
console.warn("[analytics] PostHog init failed:", err);
|
||||
} else {
|
||||
try {
|
||||
const posthogJs = (await import("posthog-js")).default;
|
||||
posthog =
|
||||
posthogJs.init(config.posthogApiKey, {
|
||||
api_host: config.posthogHost,
|
||||
autocapture: false,
|
||||
capture_pageview: true,
|
||||
disable_session_recording: true,
|
||||
ip: false,
|
||||
persistence: "localStorage",
|
||||
person_profiles: "identified_only",
|
||||
}) ?? null;
|
||||
initialized = true;
|
||||
enabled = true;
|
||||
} catch (err) {
|
||||
console.warn("[analytics] PostHog init failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (posthog) {
|
||||
@@ -80,51 +73,28 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.sentryDsn) {
|
||||
if (config.sentryDsnWeb) {
|
||||
const Sentry = await import("@sentry/react");
|
||||
const { buildWebBeforeSend, DENY_URLS, IGNORE_ERRORS } = await import("@/lib/sentry-scrub");
|
||||
// buildWebBeforeSend is typed on loose Record shapes so sentry-scrub.ts
|
||||
// never imports @sentry/react (this module loads the SDK lazily); cast
|
||||
// at this one boundary to the SDK callback type.
|
||||
type SentryOptions = NonNullable<Parameters<typeof Sentry.init>[0]>;
|
||||
Sentry.init({
|
||||
dsn: config.sentryDsn,
|
||||
dsn: config.sentryDsnWeb,
|
||||
release:
|
||||
import.meta.env.VITE_SENTRY_RELEASE || (await import("@snapotter/shared")).APP_VERSION,
|
||||
environment: "production",
|
||||
tracesSampleRate: config.sampleRate,
|
||||
sendDefaultPii: false,
|
||||
integrations: [Sentry.browserTracingIntegration()],
|
||||
beforeSend(event) {
|
||||
if (!enabled) return null;
|
||||
event.message = undefined;
|
||||
event.logentry = undefined;
|
||||
event.request = undefined;
|
||||
event.extra = undefined;
|
||||
event.contexts = undefined;
|
||||
event.breadcrumbs = undefined;
|
||||
event.user = undefined;
|
||||
if (event.exception?.values) {
|
||||
for (const ex of event.exception.values) {
|
||||
ex.value = ex.type;
|
||||
if (ex.stacktrace?.frames) {
|
||||
for (const frame of ex.stacktrace.frames) {
|
||||
if (frame.filename) frame.filename = scrubFramePath(frame.filename);
|
||||
if (frame.abs_path) frame.abs_path = scrubFramePath(frame.abs_path);
|
||||
frame.vars = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep debug_meta image paths consistent with the scrubbed frames so
|
||||
// debug-id source-map matching still resolves, minus the hostname.
|
||||
if (event.debug_meta?.images) {
|
||||
for (const img of event.debug_meta.images) {
|
||||
if ("code_file" in img && img.code_file) {
|
||||
img.code_file = scrubFramePath(img.code_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return event;
|
||||
},
|
||||
beforeBreadcrumb() {
|
||||
return null;
|
||||
},
|
||||
sendClientReports: false,
|
||||
// Errors only: no tracing options, and release-health sessions are
|
||||
// dropped by removing the session integration below.
|
||||
integrations: (defaults) => defaults.filter((i) => i.name !== "BrowserSession"),
|
||||
ignoreErrors: IGNORE_ERRORS,
|
||||
denyUrls: DENY_URLS,
|
||||
maxBreadcrumbs: 0,
|
||||
beforeBreadcrumb: () => null,
|
||||
beforeSend: buildWebBeforeSend(() => enabled) as unknown as SentryOptions["beforeSend"],
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { rebuildErrorValue } from "@snapotter/shared";
|
||||
|
||||
export const IGNORE_ERRORS: (string | RegExp)[] = [
|
||||
/^AbortError/,
|
||||
"Failed to fetch",
|
||||
"NetworkError when attempting to fetch resource.",
|
||||
"Load failed",
|
||||
/^ResizeObserver loop/,
|
||||
"The operation was aborted.",
|
||||
];
|
||||
|
||||
export const DENY_URLS: RegExp[] = [
|
||||
/^chrome-extension:\/\//,
|
||||
/^moz-extension:\/\//,
|
||||
/^safari-web-extension:\/\//,
|
||||
];
|
||||
|
||||
const NATIVE_ERRORS = new Set([
|
||||
"TypeError",
|
||||
"RangeError",
|
||||
"SyntaxError",
|
||||
"ReferenceError",
|
||||
"DOMException",
|
||||
"SecurityError",
|
||||
"NotSupportedError",
|
||||
"QuotaExceededError",
|
||||
"AbortError",
|
||||
]);
|
||||
|
||||
const CEILING_PER_HOUR = 20;
|
||||
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;
|
||||
|
||||
/** 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 message.replace(BLOB_RE, "<blob>").replace(URL_RE, "<url>").replace(PATH_RE, "<path>");
|
||||
}
|
||||
|
||||
const TAG_ALLOWLIST = new Set(["route", "tool_id", "locale", "error_class"]);
|
||||
|
||||
// Sentry event/hint are typed loosely on purpose: this module must not import
|
||||
// @sentry/react (analytics.ts loads the SDK lazily and passes events through).
|
||||
type AnyEvent = Record<string, unknown>;
|
||||
type AnyHint = { originalException?: unknown };
|
||||
|
||||
/** Narrow to a plain mutable object, or null for anything else (fail-closed). */
|
||||
function asObj(value: unknown): AnyEvent | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as AnyEvent)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function buildWebBeforeSend(isActive: () => boolean) {
|
||||
let windowStart = 0;
|
||||
let sentInWindow = 0;
|
||||
|
||||
return function beforeSend(event: AnyEvent, hint: AnyHint): AnyEvent | null {
|
||||
if (!isActive()) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - windowStart > HOUR_MS) {
|
||||
windowStart = now;
|
||||
sentInWindow = 0;
|
||||
}
|
||||
if (++sentInWindow > CEILING_PER_HOUR) return null;
|
||||
|
||||
event.message = undefined;
|
||||
event.logentry = undefined;
|
||||
event.request = undefined;
|
||||
event.extra = undefined;
|
||||
event.breadcrumbs = undefined;
|
||||
event.user = undefined;
|
||||
|
||||
// React's componentStack is component names only; every other context goes.
|
||||
const react = asObj(event.contexts)?.react;
|
||||
event.contexts = react ? { react } : undefined;
|
||||
|
||||
const tags = asObj(event.tags);
|
||||
if (tags) {
|
||||
for (const key of Object.keys(tags)) {
|
||||
if (!TAG_ALLOWLIST.has(key)) delete tags[key];
|
||||
}
|
||||
}
|
||||
|
||||
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 values = asObj(event.exception)?.values;
|
||||
if (Array.isArray(values)) {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const ex = asObj(values[i]);
|
||||
if (!ex) continue;
|
||||
// The last entry is the original error; linked/outer wrappers get type-only.
|
||||
ex.value = i === values.length - 1 && rebuilt ? rebuilt : ex.type;
|
||||
const frames = asObj(ex.stacktrace)?.frames;
|
||||
if (Array.isArray(frames)) {
|
||||
for (const entry of frames) {
|
||||
const frame = asObj(entry);
|
||||
if (!frame) continue;
|
||||
if (frame.filename) frame.filename = scrubFramePath(String(frame.filename));
|
||||
if (frame.abs_path) frame.abs_path = scrubFramePath(String(frame.abs_path));
|
||||
frame.vars = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep debug_meta image paths consistent with the scrubbed frames so
|
||||
// debug-id source-map matching still resolves, minus the hostname.
|
||||
const images = asObj(event.debug_meta)?.images;
|
||||
if (Array.isArray(images)) {
|
||||
for (const entry of images) {
|
||||
const img = asObj(entry);
|
||||
if (img?.code_file) img.code_file = scrubFramePath(String(img.code_file));
|
||||
}
|
||||
}
|
||||
return event;
|
||||
};
|
||||
}
|
||||
|
||||
// App bundle frames are http(s) URLs: keep the host-less pathname so Sentry can
|
||||
// match the frame to its uploaded source map, but drop the instance hostname
|
||||
// (not anonymous). Filesystem-style paths collapse to the basename so a local
|
||||
// path or username can never leave the browser.
|
||||
function scrubFramePath(p: string): string {
|
||||
const url = p.match(/^https?:\/\/[^/]+(\/[^?#]*)?/i);
|
||||
if (url) return url[1] ?? "/";
|
||||
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { safeRandomUUID } from "@/lib/uuid";
|
||||
|
||||
export interface SavedSignature {
|
||||
id: string;
|
||||
dataUrl: string;
|
||||
@@ -31,7 +33,7 @@ export function listSignatures(): SavedSignature[] {
|
||||
|
||||
export function addSignature(dataUrl: string): SavedSignature {
|
||||
const sig: SavedSignature = {
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
dataUrl,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { safeRandomUUID } from "@/lib/uuid";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
return safeRandomUUID();
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* crypto.randomUUID only exists in secure contexts, and self-hosted SnapOtter
|
||||
* is very often reached over plain http on a LAN, where calling it throws
|
||||
* (Sentry NODE-1K/1M crashed Sign PDF exactly this way). getRandomValues works
|
||||
* everywhere, so fall back to assembling a v4 UUID from it.
|
||||
*/
|
||||
export function safeRandomUUID(): string {
|
||||
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
@@ -18,7 +18,8 @@ export default defineConfig({
|
||||
// Must come after the other plugins so it sees the final bundle.
|
||||
sentryVitePlugin({
|
||||
org: "snapotter",
|
||||
project: "node",
|
||||
// Web source maps upload to the browser project; api events live on "node".
|
||||
project: "web",
|
||||
authToken: sentryAuthToken,
|
||||
telemetry: false,
|
||||
disable: !sentryAuthToken,
|
||||
|
||||
@@ -68,9 +68,11 @@ COPY apps/web/public ./apps/web/public
|
||||
ARG SNAPOTTER_ANALYTICS=on
|
||||
ARG SNAPOTTER_POSTHOG_PROJECT_ID=
|
||||
ARG SNAPOTTER_SENTRY_DSN=
|
||||
ARG SNAPOTTER_SENTRY_DSN_WEB=
|
||||
COPY scripts/bake-analytics.mjs ./scripts/
|
||||
RUN SNAPOTTER_POSTHOG_PROJECT_ID="${SNAPOTTER_POSTHOG_PROJECT_ID}" \
|
||||
SNAPOTTER_SENTRY_DSN="${SNAPOTTER_SENTRY_DSN}" \
|
||||
SNAPOTTER_SENTRY_DSN_WEB="${SNAPOTTER_SENTRY_DSN_WEB}" \
|
||||
node scripts/bake-analytics.mjs ${SNAPOTTER_ANALYTICS}
|
||||
|
||||
# Build only the web frontend (API runs from TS source via tsx). When a
|
||||
|
||||
+25
-12
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { context, propagation, SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import { SafeError } from "@snapotter/shared";
|
||||
import { missingBundleForScript } from "./feature-gate.js";
|
||||
import { acquireVenvRead, tryAcquireVenvRead } from "./venv-lock.js";
|
||||
|
||||
@@ -327,17 +328,24 @@ export class PythonDispatcher {
|
||||
if (req) {
|
||||
this.pending.delete(reqId);
|
||||
if (response.exitCode !== 0) {
|
||||
const errText =
|
||||
extractPythonError({
|
||||
stdout: response.stdout,
|
||||
stderr: req.stderrLines.join("\n"),
|
||||
}) ||
|
||||
(response.exitCode === 137
|
||||
? "Process killed (out of memory) -- try a lighter model or smaller image"
|
||||
: response.exitCode === 139
|
||||
? "Process crashed (segmentation fault)"
|
||||
: `Python script exited with code ${response.exitCode}`);
|
||||
req.reject(new Error(errText));
|
||||
const extracted = extractPythonError({
|
||||
stdout: response.stdout,
|
||||
stderr: req.stderrLines.join("\n"),
|
||||
});
|
||||
if (!extracted && (response.exitCode === 137 || response.exitCode === 139)) {
|
||||
req.reject(
|
||||
new SafeError(
|
||||
response.exitCode === 137
|
||||
? "Process killed (out of memory) -- try a lighter model or smaller image"
|
||||
: "Process crashed (segmentation fault)",
|
||||
{ kind: "operational", code: `exit-${response.exitCode}` },
|
||||
),
|
||||
);
|
||||
} else {
|
||||
req.reject(
|
||||
new Error(extracted || `Python script exited with code ${response.exitCode}`),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
req.resolve({
|
||||
stdout: response.stdout || "",
|
||||
@@ -564,8 +572,13 @@ export class PythonDispatcher {
|
||||
: signal === "SIGSEGV" || code === 139
|
||||
? "Process crashed (segmentation fault)"
|
||||
: null;
|
||||
if (signalMsg) {
|
||||
rejectPromise(
|
||||
new SafeError(signalMsg, { kind: "operational", code: `exit-${code ?? signal}` }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const errorText =
|
||||
signalMsg ||
|
||||
extractPythonError({ stdout: stdout.trim(), stderr }) ||
|
||||
`Python script exited with code ${code}`;
|
||||
rejectPromise(new Error(errorText));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { markToolInputError } from "@snapotter/shared";
|
||||
import { resolveFfmpeg } from "./binaries.js";
|
||||
import { type FfmpegProgress, parseProgressBlock } from "./progress.js";
|
||||
|
||||
@@ -10,6 +11,23 @@ export interface RunFfmpegOptions {
|
||||
|
||||
const STDERR_RING_MAX = 16 * 1024;
|
||||
|
||||
// stderr shapes that mean "the input media is unusable", not an ffmpeg bug.
|
||||
// Marked errors are classified expected upstream and never reach Sentry.
|
||||
const INPUT_ERROR_PATTERNS = [
|
||||
/received no packets/i,
|
||||
/invalid data found when processing input/i,
|
||||
/could not find codec parameters/i,
|
||||
/moov atom not found/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Applies the isToolInputError marker (see @snapotter/shared tool-errors.ts)
|
||||
* when the captured stderr matches a known input-failure shape.
|
||||
*/
|
||||
export function markIfInputError<E extends Error>(err: E, stderr: string): E {
|
||||
return INPUT_ERROR_PATTERNS.some((re) => re.test(stderr)) ? markToolInputError(err) : err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs ffmpeg with `-progress pipe:1` appended, parsing progress blocks from
|
||||
* stdout. Rejects with the tail of stderr on non-zero exit, timeout or abort.
|
||||
@@ -76,7 +94,10 @@ export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Pr
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (code === 0) resolvePromise(stderrTail);
|
||||
else reject(new Error(`ffmpeg exited ${code ?? signal}: ${stderrTail.slice(-2000)}`));
|
||||
else {
|
||||
const err = new Error(`ffmpeg exited ${code ?? signal}: ${stderrTail.slice(-2000)}`);
|
||||
reject(markIfInputError(err, stderrTail));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolveFfprobe } from "./binaries.js";
|
||||
import { markIfInputError } from "./ffmpeg.js";
|
||||
|
||||
export interface MediaStreamInfo {
|
||||
type: "video" | "audio" | "other";
|
||||
@@ -68,7 +69,10 @@ export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Pro
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolvePromise(out);
|
||||
else reject(new Error(`ffprobe exited ${code ?? signal}: ${err.slice(-1000)}`));
|
||||
else {
|
||||
const probeErr = new Error(`ffprobe exited ${code ?? signal}: ${err.slice(-1000)}`);
|
||||
reject(markIfInputError(probeErr, err));
|
||||
}
|
||||
});
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
|
||||
@@ -4,5 +4,6 @@ export const ANALYTICS_BAKED = {
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
sentryDsnWeb: "",
|
||||
posthogSampleRate: 0,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Pure error-inspection helpers shared by the api and web Sentry scrubbers.
|
||||
* Everything here rebuilds safe strings from VETTED FIELDS ONLY; raw error
|
||||
* messages are never passed through (except SafeError, whose messages we
|
||||
* author). Returning null means "no safe rebuild known, use type-only".
|
||||
*/
|
||||
import { isSafeMessageError } from "../tool-errors.js";
|
||||
|
||||
interface ErrLike {
|
||||
name?: unknown;
|
||||
code?: unknown;
|
||||
syscall?: unknown;
|
||||
severity?: unknown;
|
||||
routine?: unknown;
|
||||
message?: unknown;
|
||||
cause?: unknown;
|
||||
issues?: unknown;
|
||||
status?: unknown;
|
||||
}
|
||||
|
||||
const NODE_CODE = /^E[A-Z0-9_]+$/;
|
||||
const SQLSTATE = /^[0-9A-Z]{5}$/;
|
||||
const PG_CONNECTIVITY = /^(08|57P0[123])/;
|
||||
const PG_ROUTINE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
||||
const REPLY_TOKEN = /^[A-Z][A-Z0-9_]{1,19}$/;
|
||||
const SAFE_NAME = /^[A-Za-z][A-Za-z0-9_$]{0,63}$/;
|
||||
const SAFE_PATH_SEGMENT = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const NET_CODES = new Set([
|
||||
"ECONNREFUSED",
|
||||
"ECONNRESET",
|
||||
"ETIMEDOUT",
|
||||
"EHOSTUNREACH",
|
||||
"EPIPE",
|
||||
"EAI_AGAIN",
|
||||
"ENOTFOUND",
|
||||
]);
|
||||
|
||||
function chain(err: unknown, max = 6): ErrLike[] {
|
||||
const out: ErrLike[] = [];
|
||||
let cur = err;
|
||||
while (cur && typeof cur === "object" && out.length < max) {
|
||||
out.push(cur as ErrLike);
|
||||
cur = (cur as ErrLike).cause;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function looksLikePg(links: ErrLike[]): boolean {
|
||||
return links.some(
|
||||
(l) =>
|
||||
(typeof l.code === "string" && SQLSTATE.test(l.code) && !NODE_CODE.test(l.code)) ||
|
||||
l.severity !== undefined ||
|
||||
l.name === "PostgresError" ||
|
||||
l.name === "DrizzleQueryError" ||
|
||||
(typeof l.message === "string" && l.message.startsWith("Failed query")),
|
||||
);
|
||||
}
|
||||
|
||||
/** Safe replacement for exception.value, or null for type-only fallback. */
|
||||
export function rebuildErrorValue(err: unknown): string | null {
|
||||
try {
|
||||
if (isSafeMessageError(err)) return err.message;
|
||||
const links = chain(err);
|
||||
if (links.length === 0) return null;
|
||||
|
||||
for (const l of links) {
|
||||
if (typeof l.code === "string" && SQLSTATE.test(l.code) && !NODE_CODE.test(l.code)) {
|
||||
return typeof l.routine === "string" && PG_ROUTINE.test(l.routine)
|
||||
? `pg ${l.code} ${l.routine}`
|
||||
: `pg ${l.code}`;
|
||||
}
|
||||
}
|
||||
for (const l of links) {
|
||||
if (typeof l.code === "string" && NODE_CODE.test(l.code)) {
|
||||
// syscall is libuv vocabulary or "spawn <server-binary>", never user args.
|
||||
return typeof l.syscall === "string" ? `${l.code} ${l.syscall}` : l.code;
|
||||
}
|
||||
}
|
||||
const top = links[0];
|
||||
if (top.name === "ReplyError" && typeof top.message === "string") {
|
||||
const token = top.message.split(" ")[0];
|
||||
return REPLY_TOKEN.test(token) ? `reply ${token}` : "reply";
|
||||
}
|
||||
if (top.name === "ZodError" && Array.isArray(top.issues) && top.issues[0]) {
|
||||
const issue = top.issues[0] as { code?: string; path?: Array<string | number> };
|
||||
const path = (issue.path ?? [])
|
||||
.map((seg) => {
|
||||
if (typeof seg === "number") return String(seg);
|
||||
return typeof seg === "string" && SAFE_PATH_SEGMENT.test(seg) ? seg : "~";
|
||||
})
|
||||
.join(".");
|
||||
return `zod ${issue.code ?? "invalid"} at ${path}`;
|
||||
}
|
||||
if (typeof top.status === "number") {
|
||||
const name =
|
||||
typeof top.name === "string" && SAFE_NAME.test(top.name) ? top.name : "HttpError";
|
||||
return `${name} ${top.status}`;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type ConnectivityClass = "pg-unavailable" | "redis-unavailable" | "net-unavailable";
|
||||
|
||||
/** Infra-connectivity classification used for fingerprinting + throttling. */
|
||||
export function connectivityClass(err: unknown): ConnectivityClass | null {
|
||||
try {
|
||||
const links = chain(err);
|
||||
if (links.length === 0) return null;
|
||||
if (links.some((l) => l.name === "MaxRetriesPerRequestError")) return "redis-unavailable";
|
||||
const hasPgState = links.some(
|
||||
(l) => typeof l.code === "string" && SQLSTATE.test(l.code) && PG_CONNECTIVITY.test(l.code),
|
||||
);
|
||||
const hasNetCode = links.some((l) => typeof l.code === "string" && NET_CODES.has(l.code));
|
||||
if (hasPgState || (hasNetCode && looksLikePg(links))) return "pg-unavailable";
|
||||
if (hasNetCode) return "net-unavailable";
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Client went away mid-request; operational noise, never reported. */
|
||||
export function isClientAbort(err: unknown): boolean {
|
||||
try {
|
||||
const links = chain(err);
|
||||
if (links.length === 0) return false;
|
||||
const top = links[0];
|
||||
if (top.code === "ECONNRESET" || top.code === "ERR_STREAM_PREMATURE_CLOSE") return true;
|
||||
return links.some(
|
||||
(l) =>
|
||||
l.name === "RequestAbortedError" ||
|
||||
l.name === "AbortError" ||
|
||||
(typeof l.message === "string" &&
|
||||
/^(request aborted|premature close|aborted)$/i.test(l.message)),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export interface AnalyticsConfig {
|
||||
posthogApiKey: string;
|
||||
posthogHost: string;
|
||||
sentryDsn: string;
|
||||
sampleRate: number;
|
||||
sentryDsnWeb: string;
|
||||
posthogSampleRate: number;
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./analytics/baked.js";
|
||||
export * from "./analytics/error-sanitize.js";
|
||||
export * from "./analytics/events.js";
|
||||
export * from "./analytics/feedback.js";
|
||||
export * from "./analytics/types.js";
|
||||
@@ -12,4 +13,5 @@ export * from "./permissions.js";
|
||||
export * from "./pipeline-templates.js";
|
||||
export * from "./search/format-aliases.js";
|
||||
export * from "./section.js";
|
||||
export * from "./tool-errors.js";
|
||||
export * from "./types.js";
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Error classes shared by api, web, and the engine packages.
|
||||
*
|
||||
* SafeError: an error whose message was AUTHORED BY US and is safe to send to
|
||||
* Sentry verbatim. RULE: the message must be a CONSTANT string; anything
|
||||
* variable (exit codes, versions, counts) goes into `code` so Sentry grouping
|
||||
* stays stable. Detection is by marker property, not instanceof, so it
|
||||
* survives error copying across module boundaries.
|
||||
*
|
||||
* ToolInputError: the user's input was the problem (bad CSV, corrupt media).
|
||||
* Never reported to Sentry. Engine packages can import these helpers
|
||||
* directly; the raw marker form Object.assign(err, { isToolInputError: true })
|
||||
* remains the wire format for contexts where an import is undesirable, and
|
||||
* because instanceof is brittle across duplicate module instances.
|
||||
*/
|
||||
export type SafeErrorKind = "operational" | "bug";
|
||||
|
||||
export class SafeError extends Error {
|
||||
readonly isSafeMessage = true;
|
||||
readonly kind: SafeErrorKind;
|
||||
readonly code?: string;
|
||||
readonly statusCode?: number;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
opts: { kind?: SafeErrorKind; code?: string; statusCode?: number; cause?: unknown } = {},
|
||||
) {
|
||||
super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
|
||||
this.name = "SafeError";
|
||||
this.kind = opts.kind ?? "operational";
|
||||
this.code = opts.code;
|
||||
this.statusCode = opts.statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker-detected errors that were copied across module boundaries may lack
|
||||
* `kind`, `code`, or `statusCode`, so consumers must tolerate their absence.
|
||||
*/
|
||||
export function isSafeMessageError(err: unknown): err is SafeError {
|
||||
return err instanceof Error && (err as { isSafeMessage?: unknown }).isSafeMessage === true;
|
||||
}
|
||||
|
||||
export class ToolInputError extends Error {
|
||||
readonly isToolInputError = true;
|
||||
readonly statusCode = 400;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ToolInputError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isToolInputError(err: unknown): err is Error & { isToolInputError: true } {
|
||||
return err instanceof Error && (err as { isToolInputError?: unknown }).isToolInputError === true;
|
||||
}
|
||||
|
||||
export function markToolInputError<E extends Error>(err: E): E {
|
||||
return Object.assign(err, { isToolInputError: true });
|
||||
}
|
||||
@@ -17,13 +17,14 @@ const posthogApiKey = on
|
||||
? (process.env.SNAPOTTER_POSTHOG_PROJECT_ID ?? process.env.SNAPOTTER_POSTHOG_KEY ?? "")
|
||||
: "";
|
||||
const sentryDsn = on ? (process.env.SNAPOTTER_SENTRY_DSN ?? "") : "";
|
||||
const sentryDsnWeb = on ? (process.env.SNAPOTTER_SENTRY_DSN_WEB ?? "") : "";
|
||||
const posthogHost = posthogApiKey ? "https://us.i.posthog.com" : "";
|
||||
// Enabled only when turned on AND there is somewhere to report to, so a
|
||||
// credential-less source build never initializes the SDKs.
|
||||
const enabled = on && (posthogApiKey !== "" || sentryDsn !== "");
|
||||
// tracesSampleRate only governs performance transactions; errors are always
|
||||
// captured. Keep low so fleet-wide tracing does not drain Sentry quota.
|
||||
const sampleRate = sentryDsn ? 0.1 : 0;
|
||||
const enabled = on && (posthogApiKey !== "" || sentryDsn !== "" || sentryDsnWeb !== "");
|
||||
// PostHog event sampling only. Sentry tracing was removed in 2.0.1; do not
|
||||
// reintroduce a shared "sampleRate" that doubles as a traces rate.
|
||||
const posthogSampleRate = on ? 0.1 : 0;
|
||||
|
||||
const content = `// AUTO-GENERATED by scripts/bake-analytics.mjs -- do not edit manually
|
||||
export const ANALYTICS_BAKED = {
|
||||
@@ -31,7 +32,8 @@ export const ANALYTICS_BAKED = {
|
||||
posthogApiKey: "${posthogApiKey}",
|
||||
posthogHost: "${posthogHost}",
|
||||
sentryDsn: "${sentryDsn}",
|
||||
sampleRate: ${sampleRate},
|
||||
sentryDsnWeb: "${sentryDsnWeb}",
|
||||
posthogSampleRate: ${posthogSampleRate},
|
||||
} as const;
|
||||
`;
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ interface AnalyticsConfig {
|
||||
posthogApiKey: string;
|
||||
posthogHost: string;
|
||||
sentryDsn: string;
|
||||
sampleRate: number;
|
||||
sentryDsnWeb: string;
|
||||
posthogSampleRate: number;
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
@@ -87,7 +88,8 @@ test.describe("Analytics opt-out toggle", () => {
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
sentryDsnWeb: "",
|
||||
posthogSampleRate: 0,
|
||||
instanceId: "",
|
||||
});
|
||||
|
||||
|
||||
@@ -15,5 +15,14 @@ setup("authenticate for analytics tests", async ({ page }) => {
|
||||
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
// This suite runs with analytics baked on against a fresh DB, so the admin
|
||||
// usage-survey overlay would cover the UI and swallow every click. Mark it
|
||||
// dismissed the same way the overlay itself does before any spec runs.
|
||||
const res = await page.request.put("/api/v1/settings", {
|
||||
data: { "onboarding.usageSurvey.dismissedAt": new Date().toISOString() },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
|
||||
@@ -14,7 +14,9 @@ test.describe("Privacy Policy Page", () => {
|
||||
|
||||
test("mentions PostHog as analytics provider", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/privacy");
|
||||
await expect(page.getByText(/posthog/i)).toBeVisible({ timeout: 5_000 });
|
||||
// The page legitimately mentions PostHog more than once; first() avoids a
|
||||
// strict-mode collision without weakening the presence assertion.
|
||||
await expect(page.getByText(/posthog/i).first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("mentions Sentry as error tracking provider", async ({ loggedInPage: page }) => {
|
||||
|
||||
@@ -20,14 +20,16 @@ test.describe("GET /api/v1/config/analytics (public)", () => {
|
||||
expect(config).toHaveProperty("posthogApiKey");
|
||||
expect(config).toHaveProperty("posthogHost");
|
||||
expect(config).toHaveProperty("sentryDsn");
|
||||
expect(config).toHaveProperty("sampleRate");
|
||||
expect(config).toHaveProperty("sentryDsnWeb");
|
||||
expect(config).toHaveProperty("posthogSampleRate");
|
||||
expect(config).toHaveProperty("instanceId");
|
||||
|
||||
expect(typeof config.enabled).toBe("boolean");
|
||||
expect(typeof config.posthogApiKey).toBe("string");
|
||||
expect(typeof config.posthogHost).toBe("string");
|
||||
expect(typeof config.sentryDsn).toBe("string");
|
||||
expect(typeof config.sampleRate).toBe("number");
|
||||
expect(typeof config.sentryDsnWeb).toBe("string");
|
||||
expect(typeof config.posthogSampleRate).toBe("number");
|
||||
expect(typeof config.instanceId).toBe("string");
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ test.describe("Analytics disabled by build-time config", () => {
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
sentryDsnWeb: "",
|
||||
posthogSampleRate: 0,
|
||||
instanceId: "",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,8 +75,22 @@ async function main() {
|
||||
cleanup();
|
||||
|
||||
// 1. Bare `docker run` with no DB env boots and becomes healthy.
|
||||
// Prod images bake real telemetry keys, so every container start in this
|
||||
// harness sets SNAPOTTER_TELEMETRY=0 to keep test-fleet boots silent.
|
||||
console.log("\n[1] bare docker run boots healthy");
|
||||
docker(["run", "-d", "--name", NAME, "-p", `${PORT}:1349`, "-v", `${VOL}:/data`, IMAGE]);
|
||||
docker([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
NAME,
|
||||
"-p",
|
||||
`${PORT}:1349`,
|
||||
"-v",
|
||||
`${VOL}:/data`,
|
||||
"-e",
|
||||
"SNAPOTTER_TELEMETRY=0",
|
||||
IMAGE,
|
||||
]);
|
||||
const healthy = await waitHealthy(300000);
|
||||
healthy
|
||||
? ok("embedded container reached healthy")
|
||||
@@ -111,15 +125,23 @@ async function main() {
|
||||
|
||||
// 4. Non-root fails fast.
|
||||
console.log("\n[4] non-root fail-fast");
|
||||
combined(["run", "--rm", "--user", "1000:1000", IMAGE]).includes("embedded mode needs root")
|
||||
combined(["run", "--rm", "--user", "1000:1000", "-e", "SNAPOTTER_TELEMETRY=0", IMAGE]).includes(
|
||||
"embedded mode needs root",
|
||||
)
|
||||
? ok("non-root rejected with guidance")
|
||||
: bad("non-root not rejected");
|
||||
|
||||
// 5. Partial config fails fast.
|
||||
console.log("\n[5] partial-config fail-fast");
|
||||
combined(["run", "--rm", "-e", "REDIS_URL=redis://x:6379", IMAGE]).includes(
|
||||
"set BOTH DATABASE_URL and REDIS_URL",
|
||||
)
|
||||
combined([
|
||||
"run",
|
||||
"--rm",
|
||||
"-e",
|
||||
"REDIS_URL=redis://x:6379",
|
||||
"-e",
|
||||
"SNAPOTTER_TELEMETRY=0",
|
||||
IMAGE,
|
||||
]).includes("set BOTH DATABASE_URL and REDIS_URL")
|
||||
? ok("partial config rejected")
|
||||
: bad("partial config not rejected");
|
||||
|
||||
@@ -152,7 +174,19 @@ async function main() {
|
||||
"-c",
|
||||
`apk add --no-cache sqlite >/dev/null 2>&1 && sqlite3 /data/snapotter.db "${seedSql}"`,
|
||||
]);
|
||||
docker(["run", "-d", "--name", NAME, "-p", `${PORT}:1349`, "-v", `${VOL}:/data`, IMAGE]);
|
||||
docker([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
NAME,
|
||||
"-p",
|
||||
`${PORT}:1349`,
|
||||
"-v",
|
||||
`${VOL}:/data`,
|
||||
"-e",
|
||||
"SNAPOTTER_TELEMETRY=0",
|
||||
IMAGE,
|
||||
]);
|
||||
(await waitHealthy(300000))
|
||||
? ok("healthy after upgrade boot")
|
||||
: bad("unhealthy after upgrade boot");
|
||||
|
||||
@@ -47,14 +47,16 @@ describe("GET /api/v1/config/analytics", () => {
|
||||
expect(config).toHaveProperty("posthogApiKey");
|
||||
expect(config).toHaveProperty("posthogHost");
|
||||
expect(config).toHaveProperty("sentryDsn");
|
||||
expect(config).toHaveProperty("sampleRate");
|
||||
expect(config).toHaveProperty("sentryDsnWeb");
|
||||
expect(config).toHaveProperty("posthogSampleRate");
|
||||
expect(config).toHaveProperty("instanceId");
|
||||
|
||||
expect(typeof config.enabled).toBe("boolean");
|
||||
expect(typeof config.posthogApiKey).toBe("string");
|
||||
expect(typeof config.posthogHost).toBe("string");
|
||||
expect(typeof config.sentryDsn).toBe("string");
|
||||
expect(typeof config.sampleRate).toBe("number");
|
||||
expect(typeof config.sentryDsnWeb).toBe("string");
|
||||
expect(typeof config.posthogSampleRate).toBe("number");
|
||||
expect(typeof config.instanceId).toBe("string");
|
||||
});
|
||||
|
||||
@@ -76,7 +78,8 @@ describe("GET /api/v1/config/analytics", () => {
|
||||
expect(config.posthogApiKey).toBe("");
|
||||
expect(config.posthogHost).toBe("");
|
||||
expect(config.sentryDsn).toBe("");
|
||||
expect(config.sampleRate).toBe(0);
|
||||
expect(config.sentryDsnWeb).toBe("");
|
||||
expect(config.posthogSampleRate).toBe(0);
|
||||
expect(config.instanceId).toBe("");
|
||||
});
|
||||
|
||||
|
||||
@@ -298,6 +298,23 @@ describe("importBundleArchive", () => {
|
||||
/invalid model path/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a corrupt gzip stream with ImportValidationError, not a ZlibError", async () => {
|
||||
// Gzip magic bytes followed by garbage: minizlib raises a ZlibError
|
||||
// during tar parse (Sentry NODE-1Z shape).
|
||||
const corruptPath = join(tmpdir(), `snapotter-corrupt-${randomUUID()}.tar.gz`);
|
||||
writeFileSync(
|
||||
corruptPath,
|
||||
Buffer.concat([Buffer.from([0x1f, 0x8b, 0x08]), Buffer.alloc(64, 0x41)]),
|
||||
);
|
||||
|
||||
await expect(importBundleArchive(createReadStream(corruptPath))).rejects.toThrow(
|
||||
ImportValidationError,
|
||||
);
|
||||
await expect(importBundleArchive(createReadStream(corruptPath))).rejects.toThrow(
|
||||
/not a valid bundle archive/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("site-packages import", () => {
|
||||
@@ -441,6 +458,30 @@ describe("POST /api/v1/admin/features/import", () => {
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it("rejects a non-archive upload with 400, not a crash", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "corrupt.tar.gz",
|
||||
contentType: "application/gzip",
|
||||
content: Buffer.from("this is not gzip data"),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/features/import",
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/not a valid bundle archive/i);
|
||||
});
|
||||
|
||||
it("returns 409 when lock is held", async () => {
|
||||
const locked = acquireInstallLock("blocking-bundle");
|
||||
expect(locked).toBe(true);
|
||||
|
||||
@@ -203,7 +203,11 @@ describe("bridge - runPythonWithProgress (per-request fallback)", () => {
|
||||
mock.stderr.emit("data", Buffer.from("RuntimeError: model not found\n"));
|
||||
mock.emitEvent("close", 1, null);
|
||||
|
||||
await expect(promise).rejects.toThrow("RuntimeError: model not found");
|
||||
const err = (await promise.catch((e: unknown) => e)) as Error & { isSafeMessage?: unknown };
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toContain("RuntimeError: model not found");
|
||||
// Variable python-derived text must stay a plain Error, never a SafeError
|
||||
expect(err.isSafeMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects with OOM message on exit code 137 (SIGKILL)", async () => {
|
||||
@@ -214,7 +218,18 @@ describe("bridge - runPythonWithProgress (per-request fallback)", () => {
|
||||
|
||||
mock.emitEvent("close", 137, "SIGKILL");
|
||||
|
||||
await expect(promise).rejects.toThrow("Process killed (out of memory)");
|
||||
const err = (await promise.catch((e: unknown) => e)) as Error & {
|
||||
isSafeMessage?: unknown;
|
||||
kind?: string;
|
||||
code?: string;
|
||||
};
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe(
|
||||
"Process killed (out of memory) -- try a lighter model or smaller image",
|
||||
);
|
||||
expect(err.isSafeMessage).toBe(true);
|
||||
expect(err.kind).toBe("operational");
|
||||
expect(err.code).toBe("exit-137");
|
||||
});
|
||||
|
||||
it("rejects with segfault message on exit code 139 (SIGSEGV)", async () => {
|
||||
@@ -225,7 +240,16 @@ describe("bridge - runPythonWithProgress (per-request fallback)", () => {
|
||||
|
||||
mock.emitEvent("close", 139, "SIGSEGV");
|
||||
|
||||
await expect(promise).rejects.toThrow("Process crashed (segmentation fault)");
|
||||
const err = (await promise.catch((e: unknown) => e)) as Error & {
|
||||
isSafeMessage?: unknown;
|
||||
kind?: string;
|
||||
code?: string;
|
||||
};
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe("Process crashed (segmentation fault)");
|
||||
expect(err.isSafeMessage).toBe(true);
|
||||
expect(err.kind).toBe("operational");
|
||||
expect(err.code).toBe("exit-139");
|
||||
});
|
||||
|
||||
it("rejects with timeout error when process exceeds timeout", async () => {
|
||||
@@ -472,7 +496,14 @@ describe("bridge - runPythonWithProgress (per-request fallback)", () => {
|
||||
// SIGKILL signal without exit code 137
|
||||
mock.emitEvent("close", null, "SIGKILL");
|
||||
|
||||
await expect(promise).rejects.toThrow("out of memory");
|
||||
const err = (await promise.catch((e: unknown) => e)) as Error & {
|
||||
isSafeMessage?: unknown;
|
||||
code?: string;
|
||||
};
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toContain("out of memory");
|
||||
expect(err.isSafeMessage).toBe(true);
|
||||
expect(err.code).toBe("exit-SIGKILL");
|
||||
});
|
||||
|
||||
it("treats SIGSEGV signal as segfault error", async () => {
|
||||
@@ -483,7 +514,14 @@ describe("bridge - runPythonWithProgress (per-request fallback)", () => {
|
||||
|
||||
mock.emitEvent("close", null, "SIGSEGV");
|
||||
|
||||
await expect(promise).rejects.toThrow("segmentation fault");
|
||||
const err = (await promise.catch((e: unknown) => e)) as Error & {
|
||||
isSafeMessage?: unknown;
|
||||
code?: string;
|
||||
};
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toContain("segmentation fault");
|
||||
expect(err.isSafeMessage).toBe(true);
|
||||
expect(err.code).toBe("exit-SIGSEGV");
|
||||
});
|
||||
|
||||
it("includes exit code in error when no signal and no stderr", async () => {
|
||||
@@ -1456,7 +1494,18 @@ describe("bridge - dispatcher stdin JSON-RPC protocol", () => {
|
||||
|
||||
mock.stdout.emit("data", Buffer.from(`${JSON.stringify({ id, exitCode: 137, stdout: "" })}\n`));
|
||||
|
||||
await expect(promise).rejects.toThrow("out of memory");
|
||||
const err = (await promise.catch((e: unknown) => e)) as Error & {
|
||||
isSafeMessage?: unknown;
|
||||
kind?: string;
|
||||
code?: string;
|
||||
};
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe(
|
||||
"Process killed (out of memory) -- try a lighter model or smaller image",
|
||||
);
|
||||
expect(err.isSafeMessage).toBe(true);
|
||||
expect(err.kind).toBe("operational");
|
||||
expect(err.code).toBe("exit-137");
|
||||
});
|
||||
|
||||
it("rejects with segfault message when dispatcher response has exitCode 139", async () => {
|
||||
@@ -1470,7 +1519,39 @@ describe("bridge - dispatcher stdin JSON-RPC protocol", () => {
|
||||
|
||||
mock.stdout.emit("data", Buffer.from(`${JSON.stringify({ id, exitCode: 139, stdout: "" })}\n`));
|
||||
|
||||
await expect(promise).rejects.toThrow("segmentation fault");
|
||||
const err = (await promise.catch((e: unknown) => e)) as Error & {
|
||||
isSafeMessage?: unknown;
|
||||
kind?: string;
|
||||
code?: string;
|
||||
};
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe("Process crashed (segmentation fault)");
|
||||
expect(err.isSafeMessage).toBe(true);
|
||||
expect(err.kind).toBe("operational");
|
||||
expect(err.code).toBe("exit-139");
|
||||
});
|
||||
|
||||
it("keeps extracted python text as a plain Error even on dispatcher exitCode 137", async () => {
|
||||
const mock = await setupReadyDispatcher();
|
||||
|
||||
const promise = runPythonWithProgress("heavy.py", []);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const line = mock.stdinWrites.join("").split("\n").filter(Boolean)[0];
|
||||
const id = JSON.parse(line).id;
|
||||
|
||||
// Extractable error text takes precedence over the constant signal message
|
||||
mock.stdout.emit(
|
||||
"data",
|
||||
Buffer.from(
|
||||
`${JSON.stringify({ id, exitCode: 137, stdout: '{"error": "CUDA out of memory"}' })}\n`,
|
||||
),
|
||||
);
|
||||
|
||||
const err = (await promise.catch((e: unknown) => e)) as Error & { isSafeMessage?: unknown };
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe("CUDA out of memory");
|
||||
expect(err.isSafeMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores stdout lines that are not valid JSON", async () => {
|
||||
|
||||
@@ -5,21 +5,26 @@ import {
|
||||
analyticsEnabled,
|
||||
bakedEnabled,
|
||||
refreshAnalyticsGate,
|
||||
telemetryEnvKilled,
|
||||
} from "../../../apps/api/src/lib/analytics-gate.js";
|
||||
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
const origOverride = process.env.ANALYTICS_BAKED_OVERRIDE;
|
||||
const origTelemetry = process.env.SNAPOTTER_TELEMETRY;
|
||||
|
||||
beforeEach(() => {
|
||||
__resetGateForTests();
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.ANALYTICS_BAKED_OVERRIDE = "on"; // force bake on for these unit tests
|
||||
delete process.env.SNAPOTTER_TELEMETRY; // ambient kill switch would poison the suite
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = origEnv;
|
||||
if (origOverride === undefined) delete process.env.ANALYTICS_BAKED_OVERRIDE;
|
||||
else process.env.ANALYTICS_BAKED_OVERRIDE = origOverride;
|
||||
if (origTelemetry === undefined) delete process.env.SNAPOTTER_TELEMETRY;
|
||||
else process.env.SNAPOTTER_TELEMETRY = origTelemetry;
|
||||
__setReaderForTests(null);
|
||||
});
|
||||
|
||||
@@ -58,6 +63,30 @@ describe("effective enabled = baked AND toggle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SNAPOTTER_TELEMETRY runtime kill switch", () => {
|
||||
it("outranks ANALYTICS_BAKED_OVERRIDE=on outside production", () => {
|
||||
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
|
||||
process.env.SNAPOTTER_TELEMETRY = "0";
|
||||
expect(bakedEnabled()).toBe(false);
|
||||
});
|
||||
it("telemetryEnvKilled matches 0, false, off and nothing else", () => {
|
||||
for (const v of ["0", "false", "off"]) {
|
||||
process.env.SNAPOTTER_TELEMETRY = v;
|
||||
expect(telemetryEnvKilled()).toBe(true);
|
||||
}
|
||||
delete process.env.SNAPOTTER_TELEMETRY;
|
||||
expect(telemetryEnvKilled()).toBe(false);
|
||||
process.env.SNAPOTTER_TELEMETRY = "1";
|
||||
expect(telemetryEnvKilled()).toBe(false);
|
||||
});
|
||||
it("is honored in production builds", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.SNAPOTTER_TELEMETRY = "0";
|
||||
expect(bakedEnabled()).toBe(false);
|
||||
expect(telemetryEnvKilled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fail closed", () => {
|
||||
it("stays disabled across a transient read error once disabled was seen", async () => {
|
||||
__setReaderForTests(async () => false);
|
||||
|
||||
@@ -5,7 +5,8 @@ const bakedConfig = vi.hoisted(() => ({
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 1.0,
|
||||
sentryDsnWeb: "",
|
||||
posthogSampleRate: 1.0,
|
||||
}));
|
||||
|
||||
const mockCapture = vi.hoisted(() => vi.fn());
|
||||
@@ -20,6 +21,13 @@ const MockPostHog = vi.hoisted(() =>
|
||||
const mockSentryCapture = vi.hoisted(() => vi.fn());
|
||||
const mockSentryClose = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const mockSentryInit = vi.hoisted(() => vi.fn());
|
||||
// The captureException shim routes through reportError, which captures
|
||||
// inside Sentry.withScope; the mock must invoke the callback.
|
||||
const mockSentryWithScope = vi.hoisted(() =>
|
||||
vi.fn((cb: (scope: unknown) => unknown) =>
|
||||
cb({ setTag: () => {}, setLevel: () => {}, setFingerprint: () => {} }),
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("@snapotter/shared", async (importOriginal) => {
|
||||
const actual: Record<string, unknown> = await importOriginal();
|
||||
@@ -56,6 +64,7 @@ vi.mock("@sentry/node", () => ({
|
||||
init: mockSentryInit,
|
||||
captureException: mockSentryCapture,
|
||||
close: mockSentryClose,
|
||||
withScope: mockSentryWithScope,
|
||||
}));
|
||||
|
||||
type AnalyticsModule = typeof import("../../../apps/api/src/lib/analytics.js");
|
||||
@@ -66,7 +75,8 @@ beforeEach(async () => {
|
||||
bakedConfig.posthogApiKey = "";
|
||||
bakedConfig.posthogHost = "";
|
||||
bakedConfig.sentryDsn = "";
|
||||
bakedConfig.sampleRate = 1.0;
|
||||
bakedConfig.sentryDsnWeb = "";
|
||||
bakedConfig.posthogSampleRate = 1.0;
|
||||
|
||||
mockCapture.mockClear();
|
||||
mockShutdown.mockClear();
|
||||
@@ -74,6 +84,7 @@ beforeEach(async () => {
|
||||
mockSentryCapture.mockClear();
|
||||
mockSentryClose.mockClear();
|
||||
mockSentryInit.mockClear();
|
||||
mockSentryWithScope.mockClear();
|
||||
|
||||
vi.resetModules();
|
||||
mod = await import("../../../apps/api/src/lib/analytics.js");
|
||||
@@ -165,10 +176,10 @@ describe("trackEvent", () => {
|
||||
await expect(mod.trackEvent("test_event", { key: "value" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing when sampleRate is 0", async () => {
|
||||
it("does nothing when posthogSampleRate is 0", async () => {
|
||||
bakedConfig.enabled = true;
|
||||
bakedConfig.posthogApiKey = "phc_test_key";
|
||||
bakedConfig.sampleRate = 0;
|
||||
bakedConfig.posthogSampleRate = 0;
|
||||
await mod.initAnalytics();
|
||||
|
||||
await mod.trackEvent("test_event", { key: "value" });
|
||||
@@ -178,7 +189,7 @@ describe("trackEvent", () => {
|
||||
it("captures event with only allow-listed properties when enabled", async () => {
|
||||
bakedConfig.enabled = true;
|
||||
bakedConfig.posthogApiKey = "phc_test_key";
|
||||
bakedConfig.sampleRate = 1.0;
|
||||
bakedConfig.posthogSampleRate = 1.0;
|
||||
await mod.initAnalytics();
|
||||
|
||||
await mod.trackEvent("tool_used", {
|
||||
@@ -197,7 +208,7 @@ describe("trackEvent", () => {
|
||||
it("uses provided distinctId when given", async () => {
|
||||
bakedConfig.enabled = true;
|
||||
bakedConfig.posthogApiKey = "phc_test_key";
|
||||
bakedConfig.sampleRate = 1.0;
|
||||
bakedConfig.posthogSampleRate = 1.0;
|
||||
await mod.initAnalytics();
|
||||
|
||||
await mod.trackEvent("tool_used", { tool: "crop" }, "custom-id-123");
|
||||
@@ -211,7 +222,7 @@ describe("trackEvent", () => {
|
||||
it("does not throw when capture throws internally", async () => {
|
||||
bakedConfig.enabled = true;
|
||||
bakedConfig.posthogApiKey = "phc_test_key";
|
||||
bakedConfig.sampleRate = 1.0;
|
||||
bakedConfig.posthogSampleRate = 1.0;
|
||||
await mod.initAnalytics();
|
||||
|
||||
mockCapture.mockImplementationOnce(() => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isStaticAssetRequest } from "../../../apps/api/src/plugins/auth.js";
|
||||
|
||||
describe("isStaticAssetRequest", () => {
|
||||
it("matches GET/HEAD under /assets/ only", () => {
|
||||
expect(isStaticAssetRequest("GET", "/assets/react-D3OgQKsK.js")).toBe(true);
|
||||
expect(isStaticAssetRequest("HEAD", "/assets/x.css")).toBe(true);
|
||||
expect(isStaticAssetRequest("GET", "/api/v1/settings")).toBe(false);
|
||||
expect(isStaticAssetRequest("POST", "/assets/x.js")).toBe(false);
|
||||
expect(isStaticAssetRequest("GET", "/assetsish")).toBe(false);
|
||||
expect(isStaticAssetRequest("GET", "/assets/../api/v1/settings")).toBe(false);
|
||||
expect(isStaticAssetRequest("GET", "/assets/x.js?v=abc")).toBe(true);
|
||||
expect(isStaticAssetRequest("GET", "/assets")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Regression guard for the single deliberate Sentry capture path.
|
||||
*
|
||||
* Before the overhaul a failed tool job was captured twice (processToolJob
|
||||
* catch + worker.on("failed")) and wrapped in new Error(String(err)), which
|
||||
* produced frameless events. These tests pin the contract: one reportError
|
||||
* call yields exactly one captureException with the ORIGINAL error object,
|
||||
* and the throttle allows one capture per distinct signature.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { reportError, resetThrottleForTests } from "../../../apps/api/src/lib/error-report.js";
|
||||
|
||||
const h = vi.hoisted(() => {
|
||||
const scope = { setTag: vi.fn(), setLevel: vi.fn(), setFingerprint: vi.fn() };
|
||||
return {
|
||||
scope,
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn((cb: (s: typeof scope) => unknown) => cb(scope)),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@sentry/node", () => ({
|
||||
captureException: h.captureException,
|
||||
withScope: h.withScope,
|
||||
}));
|
||||
|
||||
vi.mock("../../../apps/api/src/lib/analytics-gate.js", () => ({
|
||||
analyticsEnabled: () => true,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetThrottleForTests();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("capture path", () => {
|
||||
it("a single worker-failure reportError yields exactly one capture, unwrapped, tagged", async () => {
|
||||
const boom = new Error("boom");
|
||||
await reportError(boom, { source: "worker", pool: "image" });
|
||||
|
||||
expect(h.captureException).toHaveBeenCalledTimes(1);
|
||||
// The original object flows through: no new Error(String(err)) wrapping,
|
||||
// so stacks, codes, and marker properties survive.
|
||||
expect(h.captureException).toHaveBeenCalledWith(boom);
|
||||
expect(h.scope.setTag).toHaveBeenCalledWith("source", "worker");
|
||||
expect(h.scope.setTag).toHaveBeenCalledWith("pool", "image");
|
||||
});
|
||||
|
||||
it("captures once per distinct signature; operational repeats are throttled", async () => {
|
||||
const full = Object.assign(new Error("disk full"), { code: "ENOSPC" });
|
||||
await reportError(full, { source: "worker", pool: "image" });
|
||||
await reportError(full, { source: "worker", pool: "image" });
|
||||
expect(h.captureException).toHaveBeenCalledTimes(1);
|
||||
|
||||
const denied = Object.assign(new Error("denied"), { code: "EACCES" });
|
||||
await reportError(denied, { source: "worker", pool: "image" });
|
||||
expect(h.captureException).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { SafeError, ToolInputError } from "@snapotter/shared";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyError,
|
||||
errorSignature,
|
||||
resetThrottleForTests,
|
||||
shouldReport,
|
||||
} from "../../../apps/api/src/lib/error-report.js";
|
||||
|
||||
describe("classifyError", () => {
|
||||
it("expected: tool input, aborts, worker cancel/timeout strings, zod, upload validation", () => {
|
||||
expect(classifyError(new ToolInputError("bad csv"))).toBe("expected");
|
||||
expect(classifyError(Object.assign(new Error("aborted"), { code: "ECONNRESET" }))).toBe(
|
||||
"expected",
|
||||
);
|
||||
expect(classifyError(new Error("Canceled"))).toBe("expected");
|
||||
expect(classifyError(new Error("Timed out after 120s"))).toBe("expected");
|
||||
expect(
|
||||
classifyError(Object.assign(new Error("zodish"), { name: "ZodError", issues: [] })),
|
||||
).toBe("expected");
|
||||
expect(
|
||||
classifyError(Object.assign(new Error("bad png"), { name: "InputValidationError" })),
|
||||
).toBe("expected");
|
||||
});
|
||||
it("operational: connectivity, disk, perms, operational SafeError, marker-copied SafeError without kind", () => {
|
||||
const pg = Object.assign(new Error("Failed query: q"), {
|
||||
cause: Object.assign(new Error("57P01"), { code: "57P01" }),
|
||||
});
|
||||
expect(classifyError(pg)).toBe("operational");
|
||||
expect(classifyError(Object.assign(new Error("full"), { code: "ENOSPC" }))).toBe("operational");
|
||||
expect(classifyError(new SafeError("AI dispatcher exited", { kind: "operational" }))).toBe(
|
||||
"operational",
|
||||
);
|
||||
expect(classifyError(Object.assign(new Error("copied"), { isSafeMessage: true }))).toBe(
|
||||
"operational",
|
||||
);
|
||||
});
|
||||
it("bug: everything else, including bug-kind SafeError and ReplyError", () => {
|
||||
expect(classifyError(new Error("undefined is not a function"))).toBe("bug");
|
||||
expect(classifyError(new SafeError("Impossible state", { kind: "bug" }))).toBe("bug");
|
||||
expect(classifyError(Object.assign(new Error("ERR bad cmd"), { name: "ReplyError" }))).toBe(
|
||||
"bug",
|
||||
);
|
||||
});
|
||||
it("worker source: zod is a bug (schema drift) and bare resets are operational", () => {
|
||||
const zod = Object.assign(new Error("z"), { name: "ZodError", issues: [] });
|
||||
expect(classifyError(zod, "worker")).toBe("bug");
|
||||
expect(classifyError(zod, "http")).toBe("expected");
|
||||
const reset = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" });
|
||||
expect(classifyError(reset, "worker")).toBe("operational");
|
||||
expect(classifyError(reset, "http")).toBe("expected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("throttle", () => {
|
||||
beforeEach(() => resetThrottleForTests());
|
||||
it("operational: 1 per signature per hour; bug: 10", () => {
|
||||
expect(shouldReport("operational", "sig-a")).toBe(true);
|
||||
expect(shouldReport("operational", "sig-a")).toBe(false);
|
||||
expect(shouldReport("operational", "sig-b")).toBe(true);
|
||||
for (let i = 0; i < 10; i++) expect(shouldReport("bug", "sig-c")).toBe(true);
|
||||
expect(shouldReport("bug", "sig-c")).toBe(false);
|
||||
});
|
||||
it("window resets after an hour", () => {
|
||||
expect(shouldReport("operational", "sig", 1_000)).toBe(true);
|
||||
expect(shouldReport("operational", "sig", 2_000)).toBe(false);
|
||||
expect(shouldReport("operational", "sig", 1_000 + 3_600_001)).toBe(true);
|
||||
});
|
||||
it("same signature under different classes throttles independently", () => {
|
||||
expect(shouldReport("operational", "sig-x")).toBe(true);
|
||||
expect(shouldReport("bug", "sig-x")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorSignature", () => {
|
||||
it("combines name, code, and first in-repo frame", () => {
|
||||
const err = Object.assign(new Error("x"), { code: "EACCES" });
|
||||
err.stack =
|
||||
"Error: x\n at mkdir (node:fs:1)\n at startCleanupCron (/app/apps/api/src/lib/cleanup.ts:48:3)";
|
||||
expect(errorSignature(err)).toBe("Error:EACCES:cleanup.ts:48");
|
||||
});
|
||||
it("degrades gracefully without stack or code", () => {
|
||||
expect(errorSignature(new TypeError("t"))).toMatch(/^TypeError:-:/);
|
||||
expect(errorSignature(null)).toBe("Unknown:-:-");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { chmod, mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -13,6 +13,18 @@ vi.mock("../../../apps/api/src/config.js", () => ({
|
||||
env: config,
|
||||
}));
|
||||
|
||||
// Passthrough fs mock: only statfs is overridable, to simulate a full disk.
|
||||
const diskState = vi.hoisted(() => ({ lowDisk: false }));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...actual,
|
||||
statfs: (path: string) =>
|
||||
diskState.lowDisk ? Promise.resolve({ bfree: 0, bsize: 4096 }) : actual.statfs(path),
|
||||
};
|
||||
});
|
||||
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -23,6 +35,7 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
diskState.lowDisk = false;
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -30,6 +43,13 @@ async function importModule() {
|
||||
return await import("../../../apps/api/src/lib/file-storage.js");
|
||||
}
|
||||
|
||||
type StorageError = Error & {
|
||||
isSafeMessage?: unknown;
|
||||
kind?: string;
|
||||
code?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
describe("saveFile", () => {
|
||||
it("saves buffer and returns UUID-based filename with correct extension", async () => {
|
||||
const { saveFile } = await importModule();
|
||||
@@ -189,3 +209,87 @@ describe("thumbnail functions", () => {
|
||||
await expect(deleteThumbnail("ghost.png")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("storage not writable (EACCES)", () => {
|
||||
// chmod-based EACCES does not apply to root, which bypasses permission checks
|
||||
const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
|
||||
|
||||
it.skipIf(isRoot)("ensureStorageDir throws a SafeError carrying statusCode 503", async () => {
|
||||
const roParent = join(tmpdir(), `snapotter-fs-ro-${randomUUID().slice(0, 8)}`);
|
||||
await mkdir(roParent, { recursive: true });
|
||||
config.FILES_STORAGE_PATH = join(roParent, "sub");
|
||||
try {
|
||||
await chmod(roParent, 0o555);
|
||||
const { ensureStorageDir } = await importModule();
|
||||
const err = (await ensureStorageDir().then(
|
||||
() => null,
|
||||
(e: unknown) => e,
|
||||
)) as StorageError | null;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err?.message).toBe("Storage directory is not writable");
|
||||
expect(err?.isSafeMessage).toBe(true);
|
||||
expect(err?.kind).toBe("operational");
|
||||
expect(err?.code).toBe("EACCES");
|
||||
expect(err?.statusCode).toBe(503);
|
||||
} finally {
|
||||
await chmod(roParent, 0o755).catch(() => {});
|
||||
await rm(roParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(isRoot)("saveFile throws a SafeError when the directory is read-only", async () => {
|
||||
const { saveFile } = await importModule();
|
||||
try {
|
||||
await chmod(testDir, 0o555);
|
||||
const err = (await saveFile(Buffer.from("x"), "a.png").then(
|
||||
() => null,
|
||||
(e: unknown) => e,
|
||||
)) as StorageError | null;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err?.message).toBe("Storage directory is not writable");
|
||||
expect(err?.isSafeMessage).toBe(true);
|
||||
expect(err?.statusCode).toBe(503);
|
||||
} finally {
|
||||
await chmod(testDir, 0o755).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(isRoot)(
|
||||
"saveThumbnail throws a SafeError when the directory is read-only",
|
||||
async () => {
|
||||
const { saveThumbnail } = await importModule();
|
||||
try {
|
||||
await chmod(testDir, 0o555);
|
||||
const err = (await saveThumbnail("pic.png", Buffer.from("t")).then(
|
||||
() => null,
|
||||
(e: unknown) => e,
|
||||
)) as StorageError | null;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err?.message).toBe("Storage directory is not writable");
|
||||
expect(err?.isSafeMessage).toBe(true);
|
||||
expect(err?.kind).toBe("operational");
|
||||
expect(err?.code).toBe("EACCES");
|
||||
expect(err?.statusCode).toBe(503);
|
||||
} finally {
|
||||
await chmod(testDir, 0o755).catch(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("disk space floor", () => {
|
||||
it("saveFile throws a SafeError with statusCode 507 when free space is below the floor", async () => {
|
||||
const { saveFile } = await importModule();
|
||||
diskState.lowDisk = true;
|
||||
const err = (await saveFile(Buffer.from("x"), "a.png").then(
|
||||
() => null,
|
||||
(e: unknown) => e,
|
||||
)) as StorageError | null;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err?.message).toBe("Insufficient disk space");
|
||||
expect(err?.isSafeMessage).toBe(true);
|
||||
expect(err?.kind).toBe("operational");
|
||||
expect(err?.code).toBe("ENOSPC");
|
||||
expect(err?.statusCode).toBe(507);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ const deliverWebhookMock = vi.hoisted(() => vi.fn());
|
||||
const decryptMock = vi.hoisted(() => vi.fn());
|
||||
const isEncryptedMock = vi.hoisted(() => vi.fn());
|
||||
const getActiveLicenseMock = vi.hoisted(() => vi.fn());
|
||||
const isFeatureEnabledMock = vi.hoisted(() => vi.fn());
|
||||
const selectMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
function queryChain<T>(result: T) {
|
||||
@@ -24,6 +25,8 @@ async function loadAlertEvaluator() {
|
||||
decryptMock.mockReset();
|
||||
isEncryptedMock.mockReset();
|
||||
getActiveLicenseMock.mockReset();
|
||||
isFeatureEnabledMock.mockReset();
|
||||
isFeatureEnabledMock.mockReturnValue(true);
|
||||
selectMock.mockReset();
|
||||
|
||||
vi.doMock("node:fs/promises", () => ({
|
||||
@@ -71,6 +74,7 @@ async function loadAlertEvaluator() {
|
||||
|
||||
vi.doMock("@snapotter/enterprise", () => ({
|
||||
getActiveLicense: getActiveLicenseMock,
|
||||
isFeatureEnabled: isFeatureEnabledMock,
|
||||
}));
|
||||
|
||||
return import("../../../../apps/api/src/jobs/alert-evaluator.js");
|
||||
@@ -81,6 +85,17 @@ describe("alert evaluator behavior", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns before reading settings when the admin_alerts feature is unlicensed", async () => {
|
||||
const { evaluateAlerts } = await loadAlertEvaluator();
|
||||
isFeatureEnabledMock.mockReturnValue(false);
|
||||
|
||||
await expect(evaluateAlerts()).resolves.toBeUndefined();
|
||||
|
||||
expect(getSettingStringMock).not.toHaveBeenCalled();
|
||||
expect(statfsMock).not.toHaveBeenCalled();
|
||||
expect(deliverWebhookMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when webhook destination settings are invalid JSON", async () => {
|
||||
const { evaluateAlerts } = await loadAlertEvaluator();
|
||||
getSettingStringMock.mockResolvedValueOnce("{invalid");
|
||||
|
||||
@@ -5,6 +5,7 @@ const deliverWebhookMock = vi.hoisted(() => vi.fn());
|
||||
const upsertSettingMock = vi.hoisted(() => vi.fn());
|
||||
const decryptMock = vi.hoisted(() => vi.fn());
|
||||
const isEncryptedMock = vi.hoisted(() => vi.fn());
|
||||
const isFeatureEnabledMock = vi.hoisted(() => vi.fn());
|
||||
const selectMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
function queryChain<T>(result: T, terminalWhere = false) {
|
||||
@@ -24,6 +25,8 @@ async function loadSiemForward() {
|
||||
upsertSettingMock.mockReset();
|
||||
decryptMock.mockReset();
|
||||
isEncryptedMock.mockReset();
|
||||
isFeatureEnabledMock.mockReset();
|
||||
isFeatureEnabledMock.mockReturnValue(true);
|
||||
selectMock.mockReset();
|
||||
|
||||
vi.doMock("drizzle-orm", () => ({
|
||||
@@ -75,6 +78,10 @@ async function loadSiemForward() {
|
||||
readSiemConfig: readSiemConfigMock,
|
||||
}));
|
||||
|
||||
vi.doMock("@snapotter/enterprise", () => ({
|
||||
isFeatureEnabled: isFeatureEnabledMock,
|
||||
}));
|
||||
|
||||
return import("../../../../apps/api/src/jobs/siem-forward.js");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkRedisInfoCompatible } from "../../../apps/api/src/jobs/connection.js";
|
||||
|
||||
describe("checkRedisInfoCompatible", () => {
|
||||
it("accepts 6.2, 7.x, 8.x", () => {
|
||||
expect(checkRedisInfoCompatible("# Server\r\nredis_version:8.0.1\r\n")).toBeNull();
|
||||
expect(checkRedisInfoCompatible("redis_version:6.2.14")).toBeNull();
|
||||
expect(checkRedisInfoCompatible("redis_version:7.4.0")).toBeNull();
|
||||
});
|
||||
it("rejects < 6.2 with a SafeError carrying the version in code", () => {
|
||||
const err = checkRedisInfoCompatible("redis_version:6.0.16");
|
||||
expect(err?.message).toBe("Redis 6.2 or newer is required. Point REDIS_URL at Redis 8.");
|
||||
expect(err?.code).toBe("redis-6.0");
|
||||
const old = checkRedisInfoCompatible("redis_version:5.0.7");
|
||||
expect(old?.code).toBe("redis-5.0");
|
||||
});
|
||||
it("tolerates unparseable INFO (managed Redis)", () => {
|
||||
expect(checkRedisInfoCompatible("garbage")).toBeNull();
|
||||
expect(checkRedisInfoCompatible("")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildBeforeSend } from "../../../apps/api/src/lib/sentry-scrub.js";
|
||||
|
||||
type AnyEvent = Record<string, any>;
|
||||
const evt = (over: AnyEvent = {}): AnyEvent => ({
|
||||
message: "raw message",
|
||||
server_name: "users-macbook",
|
||||
request: { url: "http://10.0.0.5/api/x" },
|
||||
extra: { a: 1 },
|
||||
breadcrumbs: [{ message: "SELECT secret" }],
|
||||
user: { ip: "1.2.3.4" },
|
||||
contexts: {
|
||||
os: { name: "Ubuntu", version: "24.04", kernel: "x" },
|
||||
runtime: { name: "node", version: "22.1.0" },
|
||||
device: { hostname: "leak" },
|
||||
},
|
||||
tags: { tool_id: "resize", secret_tag: "leak" },
|
||||
exception: {
|
||||
values: [
|
||||
{
|
||||
type: "Error",
|
||||
value: "EACCES: permission denied, mkdir '/data/x'",
|
||||
stacktrace: {
|
||||
frames: [
|
||||
{ filename: "/app/apps/api/src/lib/cleanup.ts", abs_path: "/app/x", vars: { p: "s" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("buildBeforeSend (api)", () => {
|
||||
let send: ReturnType<typeof buildBeforeSend>;
|
||||
beforeEach(() => {
|
||||
send = buildBeforeSend(() => true);
|
||||
});
|
||||
|
||||
it("returns null when the gate is off", () => {
|
||||
expect(buildBeforeSend(() => false)(evt(), {})).toBeNull();
|
||||
});
|
||||
it("strips PII surfaces and rebuilds the value from the hint error", () => {
|
||||
const hint = {
|
||||
originalException: Object.assign(new Error("x"), { code: "EACCES", syscall: "mkdir" }),
|
||||
};
|
||||
const out = send(evt(), hint)!;
|
||||
expect(out.message).toBeUndefined();
|
||||
expect(out.server_name).toBeUndefined();
|
||||
expect(out.request).toBeUndefined();
|
||||
expect(out.extra).toBeUndefined();
|
||||
expect(out.breadcrumbs).toBeUndefined();
|
||||
expect(out.user).toBeUndefined();
|
||||
expect(out.exception.values[0].value).toBe("EACCES mkdir");
|
||||
expect(out.exception.values[0].stacktrace.frames[0].filename).toBe("cleanup.ts");
|
||||
expect(out.exception.values[0].stacktrace.frames[0].abs_path).toBeUndefined();
|
||||
expect(out.exception.values[0].stacktrace.frames[0].vars).toBeUndefined();
|
||||
});
|
||||
it("falls back to type-only for unknown errors", () => {
|
||||
const out = send(evt(), { originalException: new Error("user path /tmp/z") })!;
|
||||
expect(out.exception.values[0].value).toBe("Error");
|
||||
});
|
||||
it("applies the rebuilt value to the last (original) exception entry only", () => {
|
||||
const event = evt({
|
||||
exception: {
|
||||
values: [
|
||||
{ type: "WrapperError", value: "outer secret" },
|
||||
{ type: "Error", value: "inner secret" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const hint = { originalException: Object.assign(new Error("x"), { code: "ENOSPC" }) };
|
||||
const out = send(event, hint)!;
|
||||
expect(out.exception.values[0].value).toBe("WrapperError");
|
||||
expect(out.exception.values[1].value).toBe("ENOSPC");
|
||||
});
|
||||
it("keeps only allowlisted contexts and tags", () => {
|
||||
const out = send(evt(), {})!;
|
||||
expect(out.contexts).toEqual({
|
||||
os: { name: "Ubuntu", version: "24.04" },
|
||||
runtime: { name: "node", version: "22.1.0" },
|
||||
});
|
||||
expect(out.tags.tool_id).toBe("resize");
|
||||
expect(out.tags.secret_tag).toBeUndefined();
|
||||
});
|
||||
it("drops contexts entirely when nothing allowlisted survives", () => {
|
||||
const out = send(evt({ contexts: { device: { hostname: "leak" } } }), {})!;
|
||||
expect(out.contexts).toBeUndefined();
|
||||
});
|
||||
it("enforces the 20-events-per-hour ceiling", () => {
|
||||
for (let i = 0; i < 20; i++) expect(send(evt(), {})).not.toBeNull();
|
||||
expect(send(evt(), {})).toBeNull();
|
||||
});
|
||||
it("never throws on malformed events (fail-closed to a scrubbed event)", () => {
|
||||
expect(() => send({} as AnyEvent, {})).not.toThrow();
|
||||
expect(() => send(evt({ exception: { values: null } }), {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runSiemForward } from "../../../apps/api/src/jobs/siem-forward.js";
|
||||
|
||||
const readSiemConfig = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@snapotter/enterprise", () => ({ isFeatureEnabled: () => false }));
|
||||
vi.mock("../../../apps/api/src/routes/enterprise/siem.js", () => ({ readSiemConfig }));
|
||||
|
||||
describe("runSiemForward license gate", () => {
|
||||
it("returns before any DB read when the siem_forwarding feature is unlicensed", async () => {
|
||||
await expect(runSiemForward()).resolves.toBeUndefined();
|
||||
expect(readSiemConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toFetchResponse } from "../../../apps/api/src/lib/ssrf.js";
|
||||
|
||||
describe("toFetchResponse", () => {
|
||||
it("null-body statuses never throw, even with a body buffer", async () => {
|
||||
for (const status of [204, 304, 205]) {
|
||||
const res = toFetchResponse(Buffer.from("stray body"), status, "", new Headers());
|
||||
expect(res.status).toBe(status);
|
||||
expect(await res.text()).toBe("");
|
||||
}
|
||||
});
|
||||
it("clamps out-of-range statuses to 502", () => {
|
||||
expect(toFetchResponse(Buffer.alloc(0), 100, "", new Headers()).status).toBe(502);
|
||||
expect(toFetchResponse(Buffer.alloc(0), 700, "", new Headers()).status).toBe(502);
|
||||
expect(toFetchResponse(Buffer.alloc(0), undefined, "", new Headers()).status).toBe(502);
|
||||
});
|
||||
it("passes normal responses through", async () => {
|
||||
const res = toFetchResponse(Buffer.from("ok"), 200, "OK", new Headers({ "x-a": "b" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("ok");
|
||||
expect(res.headers.get("x-a")).toBe("b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markIfInputError } from "../../../packages/media-engine/src/ffmpeg.js";
|
||||
|
||||
type MaybeMarked = Error & { isToolInputError?: unknown };
|
||||
|
||||
describe("markIfInputError", () => {
|
||||
it("marks the error when stderr reports no packets received", () => {
|
||||
const err = new Error("ffmpeg exited 1: tail of stderr");
|
||||
const result = markIfInputError(
|
||||
err,
|
||||
"Finishing stream without any data written to it.\nOutput received no packets",
|
||||
) as MaybeMarked;
|
||||
expect(result).toBe(err);
|
||||
expect(result.isToolInputError).toBe(true);
|
||||
expect(result.message).toBe("ffmpeg exited 1: tail of stderr");
|
||||
});
|
||||
|
||||
it("marks the other known input-failure stderr shapes, case-insensitively", () => {
|
||||
const shapes = [
|
||||
"input.mp4: Invalid data found when processing input",
|
||||
"Could not find codec parameters for stream 0",
|
||||
"[mov,mp4,m4a,3gp,3g2,mj2] moov atom not found",
|
||||
"OUTPUT RECEIVED NO PACKETS",
|
||||
];
|
||||
for (const stderr of shapes) {
|
||||
const marked = markIfInputError(new Error("ffmpeg exited 1: x"), stderr) as MaybeMarked;
|
||||
expect(marked.isToolInputError, stderr).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves unrelated stderr unmarked", () => {
|
||||
const err = markIfInputError(
|
||||
new Error("ffmpeg exited 1: tail of stderr"),
|
||||
"Error while opening encoder: some libx264 build issue",
|
||||
) as MaybeMarked;
|
||||
expect(err.isToolInputError).toBeUndefined();
|
||||
expect(err.message).toBe("ffmpeg exited 1: tail of stderr");
|
||||
});
|
||||
|
||||
it("leaves empty stderr unmarked", () => {
|
||||
const err = markIfInputError(new Error("ffmpeg exited 1: "), "") as MaybeMarked;
|
||||
expect(err.isToolInputError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,14 +8,16 @@ describe("AnalyticsConfig type", () => {
|
||||
posthogApiKey: "phc_test123",
|
||||
posthogHost: "https://us.i.posthog.com",
|
||||
sentryDsn: "https://abc@sentry.io/123",
|
||||
sampleRate: 1.0,
|
||||
sentryDsnWeb: "https://abc@sentry.io/456",
|
||||
posthogSampleRate: 1.0,
|
||||
instanceId: "inst-abc-123",
|
||||
};
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.posthogApiKey).toBe("phc_test123");
|
||||
expect(config.posthogHost).toBe("https://us.i.posthog.com");
|
||||
expect(config.sentryDsn).toBe("https://abc@sentry.io/123");
|
||||
expect(config.sampleRate).toBe(1.0);
|
||||
expect(config.sentryDsnWeb).toBe("https://abc@sentry.io/456");
|
||||
expect(config.posthogSampleRate).toBe(1.0);
|
||||
expect(config.instanceId).toBe("inst-abc-123");
|
||||
});
|
||||
|
||||
@@ -25,11 +27,12 @@ describe("AnalyticsConfig type", () => {
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
sentryDsnWeb: "",
|
||||
posthogSampleRate: 0,
|
||||
instanceId: "",
|
||||
};
|
||||
expect(config.enabled).toBe(false);
|
||||
expect(config.sampleRate).toBe(0);
|
||||
expect(config.posthogSampleRate).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts fractional sample rates", () => {
|
||||
@@ -38,10 +41,11 @@ describe("AnalyticsConfig type", () => {
|
||||
posthogApiKey: "key",
|
||||
posthogHost: "https://host.com",
|
||||
sentryDsn: "https://dsn",
|
||||
sampleRate: 0.5,
|
||||
sentryDsnWeb: "https://dsn-web",
|
||||
posthogSampleRate: 0.5,
|
||||
instanceId: "id",
|
||||
};
|
||||
expect(config.sampleRate).toBe(0.5);
|
||||
expect(config.posthogSampleRate).toBe(0.5);
|
||||
});
|
||||
|
||||
it("has exactly the expected keys", () => {
|
||||
@@ -50,12 +54,21 @@ describe("AnalyticsConfig type", () => {
|
||||
posthogApiKey: "key",
|
||||
posthogHost: "host",
|
||||
sentryDsn: "dsn",
|
||||
sampleRate: 1,
|
||||
sentryDsnWeb: "dsn-web",
|
||||
posthogSampleRate: 1,
|
||||
instanceId: "id",
|
||||
};
|
||||
const keys = Object.keys(config).sort();
|
||||
expect(keys).toEqual(
|
||||
["enabled", "instanceId", "posthogApiKey", "posthogHost", "sampleRate", "sentryDsn"].sort(),
|
||||
[
|
||||
"enabled",
|
||||
"instanceId",
|
||||
"posthogApiKey",
|
||||
"posthogHost",
|
||||
"posthogSampleRate",
|
||||
"sentryDsn",
|
||||
"sentryDsnWeb",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { connectivityClass, isClientAbort, rebuildErrorValue, SafeError } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const sysErr = (code: string, syscall?: string) =>
|
||||
Object.assign(new Error(`${code}: boom /Users/secret/file.png`), { code, syscall });
|
||||
|
||||
describe("rebuildErrorValue", () => {
|
||||
it("passes SafeError messages through", () => {
|
||||
expect(rebuildErrorValue(new SafeError("Storage directory is not writable"))).toBe(
|
||||
"Storage directory is not writable",
|
||||
);
|
||||
});
|
||||
it("rebuilds node system errors as code + syscall, no path", () => {
|
||||
expect(rebuildErrorValue(sysErr("EACCES", "mkdir"))).toBe("EACCES mkdir");
|
||||
expect(rebuildErrorValue(sysErr("ENOSPC"))).toBe("ENOSPC");
|
||||
});
|
||||
it("resolves a 5-char EPIPE as a node code, not a SQLSTATE", () => {
|
||||
expect(rebuildErrorValue(sysErr("EPIPE", "write"))).toBe("EPIPE write");
|
||||
});
|
||||
it("finds a pg SQLSTATE through a drizzle-style cause chain, never the SQL", () => {
|
||||
const pg = Object.assign(new Error('relation "settings" does not exist'), {
|
||||
code: "42P01",
|
||||
severity: "ERROR",
|
||||
});
|
||||
const wrapped = Object.assign(new Error('Failed query: select "value" from "settings"'), {
|
||||
cause: pg,
|
||||
});
|
||||
expect(rebuildErrorValue(wrapped)).toBe("pg 42P01");
|
||||
});
|
||||
it("appends a vetted pg routine when present", () => {
|
||||
const pg = Object.assign(new Error("terminating connection due to administrator command"), {
|
||||
code: "57P01",
|
||||
routine: "ProcessInterrupts",
|
||||
});
|
||||
expect(rebuildErrorValue(pg)).toBe("pg 57P01 ProcessInterrupts");
|
||||
});
|
||||
it("keeps only the first token of a redis ReplyError", () => {
|
||||
const err = Object.assign(new Error("NOAUTH Authentication required secret-arg"), {
|
||||
name: "ReplyError",
|
||||
});
|
||||
expect(rebuildErrorValue(err)).toBe("reply NOAUTH");
|
||||
});
|
||||
it("returns bare reply when the ReplyError first token is not code-shaped", () => {
|
||||
const err = Object.assign(new Error("user_script:1:attempted-to-index-secret"), {
|
||||
name: "ReplyError",
|
||||
});
|
||||
expect(rebuildErrorValue(err)).toBe("reply");
|
||||
});
|
||||
it("rebuilds zod errors as issue code + path", () => {
|
||||
const err = Object.assign(new Error("big zod dump with received values"), {
|
||||
name: "ZodError",
|
||||
issues: [{ code: "invalid_type", path: ["settings", "width"] }],
|
||||
});
|
||||
expect(rebuildErrorValue(err)).toBe("zod invalid_type at settings.width");
|
||||
});
|
||||
it("replaces unvettable zod path segments with a tilde", () => {
|
||||
const err = Object.assign(new Error("dump"), {
|
||||
name: "ZodError",
|
||||
issues: [{ code: "invalid_type", path: ["files", "user secret.pdf", 0] }],
|
||||
});
|
||||
expect(rebuildErrorValue(err)).toBe("zod invalid_type at files.~.0");
|
||||
});
|
||||
it("returns null for unknown errors (caller falls back to type-only)", () => {
|
||||
expect(rebuildErrorValue(new Error("user file /tmp/x.pdf broke"))).toBeNull();
|
||||
expect(rebuildErrorValue("string")).toBeNull();
|
||||
expect(rebuildErrorValue(null)).toBeNull();
|
||||
});
|
||||
it("discards hostile names on the status branch", () => {
|
||||
const err = Object.assign(new Error("upstream said no"), {
|
||||
name: "Bad Gateway https://internal.host/path",
|
||||
status: 502,
|
||||
});
|
||||
expect(rebuildErrorValue(err)).toBe("HttpError 502");
|
||||
});
|
||||
it("returns null for a circular cause chain without hanging", () => {
|
||||
const err = new Error("loop") as Error & { cause?: unknown };
|
||||
err.cause = err;
|
||||
expect(rebuildErrorValue(err)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectivityClass", () => {
|
||||
it("classifies pg-unavailable via SQLSTATE 08/57P and drizzle wrapping", () => {
|
||||
const pg = Object.assign(new Error("terminating connection"), { code: "57P01" });
|
||||
expect(connectivityClass(Object.assign(new Error("Failed query: x"), { cause: pg }))).toBe(
|
||||
"pg-unavailable",
|
||||
);
|
||||
});
|
||||
it("classifies ECONN* under pg when the chain looks like pg, else net", () => {
|
||||
const conn = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:5432"), {
|
||||
code: "ECONNREFUSED",
|
||||
});
|
||||
expect(connectivityClass(Object.assign(new Error("Failed query: y"), { cause: conn }))).toBe(
|
||||
"pg-unavailable",
|
||||
);
|
||||
expect(connectivityClass(conn)).toBe("net-unavailable");
|
||||
});
|
||||
it("classifies ioredis connection loss as redis-unavailable", () => {
|
||||
const err = Object.assign(new Error("Connection is closed."), {
|
||||
name: "MaxRetriesPerRequestError",
|
||||
});
|
||||
expect(connectivityClass(err)).toBe("redis-unavailable");
|
||||
});
|
||||
it("returns null for ordinary errors and ReplyError", () => {
|
||||
expect(connectivityClass(new Error("nope"))).toBeNull();
|
||||
expect(
|
||||
connectivityClass(Object.assign(new Error("ERR unknown command"), { name: "ReplyError" })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClientAbort", () => {
|
||||
it("matches abort/premature-close/reset shapes", () => {
|
||||
expect(isClientAbort(Object.assign(new Error("aborted"), { code: "ECONNRESET" }))).toBe(true);
|
||||
expect(
|
||||
isClientAbort(
|
||||
Object.assign(new Error("premature close"), { code: "ERR_STREAM_PREMATURE_CLOSE" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isClientAbort(Object.assign(new Error("request aborted"), { name: "RequestAbortedError" })),
|
||||
).toBe(true);
|
||||
expect(isClientAbort(new Error("boom"))).toBe(false);
|
||||
});
|
||||
it("treats a top-level bare ECONNRESET as a client abort", () => {
|
||||
const err = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" });
|
||||
expect(isClientAbort(err)).toBe(true);
|
||||
});
|
||||
it("does not treat a drizzle-wrapped pg ECONNRESET as a client abort", () => {
|
||||
const conn = Object.assign(new Error("connect ECONNRESET 127.0.0.1:5432"), {
|
||||
code: "ECONNRESET",
|
||||
});
|
||||
const wrapped = Object.assign(new Error("Failed query: z"), { cause: conn });
|
||||
expect(isClientAbort(wrapped)).toBe(false);
|
||||
expect(connectivityClass(wrapped)).toBe("pg-unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hostile error shapes", () => {
|
||||
it("returns the fallback from all three exports when a cause getter throws", () => {
|
||||
const hostile = new Error("boom");
|
||||
Object.defineProperty(hostile, "cause", {
|
||||
get() {
|
||||
throw new Error("gotcha");
|
||||
},
|
||||
});
|
||||
expect(rebuildErrorValue(hostile)).toBeNull();
|
||||
expect(connectivityClass(hostile)).toBeNull();
|
||||
expect(isClientAbort(hostile)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
isSafeMessageError,
|
||||
isToolInputError,
|
||||
markToolInputError,
|
||||
SafeError,
|
||||
ToolInputError,
|
||||
} from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("SafeError", () => {
|
||||
it("carries a constant message, kind, and code", () => {
|
||||
const err = new SafeError("Storage directory is not writable", {
|
||||
kind: "operational",
|
||||
code: "EACCES",
|
||||
statusCode: 503,
|
||||
});
|
||||
expect(err.message).toBe("Storage directory is not writable");
|
||||
expect(err.kind).toBe("operational");
|
||||
expect(err.code).toBe("EACCES");
|
||||
expect(err.statusCode).toBe(503);
|
||||
expect(isSafeMessageError(err)).toBe(true);
|
||||
});
|
||||
it("defaults kind to operational and detects via marker not instanceof", () => {
|
||||
const copy = Object.assign(new Error("x"), { isSafeMessage: true, message: "x" });
|
||||
expect(new SafeError("m").kind).toBe("operational");
|
||||
expect(isSafeMessageError(copy)).toBe(true);
|
||||
expect(isSafeMessageError(new Error("m"))).toBe(false);
|
||||
expect(isSafeMessageError(null)).toBe(false);
|
||||
});
|
||||
it("rejects a marked plain object that is not an Error", () => {
|
||||
expect(isSafeMessageError({ isSafeMessage: true })).toBe(false);
|
||||
});
|
||||
it("leaves code and statusCode undefined by default", () => {
|
||||
const s = new SafeError("m");
|
||||
expect(s.code).toBeUndefined();
|
||||
expect(s.statusCode).toBeUndefined();
|
||||
});
|
||||
it("threads cause through to the Error constructor", () => {
|
||||
const inner = new Error("inner");
|
||||
expect(new SafeError("m", { cause: inner }).cause).toBe(inner);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolInputError", () => {
|
||||
it("is marked and 400-shaped", () => {
|
||||
const err = new ToolInputError("Column 2 must be numeric");
|
||||
expect(err.statusCode).toBe(400);
|
||||
expect(isToolInputError(err)).toBe(true);
|
||||
});
|
||||
it("markToolInputError flags a foreign error without changing its class", () => {
|
||||
const raw = new Error("ffmpeg: received no packets");
|
||||
expect(isToolInputError(raw)).toBe(false);
|
||||
markToolInputError(raw);
|
||||
expect(isToolInputError(raw)).toBe(true);
|
||||
expect(raw).toBeInstanceOf(Error);
|
||||
});
|
||||
it("rejects non-Error values", () => {
|
||||
expect(isToolInputError(null)).toBe(false);
|
||||
expect(isToolInputError("x")).toBe(false);
|
||||
});
|
||||
it("markToolInputError returns the same reference", () => {
|
||||
const e = new Error("y");
|
||||
expect(markToolInputError(e)).toBe(e);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,8 @@ const enabledConfig = {
|
||||
posthogApiKey: "phc_test",
|
||||
posthogHost: "https://ph.test",
|
||||
sentryDsn: "https://sentry.test/123",
|
||||
sampleRate: 1,
|
||||
sentryDsnWeb: "https://sentry.test/web/456",
|
||||
posthogSampleRate: 1,
|
||||
instanceId: "inst-1",
|
||||
};
|
||||
|
||||
@@ -56,7 +57,8 @@ const disabledConfig = {
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
sentryDsnWeb: "",
|
||||
posthogSampleRate: 0,
|
||||
instanceId: "",
|
||||
};
|
||||
|
||||
@@ -68,6 +70,7 @@ describe("Analytics No-Leak Invariant (baked model)", () => {
|
||||
mockPosthogInit.mockClear();
|
||||
mockCapture.mockClear();
|
||||
mockSentryInit.mockClear();
|
||||
mockBrowserTracingIntegration.mockClear();
|
||||
vi.resetModules();
|
||||
mod = await import("../../../apps/web/src/lib/analytics");
|
||||
});
|
||||
@@ -103,18 +106,75 @@ describe("Analytics No-Leak Invariant (baked model)", () => {
|
||||
expect(mockCapture).toHaveBeenCalledWith("tool_opened", { tool_id: "resize" });
|
||||
});
|
||||
|
||||
it("initAnalytics initializes Sentry when sentryDsn provided", async () => {
|
||||
it("initAnalytics initializes Sentry with the web DSN when sentryDsnWeb provided", async () => {
|
||||
await mod.initAnalytics(enabledConfig);
|
||||
expect(mockSentryInit).toHaveBeenCalledOnce();
|
||||
expect(mockSentryInit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dsn: "https://sentry.test/123",
|
||||
dsn: "https://sentry.test/web/456",
|
||||
sendDefaultPii: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("errors-only web Sentry init", () => {
|
||||
async function getSentryOptions() {
|
||||
await mod.initAnalytics(enabledConfig);
|
||||
return mockSentryInit.mock.calls[0]?.[0];
|
||||
}
|
||||
|
||||
it("passes no tracesSampleRate and never constructs a tracing integration", async () => {
|
||||
const options = await getSentryOptions();
|
||||
expect(options).toBeDefined();
|
||||
expect("tracesSampleRate" in options).toBe(false);
|
||||
expect("tracesSampler" in options).toBe(false);
|
||||
expect(mockBrowserTracingIntegration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables SDK client reports", async () => {
|
||||
const options = await getSentryOptions();
|
||||
expect(options.sendClientReports).toBe(false);
|
||||
});
|
||||
|
||||
it("filters the release-health session integration out of the defaults", async () => {
|
||||
const options = await getSentryOptions();
|
||||
const filtered = options.integrations([{ name: "BrowserSession" }, { name: "Dedupe" }]);
|
||||
expect(filtered).toEqual([{ name: "Dedupe" }]);
|
||||
});
|
||||
|
||||
it("does not init Sentry when sentryDsnWeb is empty even if sentryDsn is set", async () => {
|
||||
await mod.initAnalytics({ ...enabledConfig, sentryDsnWeb: "" });
|
||||
expect(mockSentryInit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("web-DSN-only init (no PostHog key)", () => {
|
||||
const sentryOnlyConfig = { ...enabledConfig, posthogApiKey: "", posthogHost: "" };
|
||||
|
||||
it("skips posthog.init entirely but still initializes Sentry", async () => {
|
||||
await mod.initAnalytics(sentryOnlyConfig);
|
||||
expect(mockPosthogInit).not.toHaveBeenCalled();
|
||||
expect(mockSentryInit).toHaveBeenCalledOnce();
|
||||
expect(mockSentryInit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dsn: "https://sentry.test/web/456" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps beforeSend active: error events still pass the enabled gate", async () => {
|
||||
await mod.initAnalytics(sentryOnlyConfig);
|
||||
const beforeSend = mockSentryInit.mock.calls[0]?.[0]?.beforeSend;
|
||||
expect(beforeSend).toBeDefined();
|
||||
const result = beforeSend({
|
||||
exception: { values: [{ type: "TypeError", value: "boom" }] },
|
||||
});
|
||||
// A null here would mean the enabled gate never opened for a web-DSN-only
|
||||
// bake; the event must survive (scrubbed to type-only).
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.exception.values[0].value).toBe("TypeError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PII never leaks even when analytics enabled", () => {
|
||||
it("Sentry strips the entire user object from events", async () => {
|
||||
await mod.initAnalytics(enabledConfig);
|
||||
|
||||
@@ -44,7 +44,8 @@ const enabledConfig = {
|
||||
posthogApiKey: "phc_test",
|
||||
posthogHost: "https://ph.test",
|
||||
sentryDsn: "https://sentry.test/123",
|
||||
sampleRate: 1,
|
||||
sentryDsnWeb: "https://sentry.test/web/456",
|
||||
posthogSampleRate: 1,
|
||||
instanceId: "inst-1",
|
||||
};
|
||||
|
||||
@@ -53,7 +54,8 @@ const disabledConfig = {
|
||||
posthogApiKey: "key",
|
||||
posthogHost: "https://ph.test",
|
||||
sentryDsn: "",
|
||||
sampleRate: 1,
|
||||
sentryDsnWeb: "",
|
||||
posthogSampleRate: 1,
|
||||
instanceId: "inst-1",
|
||||
};
|
||||
|
||||
@@ -91,19 +93,19 @@ describe("analytics lib (baked model)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("initializes Sentry when sentryDsn is provided", async () => {
|
||||
it("initializes Sentry with the web DSN when sentryDsnWeb is provided", async () => {
|
||||
await mod.initAnalytics(enabledConfig);
|
||||
expect(mockSentryInit).toHaveBeenCalledOnce();
|
||||
expect(mockSentryInit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dsn: "https://sentry.test/123",
|
||||
dsn: "https://sentry.test/web/456",
|
||||
sendDefaultPii: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips Sentry when sentryDsn is empty", async () => {
|
||||
await mod.initAnalytics({ ...enabledConfig, sentryDsn: "" });
|
||||
it("skips Sentry when sentryDsnWeb is empty", async () => {
|
||||
await mod.initAnalytics({ ...enabledConfig, sentryDsnWeb: "" });
|
||||
expect(mockSentryInit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// The web Sentry scrubber treats native error classes (TypeError, RangeError,
|
||||
// and friends) as browser/runtime faults: their messages pass through with
|
||||
// redaction instead of being replaced wholesale. That is only safe while our
|
||||
// own code never throws those classes, so own-code throws must stay plain
|
||||
// Error (or an app-specific subclass). This turns that grep into an invariant.
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const TREES = ["apps/web/src", "packages/shared/src"];
|
||||
const NATIVE_THROW =
|
||||
/throw\s+new\s+(TypeError|RangeError|SyntaxError|ReferenceError|DOMException)\s*\(/;
|
||||
|
||||
function sourceFiles(tree: string): string[] {
|
||||
return readdirSync(join(ROOT, tree), { recursive: true })
|
||||
.map(String)
|
||||
.filter((p) => (p.endsWith(".ts") || p.endsWith(".tsx")) && !p.includes(".test."))
|
||||
.map((p) => join(tree, p));
|
||||
}
|
||||
|
||||
describe("no native error-class throws in web/shared source", () => {
|
||||
const files = TREES.flatMap(sourceFiles);
|
||||
|
||||
it("finds source files", () => {
|
||||
expect(files.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("own code throws plain Error, never native error classes", () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of files) {
|
||||
const lines = readFileSync(join(ROOT, file), "utf-8").split("\n");
|
||||
lines.forEach((line, i) => {
|
||||
if (NATIVE_THROW.test(line)) violations.push(`${file}:${i + 1}`);
|
||||
});
|
||||
}
|
||||
expect(violations, "use plain Error so the Sentry scrubber fully redacts").toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWebBeforeSend,
|
||||
DENY_URLS,
|
||||
IGNORE_ERRORS,
|
||||
scrubBrowserMessage,
|
||||
} from "@/lib/sentry-scrub";
|
||||
|
||||
describe("scrubBrowserMessage", () => {
|
||||
it("keeps browser-native messages with urls/paths redacted", () => {
|
||||
expect(scrubBrowserMessage("TypeError", "Failed to fetch https://intra.host/x?q=1")).toBe(
|
||||
"Failed to fetch <url>",
|
||||
);
|
||||
expect(scrubBrowserMessage("TypeError", "cannot read /Users/bob/file.png")).toBe(
|
||||
"cannot read <path>",
|
||||
);
|
||||
expect(scrubBrowserMessage("RangeError", "Invalid array length")).toBe("Invalid array length");
|
||||
});
|
||||
|
||||
it("redacts blob urls and windows paths", () => {
|
||||
expect(scrubBrowserMessage("DOMException", "load blob:http://x/abc failed")).toBe(
|
||||
"load <blob> failed",
|
||||
);
|
||||
expect(scrubBrowserMessage("TypeError", "open C:\\Users\\bob\\tax.pdf")).toBe("open <path>");
|
||||
});
|
||||
|
||||
it("drops messages for non-native error names", () => {
|
||||
expect(scrubBrowserMessage("CustomerDataError", "contains secret.pdf")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("static filter lists", () => {
|
||||
it("deny extension frames and ignore noisy network errors", () => {
|
||||
expect(DENY_URLS.some((re) => re.test("chrome-extension://abcdef/content.js"))).toBe(true);
|
||||
expect(DENY_URLS.some((re) => re.test("moz-extension://abcdef/content.js"))).toBe(true);
|
||||
expect(IGNORE_ERRORS).toContain("Failed to fetch");
|
||||
expect(IGNORE_ERRORS).toContain("Load failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWebBeforeSend", () => {
|
||||
const baseEvent = (): Record<string, any> => ({
|
||||
request: { url: "http://192.168.0.4:1349/image/resize" },
|
||||
breadcrumbs: [{}],
|
||||
user: { id: "x" },
|
||||
contexts: { react: { componentStack: "at ToolPage" }, device: { name: "leak" } },
|
||||
exception: {
|
||||
values: [
|
||||
{
|
||||
type: "TypeError",
|
||||
value: "secret /Users/a/b",
|
||||
stacktrace: { frames: [{ filename: "http://192.168.0.4:1349/assets/app.js" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
tags: { tool_id: "resize", drop_me: "x" },
|
||||
});
|
||||
|
||||
it("gates, strips, keeps react componentStack, redacts values", () => {
|
||||
const send = buildWebBeforeSend(() => true);
|
||||
const out = send(baseEvent(), { originalException: new TypeError("boom /Users/a/b") })!;
|
||||
expect(out.request).toBeUndefined();
|
||||
expect(out.breadcrumbs).toBeUndefined();
|
||||
expect(out.user).toBeUndefined();
|
||||
expect(out.contexts.react.componentStack).toBe("at ToolPage");
|
||||
expect(out.contexts.device).toBeUndefined();
|
||||
expect(out.exception.values[0].value).toBe("boom <path>");
|
||||
expect(out.exception.values[0].stacktrace.frames[0].filename).toBe("/assets/app.js");
|
||||
expect(out.tags.tool_id).toBe("resize");
|
||||
expect(out.tags.drop_me).toBeUndefined();
|
||||
expect(buildWebBeforeSend(() => false)(baseEvent(), {})).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to type-only for non-native exceptions without a rebuild", () => {
|
||||
const send = buildWebBeforeSend(() => true);
|
||||
const custom = Object.assign(new Error("user secret"), { name: "WeirdLibError" });
|
||||
const out = send(baseEvent(), { originalException: custom })!;
|
||||
expect(out.exception.values[0].value).toBe("TypeError");
|
||||
});
|
||||
|
||||
it("enforces the 20-per-hour ceiling", () => {
|
||||
const send = buildWebBeforeSend(() => true);
|
||||
for (let i = 0; i < 20; i++) expect(send(baseEvent(), {})).not.toBeNull();
|
||||
expect(send(baseEvent(), {})).toBeNull();
|
||||
});
|
||||
|
||||
it("never throws on malformed events", () => {
|
||||
const send = buildWebBeforeSend(() => true);
|
||||
expect(() => send({} as Record<string, any>, {})).not.toThrow();
|
||||
expect(() => send({ exception: { values: null } } as any, {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,8 @@ beforeEach(() => {
|
||||
posthogApiKey: "phc_test",
|
||||
posthogHost: "https://us.i.posthog.com",
|
||||
sentryDsn: "",
|
||||
sampleRate: 1,
|
||||
sentryDsnWeb: "",
|
||||
posthogSampleRate: 1,
|
||||
instanceId: "instance-1",
|
||||
},
|
||||
});
|
||||
@@ -70,7 +71,8 @@ describe("ToolFeedbackPrompt", () => {
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
sentryDsnWeb: "",
|
||||
posthogSampleRate: 0,
|
||||
instanceId: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeRandomUUID } from "@/lib/uuid";
|
||||
|
||||
const V4_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
describe("safeRandomUUID", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns v4-shaped uuids", () => {
|
||||
const id = safeRandomUUID();
|
||||
expect(id).toMatch(V4_SHAPE);
|
||||
expect(safeRandomUUID()).not.toBe(id);
|
||||
});
|
||||
|
||||
it("works when crypto.randomUUID is unavailable (insecure context)", () => {
|
||||
// Simulate a plain-http origin, where the browser exposes getRandomValues
|
||||
// but not randomUUID. `delete crypto.randomUUID` would be a silent no-op
|
||||
// here (the method lives on Crypto.prototype, not as an own property), so
|
||||
// stub the global with a clone that genuinely lacks it.
|
||||
const getRandomValues = crypto.getRandomValues.bind(crypto);
|
||||
vi.stubGlobal("crypto", { getRandomValues });
|
||||
expect(crypto.randomUUID).toBeUndefined();
|
||||
|
||||
const id = safeRandomUUID();
|
||||
expect(id).toMatch(V4_SHAPE);
|
||||
expect(safeRandomUUID()).not.toBe(id);
|
||||
});
|
||||
});
|
||||
@@ -2722,7 +2722,8 @@ describe("useAnalyticsStore", () => {
|
||||
posthogApiKey: "phc_test",
|
||||
posthogHost: "https://ph.example.com",
|
||||
sentryDsn: "https://sentry.example.com",
|
||||
sampleRate: 0.5,
|
||||
sentryDsnWeb: "https://sentry.example.com/web",
|
||||
posthogSampleRate: 0.5,
|
||||
instanceId: "inst-123",
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
|
||||
Reference in New Issue
Block a user