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 ---
|
# --- Analytics ---
|
||||||
# Basic analytics are included by default. SnapOtter works normally without them.
|
# Basic analytics are included by default. SnapOtter works normally without them.
|
||||||
# To disable: docker compose build --build-arg SNAPOTTER_ANALYTICS=off
|
# 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.
|
# One-time SQLite import on first boot (1.x upgrade path). Leave unset normally.
|
||||||
# SQLITE_MIGRATE_PATH=/data/snapotter.db
|
# SQLITE_MIGRATE_PATH=/data/snapotter.db
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ jobs:
|
|||||||
SNAPOTTER_ANALYTICS=on
|
SNAPOTTER_ANALYTICS=on
|
||||||
SNAPOTTER_POSTHOG_PROJECT_ID=${{ secrets.SNAPOTTER_POSTHOG_KEY }}
|
SNAPOTTER_POSTHOG_PROJECT_ID=${{ secrets.SNAPOTTER_POSTHOG_KEY }}
|
||||||
SNAPOTTER_SENTRY_DSN=${{ secrets.SNAPOTTER_SENTRY_DSN }}
|
SNAPOTTER_SENTRY_DSN=${{ secrets.SNAPOTTER_SENTRY_DSN }}
|
||||||
|
SNAPOTTER_SENTRY_DSN_WEB=${{ secrets.SNAPOTTER_SENTRY_DSN_WEB }}
|
||||||
SENTRY_RELEASE=${{ needs.release.outputs.new_version }}
|
SENTRY_RELEASE=${{ needs.release.outputs.new_version }}
|
||||||
secrets: |
|
secrets: |
|
||||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
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 rateLimit from "@fastify/rate-limit";
|
||||||
import { trace } from "@opentelemetry/api";
|
import { trace } from "@opentelemetry/api";
|
||||||
import { getDispatcherStatus, initDispatcher, isGpuAvailable } from "@snapotter/ai";
|
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 { eq, sql } from "drizzle-orm";
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import { env } from "./config.js";
|
import { env } from "./config.js";
|
||||||
import { closeDb, db, schema } from "./db/index.js";
|
import { closeDb, db, schema } from "./db/index.js";
|
||||||
import { runMigrations } from "./db/migrate.js";
|
import { runMigrations } from "./db/migrate.js";
|
||||||
import { startCancelListener, stopCancelListener } from "./jobs/cancel.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 { closeFlowProducer, closeQueueEvents, warmQueueEvents } from "./jobs/enqueue.js";
|
||||||
import { closeQueues, perPoolHealth, queueCounts } from "./jobs/queues.js";
|
import { closeQueues, perPoolHealth, queueCounts } from "./jobs/queues.js";
|
||||||
import { enqueueSystemJob, SYSTEM_JOBS, scheduleSystemJobs } from "./jobs/system-jobs.js";
|
import { enqueueSystemJob, SYSTEM_JOBS, scheduleSystemJobs } from "./jobs/system-jobs.js";
|
||||||
import { closeWorkers, startWorkers } from "./jobs/worker.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 { shouldRunStartupCleanup } from "./lib/cleanup.js";
|
||||||
import { buildCsp } from "./lib/csp.js";
|
import { buildCsp } from "./lib/csp.js";
|
||||||
|
import { reportError } from "./lib/error-report.js";
|
||||||
import { stripInternalPaths } from "./lib/errors.js";
|
import { stripInternalPaths } from "./lib/errors.js";
|
||||||
import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js";
|
import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js";
|
||||||
import { logger } from "./lib/logger.js";
|
import { logger } from "./lib/logger.js";
|
||||||
@@ -89,6 +90,16 @@ try {
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
process.exit(1);
|
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");
|
console.log("Redis connected");
|
||||||
|
|
||||||
// Verify the local storage directories are writable before serving. A non-root
|
// 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 },
|
{ err: error, url: request.url, method: request.method },
|
||||||
"Unhandled request error",
|
"Unhandled request error",
|
||||||
);
|
);
|
||||||
captureException(error);
|
void reportError(error, {
|
||||||
|
source: "http",
|
||||||
|
route: request.routeOptions?.url ?? undefined,
|
||||||
|
method: request.method,
|
||||||
|
statusCode,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
request.log.warn({ err: error, url: request.url, method: request.method }, "Request error");
|
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 { 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
|
// 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,
|
// 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.
|
// so an opted-out instance never reports even a boot-window crash.
|
||||||
const sentryActive = () => gatePrimed() && analyticsEnabled();
|
const sentryActive = () => gatePrimed() && analyticsEnabled();
|
||||||
|
|
||||||
// Collapse any absolute path in a stack frame filename to its basename, so
|
// All-in-one detection: docker/entrypoint.sh exports EMBEDDED_MODE=1 before
|
||||||
// even our own source paths never carry a workspace or job directory.
|
// exec'ing s6-overlay, and the snapotter service run script is with-contenv,
|
||||||
function basename(p: string): string {
|
// so the marker reaches this process. URL absence is not a usable signal:
|
||||||
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
// embedded mode sets loopback DATABASE_URL/REDIS_URL before boot, and native
|
||||||
return i >= 0 ? p.slice(i + 1) : p;
|
// 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 {
|
try {
|
||||||
const Sentry = await import("@sentry/node");
|
const Sentry = await import("@sentry/node");
|
||||||
const { APP_VERSION } = await import("@snapotter/shared");
|
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.
|
// attribute to a build; falls back to APP_VERSION for non-image runs.
|
||||||
const release = process.env.SENTRY_RELEASE || APP_VERSION;
|
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({
|
Sentry.init({
|
||||||
dsn: ANALYTICS_BAKED.sentryDsn,
|
dsn: ANALYTICS_BAKED.sentryDsn,
|
||||||
release,
|
release,
|
||||||
environment: process.env.NODE_ENV || "production",
|
environment: process.env.SNAPOTTER_ENV || "production",
|
||||||
tracesSampleRate: ANALYTICS_BAKED.sampleRate,
|
|
||||||
sendDefaultPii: false,
|
sendDefaultPii: false,
|
||||||
// Release-health request-sessions and client-report envelopes are sent outside
|
// Errors only. No traces options are set at all, so the SDK never
|
||||||
// beforeSend/beforeSendTransaction, so the runtime opt-out below would not stop
|
// starts traces and BullMQ/pg idle polling can't become transactions
|
||||||
// them. Disable both so an opted-out instance truly stops phoning home.
|
// again (the July 2026 quota incident).
|
||||||
integrations: [Sentry.httpIntegration({ trackIncomingRequestsAsSessions: false })],
|
integrations: [Sentry.httpIntegration({ trackIncomingRequestsAsSessions: false })],
|
||||||
sendClientReports: false,
|
sendClientReports: false,
|
||||||
// Runtime opt-out: drop the whole transaction when analytics is off.
|
maxBreadcrumbs: 0,
|
||||||
tracesSampler: () => (sentryActive() ? ANALYTICS_BAKED.sampleRate : 0),
|
beforeBreadcrumb: () => null,
|
||||||
beforeSend(event) {
|
initialScope: { tags: { deploy_mode: deployMode() } },
|
||||||
if (!sentryActive()) return null; // kill switch (covers auto-captured errors)
|
beforeSend: buildBeforeSend(sentryActive) as unknown as SentryOptions["beforeSend"],
|
||||||
// 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;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[sentry] initialized, release:", release);
|
console.log("[sentry] initialized (errors only), release:", release);
|
||||||
} catch {
|
} catch {
|
||||||
// @sentry/node not available
|
// @sentry/node not available
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,25 @@ import { env } from "../config.js";
|
|||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
import { getSettingString } from "../lib/settings-helpers.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> {
|
export async function evaluateAlerts(): Promise<void> {
|
||||||
|
if (!(await alertsLicensed())) return;
|
||||||
|
|
||||||
// 1. Read webhook destinations from settings
|
// 1. Read webhook destinations from settings
|
||||||
const destJson = await getSettingString("webhook_destinations", "[]");
|
const destJson = await getSettingString("webhook_destinations", "[]");
|
||||||
let destinations: { url: string; authHeader: string; enabled: boolean; type: string }[];
|
let destinations: { url: string; authHeader: string; enabled: boolean; type: string }[];
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* Uses ioredis with settings compatible with BullMQ's requirements
|
* Uses ioredis with settings compatible with BullMQ's requirements
|
||||||
* (maxRetriesPerRequest: null for blocking commands).
|
* (maxRetriesPerRequest: null for blocking commands).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { SafeError } from "@snapotter/shared";
|
||||||
import type { ConnectionOptions } from "bullmq";
|
import type { ConnectionOptions } from "bullmq";
|
||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
@@ -64,3 +66,29 @@ export async function closeRedis(): Promise<void> {
|
|||||||
_shared = null;
|
_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;
|
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> {
|
export async function runSiemForward(): Promise<{ forwarded: number } | undefined> {
|
||||||
|
if (!(await siemLicensed())) return;
|
||||||
|
|
||||||
// 1. Read SIEM config
|
// 1. Read SIEM config
|
||||||
const config = await readSiemConfig();
|
const config = await readSiemConfig();
|
||||||
if (!config?.enabled || !config.webhookUrl) {
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { context, propagation, ROOT_CONTEXT, SpanStatusCode, trace } from "@opentelemetry/api";
|
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 { type Job, UnrecoverableError, Worker } from "bullmq";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import { db, schema } from "../db/index.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 { analyticsEnabled } from "../lib/analytics-gate.js";
|
||||||
import { resolveConcurrency } from "../lib/env.js";
|
import { resolveConcurrency } from "../lib/env.js";
|
||||||
|
import { reportError } from "../lib/error-report.js";
|
||||||
import { friendlyError } from "../lib/errors.js";
|
import { friendlyError } from "../lib/errors.js";
|
||||||
import { logger } from "../lib/logger.js";
|
import { logger } from "../lib/logger.js";
|
||||||
import { jobDuration, jobsTotal } from "../lib/metrics.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
|
// friendlyError(finalError)). Expected validation rejections -- bad user
|
||||||
// input, not a server fault -- would otherwise flood error logs, so skip
|
// input, not a server fault -- would otherwise flood error logs, so skip
|
||||||
// them here; they still reach the OTel span recorded below.
|
// 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) {
|
if (!isCanceled && !isTimeout && !isValidationError) {
|
||||||
logger.error({ err, jobId, toolId: data.toolId }, "tool job failed");
|
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
|
// Record error on the OTel span
|
||||||
if (span) {
|
if (span) {
|
||||||
span.setStatus({ code: SpanStatusCode.ERROR, message: finalError });
|
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");
|
span.addEvent("job.failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,9 +449,6 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
|||||||
},
|
},
|
||||||
data.analyticsDistinctId,
|
data.analyticsDistinctId,
|
||||||
);
|
);
|
||||||
if (!isCanceled && !isTimeout) {
|
|
||||||
void captureException(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCanceled) throw new UnrecoverableError("Canceled");
|
if (isCanceled) throw new UnrecoverableError("Canceled");
|
||||||
@@ -888,9 +887,12 @@ export function startWorkers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.on("failed", (job, err) => {
|
worker.on("failed", (job, err) => {
|
||||||
if (analyticsEnabled() && job) {
|
if (!job) return;
|
||||||
void captureException(err instanceof Error ? err : new Error(String(err)));
|
void reportError(err, {
|
||||||
}
|
source: "worker",
|
||||||
|
pool,
|
||||||
|
toolId: (job.data as ToolJobData | undefined)?.toolId,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
workers.push(worker);
|
workers.push(worker);
|
||||||
@@ -916,9 +918,12 @@ export function startWorkers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.on("failed", (job, err) => {
|
worker.on("failed", (job, err) => {
|
||||||
if (analyticsEnabled() && job) {
|
if (!job) return;
|
||||||
void captureException(err instanceof Error ? err : new Error(String(err)));
|
void reportError(err, {
|
||||||
}
|
source: "worker",
|
||||||
|
pool,
|
||||||
|
toolId: (job.data as ToolJobData | undefined)?.toolId,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
workers.push(worker);
|
workers.push(worker);
|
||||||
|
|||||||
@@ -25,8 +25,15 @@ async function defaultReader(): Promise<boolean | undefined> {
|
|||||||
return rows[0].value !== "false";
|
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. */
|
/** Compile-time bake, with a NON-PRODUCTION-only override so tests can force it on. */
|
||||||
export function bakedEnabled(): boolean {
|
export function bakedEnabled(): boolean {
|
||||||
|
if (telemetryEnvKilled()) return false;
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
const o = process.env.ANALYTICS_BAKED_OVERRIDE;
|
const o = process.env.ANALYTICS_BAKED_OVERRIDE;
|
||||||
if (o === "on") return true;
|
if (o === "on") return true;
|
||||||
|
|||||||
@@ -59,13 +59,10 @@ export async function initAnalytics(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function captureException(error: unknown): Promise<void> {
|
export async function captureException(error: unknown): Promise<void> {
|
||||||
try {
|
// Deprecated shim: route through the classified path. New code calls
|
||||||
if (!analyticsEnabled()) return;
|
// reportError directly with a source.
|
||||||
const Sentry = await import("@sentry/node");
|
const { reportError } = await import("./error-report.js");
|
||||||
Sentry.captureException(error);
|
await reportError(error, { source: "boot" });
|
||||||
} catch {
|
|
||||||
// analytics must never throw
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shutdownAnalytics(): Promise<void> {
|
export async function shutdownAnalytics(): Promise<void> {
|
||||||
@@ -90,8 +87,13 @@ export async function trackEvent(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!analyticsEnabled() || !posthogClient) return;
|
if (!analyticsEnabled() || !posthogClient) return;
|
||||||
if (ANALYTICS_BAKED.sampleRate < 1.0) {
|
if (ANALYTICS_BAKED.posthogSampleRate < 1.0) {
|
||||||
if (ANALYTICS_BAKED.sampleRate <= 0.0 || Math.random() >= ANALYTICS_BAKED.sampleRate) return;
|
if (
|
||||||
|
ANALYTICS_BAKED.posthogSampleRate <= 0.0 ||
|
||||||
|
Math.random() >= ANALYTICS_BAKED.posthogSampleRate
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
posthogClient.capture({
|
posthogClient.capture({
|
||||||
distinctId: distinctId ?? (await getInstanceId()),
|
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("finish", () => res());
|
||||||
extractor.on("error", rej);
|
extractor.on("error", rej);
|
||||||
stream.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
|
// 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 { extname, join } from "node:path";
|
||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
import type { S3StorageModule } from "@snapotter/enterprise";
|
import type { S3StorageModule } from "@snapotter/enterprise";
|
||||||
|
import { SafeError } from "@snapotter/shared";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
|
|
||||||
const MIN_FREE_BYTES = 100 * 1024 * 1024;
|
const MIN_FREE_BYTES = 100 * 1024 * 1024;
|
||||||
@@ -13,9 +14,12 @@ async function assertDiskSpace(dir: string): Promise<void> {
|
|||||||
const stats = await statfs(dir);
|
const stats = await statfs(dir);
|
||||||
const freeBytes = stats.bfree * stats.bsize;
|
const freeBytes = stats.bfree * stats.bsize;
|
||||||
if (freeBytes < MIN_FREE_BYTES) {
|
if (freeBytes < MIN_FREE_BYTES) {
|
||||||
const err = new Error("Insufficient disk space") as Error & { statusCode: number };
|
// ENOSPC here is synthesized from the free-space floor check, not a syscall errno.
|
||||||
err.statusCode = 507;
|
throw new SafeError("Insufficient disk space", {
|
||||||
throw err;
|
kind: "operational",
|
||||||
|
code: "ENOSPC",
|
||||||
|
statusCode: 507,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && (e as Error & { statusCode?: number }).statusCode === 507) throw 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 });
|
await mkdir(env.FILES_STORAGE_PATH, { recursive: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && (e as NodeJS.ErrnoException).code === "EACCES") {
|
if (e instanceof Error && (e as NodeJS.ErrnoException).code === "EACCES") {
|
||||||
const err = new Error("Storage directory is not writable") as Error & {
|
throw new SafeError("Storage directory is not writable", {
|
||||||
statusCode: number;
|
kind: "operational",
|
||||||
};
|
code: (e as NodeJS.ErrnoException).code,
|
||||||
err.statusCode = 503;
|
statusCode: 503,
|
||||||
throw err;
|
});
|
||||||
}
|
}
|
||||||
throw e;
|
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);
|
await writeFile(join(env.FILES_STORAGE_PATH, storedName), buffer);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && (e as NodeJS.ErrnoException).code === "EACCES") {
|
if (e instanceof Error && (e as NodeJS.ErrnoException).code === "EACCES") {
|
||||||
const err = new Error("Storage directory is not writable") as Error & {
|
throw new SafeError("Storage directory is not writable", {
|
||||||
statusCode: number;
|
kind: "operational",
|
||||||
};
|
code: (e as NodeJS.ErrnoException).code,
|
||||||
err.statusCode = 503;
|
statusCode: 503,
|
||||||
throw err;
|
});
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -185,7 +189,11 @@ async function ensureThumbDir(): Promise<void> {
|
|||||||
await mkdir(join(env.FILES_STORAGE_PATH, THUMB_DIR), { recursive: true });
|
await mkdir(join(env.FILES_STORAGE_PATH, THUMB_DIR), { recursive: true });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if ((err as NodeJS.ErrnoException).code === "EACCES") {
|
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;
|
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;
|
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 {
|
function withResponseSizeLimit(response: Response, maxBytes?: number): Response {
|
||||||
if (maxBytes === undefined || !response.body) return 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);
|
for (const v of vals) headers.append(key, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolve(
|
try {
|
||||||
new Response(body, {
|
resolve(
|
||||||
status: incomingMessage.statusCode ?? 500,
|
toFetchResponse(
|
||||||
statusText: incomingMessage.statusMessage ?? "",
|
body,
|
||||||
headers,
|
incomingMessage.statusCode,
|
||||||
}),
|
incomingMessage.statusMessage ?? "",
|
||||||
);
|
headers,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
incomingMessage.on("error", (err) => {
|
incomingMessage.on("error", (err) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
|
|||||||
@@ -6414,7 +6414,9 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
sentryDsn:
|
sentryDsn:
|
||||||
type: string
|
type: string
|
||||||
sampleRate:
|
sentryDsnWeb:
|
||||||
|
type: string
|
||||||
|
posthogSampleRate:
|
||||||
type: number
|
type: number
|
||||||
instanceId:
|
instanceId:
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@@ -1100,8 +1100,19 @@ function isPublicRoute(url: string): boolean {
|
|||||||
return PUBLIC_PATHS.some((path) => url.startsWith(path));
|
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> {
|
export async function authMiddleware(app: FastifyInstance): Promise<void> {
|
||||||
app.addHook("preHandler", async (request: FastifyRequest, reply: FastifyReply) => {
|
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) {
|
if (!env.AUTH_ENABLED) {
|
||||||
(request as FastifyRequest & { user?: AuthUser }).user = {
|
(request as FastifyRequest & { user?: AuthUser }).user = {
|
||||||
id: "anonymous",
|
id: "anonymous",
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export async function analyticsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
posthogApiKey: "",
|
posthogApiKey: "",
|
||||||
posthogHost: "",
|
posthogHost: "",
|
||||||
sentryDsn: "",
|
sentryDsn: "",
|
||||||
sampleRate: 0,
|
sentryDsnWeb: "",
|
||||||
|
posthogSampleRate: 0,
|
||||||
instanceId: "",
|
instanceId: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -28,7 +29,8 @@ export async function analyticsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
posthogApiKey: ANALYTICS_BAKED.posthogApiKey,
|
posthogApiKey: ANALYTICS_BAKED.posthogApiKey,
|
||||||
posthogHost: ANALYTICS_BAKED.posthogHost,
|
posthogHost: ANALYTICS_BAKED.posthogHost,
|
||||||
sentryDsn: ANALYTICS_BAKED.sentryDsn,
|
sentryDsn: ANALYTICS_BAKED.sentryDsn,
|
||||||
sampleRate: ANALYTICS_BAKED.sampleRate,
|
sentryDsnWeb: ANALYTICS_BAKED.sentryDsnWeb,
|
||||||
|
posthogSampleRate: ANALYTICS_BAKED.posthogSampleRate,
|
||||||
instanceId: row?.value ?? "",
|
instanceId: row?.value ?? "",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -238,7 +238,9 @@ function ensureSubscriber(): void {
|
|||||||
sseSubscriber.on("error", (err) => {
|
sseSubscriber.on("error", (err) => {
|
||||||
console.error("SSE progress subscriber 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) => {
|
sseSubscriber.on("message", (_channel: string, message: string) => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(message) as { jobId?: string };
|
const parsed = JSON.parse(message) as { jobId?: string };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ToolInputError } from "@snapotter/shared";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import Papa from "papaparse";
|
import Papa from "papaparse";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
@@ -207,26 +208,26 @@ export function registerChartMaker(app: FastifyInstance) {
|
|||||||
try {
|
try {
|
||||||
data = parseInput(input.buffer, input.filename);
|
data = parseInput(input.buffer, input.filename);
|
||||||
} catch (err) {
|
} 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) {
|
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) {
|
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
|
// Validate numeric values
|
||||||
for (const point of data) {
|
for (const point of data) {
|
||||||
if (Number.isNaN(point.value)) {
|
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,
|
// Negative values render as invalid/degenerate SVG (negative bar heights,
|
||||||
// backward pie arcs that Sharp silently drops); reject with a clear message.
|
// backward pie arcs that Sharp silently drops); reject with a clear message.
|
||||||
if (data.some((point) => point.value < 0)) {
|
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;
|
let svg: string;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function presetSchema(base: string) {
|
|||||||
* own parameterized registrars.
|
* own parameterized registrars.
|
||||||
*/
|
*/
|
||||||
export function registerConversionPresets(app: FastifyInstance): number {
|
export function registerConversionPresets(app: FastifyInstance): number {
|
||||||
|
let skipped = 0;
|
||||||
for (const preset of CONVERSION_PRESETS) {
|
for (const preset of CONVERSION_PRESETS) {
|
||||||
const cfg = BASE_CONFIG[preset.base];
|
const cfg = BASE_CONFIG[preset.base];
|
||||||
if (cfg.group === "image-to-pdf") {
|
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.
|
// group "registry": delegate to the base tool's processV2 with locked settings merged in.
|
||||||
const baseConfig = getToolConfig(preset.base);
|
const baseConfig = getToolConfig(preset.base);
|
||||||
if (!baseConfig) {
|
if (!baseConfig) {
|
||||||
throw new Error(
|
// A missing base must degrade to a disabled preset, not a boot crash:
|
||||||
`Preset "${preset.id}" base "${preset.base}" is not registered in the tool registry`,
|
// 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, {
|
createToolRoute(app, {
|
||||||
toolId: preset.id,
|
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: "",
|
posthogApiKey: "",
|
||||||
posthogHost: "",
|
posthogHost: "",
|
||||||
sentryDsn: "",
|
sentryDsn: "",
|
||||||
sampleRate: 0,
|
sentryDsnWeb: "",
|
||||||
|
posthogSampleRate: 0,
|
||||||
instanceId: "",
|
instanceId: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ You will be asked to change your password on first login.
|
|||||||
::: tip Anonymous Product Analytics
|
::: 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.
|
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).
|
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 });
|
track(ANALYTICS_EVENTS.TOOL_CLIENT_ERROR, { error_name: error.name });
|
||||||
import("@sentry/react")
|
import("@sentry/react")
|
||||||
.then((Sentry) => {
|
.then((Sentry) => {
|
||||||
Sentry.captureException(error);
|
Sentry.captureReactException(error, info);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export function WaveformPlayer({ src, className }: WaveformPlayerProps) {
|
|||||||
|
|
||||||
wsRef.current = ws;
|
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", () => {
|
ws.on("ready", () => {
|
||||||
readyRef.current = true;
|
readyRef.current = true;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "re
|
|||||||
import { useTranslation } from "@/contexts/i18n-context";
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { toNormalizedRect } from "@/lib/sign-geometry";
|
import { toNormalizedRect } from "@/lib/sign-geometry";
|
||||||
import type { SavedSignature } from "@/lib/signature-store";
|
import type { SavedSignature } from "@/lib/signature-store";
|
||||||
|
import { safeRandomUUID } from "@/lib/uuid";
|
||||||
|
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||||
@@ -90,7 +91,11 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
|
|||||||
setPageCount(doc.numPages);
|
setPageCount(doc.numPages);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
setDocReady(true);
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
docRef.current?.loadingTask.destroy();
|
docRef.current?.loadingTask.destroy();
|
||||||
@@ -181,7 +186,10 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
|
|||||||
}
|
}
|
||||||
layer.batchDraw();
|
layer.batchDraw();
|
||||||
onSelectionChange?.(false);
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
@@ -241,7 +249,7 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
|
|||||||
layer.add(node);
|
layer.add(node);
|
||||||
tr.nodes([node]);
|
tr.nodes([node]);
|
||||||
layer.batchDraw();
|
layer.batchDraw();
|
||||||
placementsRef.current.push({ id: crypto.randomUUID(), page, node });
|
placementsRef.current.push({ id: safeRandomUUID(), page, node });
|
||||||
onSelectionChange?.(true);
|
onSelectionChange?.(true);
|
||||||
emitCount();
|
emitCount();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type SavedSignature,
|
type SavedSignature,
|
||||||
} from "@/lib/signature-store";
|
} from "@/lib/signature-store";
|
||||||
import { generateId } from "@/lib/utils";
|
import { generateId } from "@/lib/utils";
|
||||||
|
import { safeRandomUUID } from "@/lib/uuid";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import type { SignCanvasRef } from "./sign-canvas";
|
import type { SignCanvasRef } from "./sign-canvas";
|
||||||
import { SignaturePad } from "./signature-pad";
|
import { SignaturePad } from "./signature-pad";
|
||||||
@@ -126,7 +127,7 @@ export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
|
|||||||
const handleSavePad = (dataUrl: string, remember: boolean) => {
|
const handleSavePad = (dataUrl: string, remember: boolean) => {
|
||||||
const sig: SavedSignature = remember
|
const sig: SavedSignature = remember
|
||||||
? addSignature(dataUrl)
|
? addSignature(dataUrl)
|
||||||
: { id: crypto.randomUUID(), dataUrl, createdAt: Date.now() };
|
: { id: safeRandomUUID(), dataUrl, createdAt: Date.now() };
|
||||||
if (remember) refresh();
|
if (remember) refresh();
|
||||||
signProps?.canvasRef.current?.addSignature(sig);
|
signProps?.canvasRef.current?.addSignature(sig);
|
||||||
setPadOpen(false);
|
setPadOpen(false);
|
||||||
|
|||||||
@@ -6,21 +6,6 @@ let posthog: PostHogInstance | null = null;
|
|||||||
let initialized = false;
|
let initialized = false;
|
||||||
let enabled = false; // live runtime flag; gates track() and ErrorBoundary capture
|
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.
|
// Only these keys may leave the browser per event, and only as primitives.
|
||||||
const ALLOWED: Record<string, ReadonlySet<string>> = {
|
const ALLOWED: Record<string, ReadonlySet<string>> = {
|
||||||
tool_opened: new Set(["tool_id", "category", "modality"]),
|
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> {
|
export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||||
if (initialized || !config.enabled) return;
|
if (initialized || !config.enabled) return;
|
||||||
|
|
||||||
try {
|
if (!config.posthogApiKey) {
|
||||||
const posthogJs = (await import("posthog-js")).default;
|
// Web-DSN-only bake: no PostHog key, so skip the PostHog SDK entirely
|
||||||
posthog =
|
// (mirrors the API guard) instead of feeding it an empty key. Still mark
|
||||||
posthogJs.init(config.posthogApiKey, {
|
// the module live so the Sentry beforeSend gate below stays active.
|
||||||
api_host: config.posthogHost,
|
|
||||||
autocapture: false,
|
|
||||||
capture_pageview: true,
|
|
||||||
disable_session_recording: true,
|
|
||||||
ip: false,
|
|
||||||
persistence: "localStorage",
|
|
||||||
person_profiles: "identified_only",
|
|
||||||
}) ?? null;
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
enabled = true;
|
enabled = true;
|
||||||
} catch (err) {
|
} else {
|
||||||
console.warn("[analytics] PostHog init failed:", err);
|
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) {
|
if (posthog) {
|
||||||
@@ -80,51 +73,28 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (config.sentryDsn) {
|
if (config.sentryDsnWeb) {
|
||||||
const Sentry = await import("@sentry/react");
|
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({
|
Sentry.init({
|
||||||
dsn: config.sentryDsn,
|
dsn: config.sentryDsnWeb,
|
||||||
release:
|
release:
|
||||||
import.meta.env.VITE_SENTRY_RELEASE || (await import("@snapotter/shared")).APP_VERSION,
|
import.meta.env.VITE_SENTRY_RELEASE || (await import("@snapotter/shared")).APP_VERSION,
|
||||||
environment: "production",
|
environment: "production",
|
||||||
tracesSampleRate: config.sampleRate,
|
|
||||||
sendDefaultPii: false,
|
sendDefaultPii: false,
|
||||||
integrations: [Sentry.browserTracingIntegration()],
|
sendClientReports: false,
|
||||||
beforeSend(event) {
|
// Errors only: no tracing options, and release-health sessions are
|
||||||
if (!enabled) return null;
|
// dropped by removing the session integration below.
|
||||||
event.message = undefined;
|
integrations: (defaults) => defaults.filter((i) => i.name !== "BrowserSession"),
|
||||||
event.logentry = undefined;
|
ignoreErrors: IGNORE_ERRORS,
|
||||||
event.request = undefined;
|
denyUrls: DENY_URLS,
|
||||||
event.extra = undefined;
|
maxBreadcrumbs: 0,
|
||||||
event.contexts = undefined;
|
beforeBreadcrumb: () => null,
|
||||||
event.breadcrumbs = undefined;
|
beforeSend: buildWebBeforeSend(() => enabled) as unknown as SentryOptions["beforeSend"],
|
||||||
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;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 {
|
export interface SavedSignature {
|
||||||
id: string;
|
id: string;
|
||||||
dataUrl: string;
|
dataUrl: string;
|
||||||
@@ -31,7 +33,7 @@ export function listSignatures(): SavedSignature[] {
|
|||||||
|
|
||||||
export function addSignature(dataUrl: string): SavedSignature {
|
export function addSignature(dataUrl: string): SavedSignature {
|
||||||
const sig: SavedSignature = {
|
const sig: SavedSignature = {
|
||||||
id: crypto.randomUUID(),
|
id: safeRandomUUID(),
|
||||||
dataUrl,
|
dataUrl,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { safeRandomUUID } from "@/lib/uuid";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateId(): string {
|
export function generateId(): string {
|
||||||
const bytes = new Uint8Array(16);
|
return safeRandomUUID();
|
||||||
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)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
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.
|
// Must come after the other plugins so it sees the final bundle.
|
||||||
sentryVitePlugin({
|
sentryVitePlugin({
|
||||||
org: "snapotter",
|
org: "snapotter",
|
||||||
project: "node",
|
// Web source maps upload to the browser project; api events live on "node".
|
||||||
|
project: "web",
|
||||||
authToken: sentryAuthToken,
|
authToken: sentryAuthToken,
|
||||||
telemetry: false,
|
telemetry: false,
|
||||||
disable: !sentryAuthToken,
|
disable: !sentryAuthToken,
|
||||||
|
|||||||
@@ -68,9 +68,11 @@ COPY apps/web/public ./apps/web/public
|
|||||||
ARG SNAPOTTER_ANALYTICS=on
|
ARG SNAPOTTER_ANALYTICS=on
|
||||||
ARG SNAPOTTER_POSTHOG_PROJECT_ID=
|
ARG SNAPOTTER_POSTHOG_PROJECT_ID=
|
||||||
ARG SNAPOTTER_SENTRY_DSN=
|
ARG SNAPOTTER_SENTRY_DSN=
|
||||||
|
ARG SNAPOTTER_SENTRY_DSN_WEB=
|
||||||
COPY scripts/bake-analytics.mjs ./scripts/
|
COPY scripts/bake-analytics.mjs ./scripts/
|
||||||
RUN SNAPOTTER_POSTHOG_PROJECT_ID="${SNAPOTTER_POSTHOG_PROJECT_ID}" \
|
RUN SNAPOTTER_POSTHOG_PROJECT_ID="${SNAPOTTER_POSTHOG_PROJECT_ID}" \
|
||||||
SNAPOTTER_SENTRY_DSN="${SNAPOTTER_SENTRY_DSN}" \
|
SNAPOTTER_SENTRY_DSN="${SNAPOTTER_SENTRY_DSN}" \
|
||||||
|
SNAPOTTER_SENTRY_DSN_WEB="${SNAPOTTER_SENTRY_DSN_WEB}" \
|
||||||
node scripts/bake-analytics.mjs ${SNAPOTTER_ANALYTICS}
|
node scripts/bake-analytics.mjs ${SNAPOTTER_ANALYTICS}
|
||||||
|
|
||||||
# Build only the web frontend (API runs from TS source via tsx). When a
|
# 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 { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { context, propagation, SpanStatusCode, trace } from "@opentelemetry/api";
|
import { context, propagation, SpanStatusCode, trace } from "@opentelemetry/api";
|
||||||
|
import { SafeError } from "@snapotter/shared";
|
||||||
import { missingBundleForScript } from "./feature-gate.js";
|
import { missingBundleForScript } from "./feature-gate.js";
|
||||||
import { acquireVenvRead, tryAcquireVenvRead } from "./venv-lock.js";
|
import { acquireVenvRead, tryAcquireVenvRead } from "./venv-lock.js";
|
||||||
|
|
||||||
@@ -327,17 +328,24 @@ export class PythonDispatcher {
|
|||||||
if (req) {
|
if (req) {
|
||||||
this.pending.delete(reqId);
|
this.pending.delete(reqId);
|
||||||
if (response.exitCode !== 0) {
|
if (response.exitCode !== 0) {
|
||||||
const errText =
|
const extracted = extractPythonError({
|
||||||
extractPythonError({
|
stdout: response.stdout,
|
||||||
stdout: response.stdout,
|
stderr: req.stderrLines.join("\n"),
|
||||||
stderr: req.stderrLines.join("\n"),
|
});
|
||||||
}) ||
|
if (!extracted && (response.exitCode === 137 || response.exitCode === 139)) {
|
||||||
(response.exitCode === 137
|
req.reject(
|
||||||
? "Process killed (out of memory) -- try a lighter model or smaller image"
|
new SafeError(
|
||||||
: response.exitCode === 139
|
response.exitCode === 137
|
||||||
? "Process crashed (segmentation fault)"
|
? "Process killed (out of memory) -- try a lighter model or smaller image"
|
||||||
: `Python script exited with code ${response.exitCode}`);
|
: "Process crashed (segmentation fault)",
|
||||||
req.reject(new Error(errText));
|
{ kind: "operational", code: `exit-${response.exitCode}` },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
req.reject(
|
||||||
|
new Error(extracted || `Python script exited with code ${response.exitCode}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
req.resolve({
|
req.resolve({
|
||||||
stdout: response.stdout || "",
|
stdout: response.stdout || "",
|
||||||
@@ -564,8 +572,13 @@ export class PythonDispatcher {
|
|||||||
: signal === "SIGSEGV" || code === 139
|
: signal === "SIGSEGV" || code === 139
|
||||||
? "Process crashed (segmentation fault)"
|
? "Process crashed (segmentation fault)"
|
||||||
: null;
|
: null;
|
||||||
|
if (signalMsg) {
|
||||||
|
rejectPromise(
|
||||||
|
new SafeError(signalMsg, { kind: "operational", code: `exit-${code ?? signal}` }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const errorText =
|
const errorText =
|
||||||
signalMsg ||
|
|
||||||
extractPythonError({ stdout: stdout.trim(), stderr }) ||
|
extractPythonError({ stdout: stdout.trim(), stderr }) ||
|
||||||
`Python script exited with code ${code}`;
|
`Python script exited with code ${code}`;
|
||||||
rejectPromise(new Error(errorText));
|
rejectPromise(new Error(errorText));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
import { markToolInputError } from "@snapotter/shared";
|
||||||
import { resolveFfmpeg } from "./binaries.js";
|
import { resolveFfmpeg } from "./binaries.js";
|
||||||
import { type FfmpegProgress, parseProgressBlock } from "./progress.js";
|
import { type FfmpegProgress, parseProgressBlock } from "./progress.js";
|
||||||
|
|
||||||
@@ -10,6 +11,23 @@ export interface RunFfmpegOptions {
|
|||||||
|
|
||||||
const STDERR_RING_MAX = 16 * 1024;
|
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
|
* 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.
|
* 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;
|
settled = true;
|
||||||
cleanup();
|
cleanup();
|
||||||
if (code === 0) resolvePromise(stderrTail);
|
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 { spawn } from "node:child_process";
|
||||||
import { resolveFfprobe } from "./binaries.js";
|
import { resolveFfprobe } from "./binaries.js";
|
||||||
|
import { markIfInputError } from "./ffmpeg.js";
|
||||||
|
|
||||||
export interface MediaStreamInfo {
|
export interface MediaStreamInfo {
|
||||||
type: "video" | "audio" | "other";
|
type: "video" | "audio" | "other";
|
||||||
@@ -68,7 +69,10 @@ export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Pro
|
|||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (code === 0) resolvePromise(out);
|
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 {
|
const parsed = JSON.parse(stdout) as {
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export const ANALYTICS_BAKED = {
|
|||||||
posthogApiKey: "",
|
posthogApiKey: "",
|
||||||
posthogHost: "",
|
posthogHost: "",
|
||||||
sentryDsn: "",
|
sentryDsn: "",
|
||||||
sampleRate: 0,
|
sentryDsnWeb: "",
|
||||||
|
posthogSampleRate: 0,
|
||||||
} as const;
|
} 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;
|
posthogApiKey: string;
|
||||||
posthogHost: string;
|
posthogHost: string;
|
||||||
sentryDsn: string;
|
sentryDsn: string;
|
||||||
sampleRate: number;
|
sentryDsnWeb: string;
|
||||||
|
posthogSampleRate: number;
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./analytics/baked.js";
|
export * from "./analytics/baked.js";
|
||||||
|
export * from "./analytics/error-sanitize.js";
|
||||||
export * from "./analytics/events.js";
|
export * from "./analytics/events.js";
|
||||||
export * from "./analytics/feedback.js";
|
export * from "./analytics/feedback.js";
|
||||||
export * from "./analytics/types.js";
|
export * from "./analytics/types.js";
|
||||||
@@ -12,4 +13,5 @@ export * from "./permissions.js";
|
|||||||
export * from "./pipeline-templates.js";
|
export * from "./pipeline-templates.js";
|
||||||
export * from "./search/format-aliases.js";
|
export * from "./search/format-aliases.js";
|
||||||
export * from "./section.js";
|
export * from "./section.js";
|
||||||
|
export * from "./tool-errors.js";
|
||||||
export * from "./types.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 ?? "")
|
? (process.env.SNAPOTTER_POSTHOG_PROJECT_ID ?? process.env.SNAPOTTER_POSTHOG_KEY ?? "")
|
||||||
: "";
|
: "";
|
||||||
const sentryDsn = on ? (process.env.SNAPOTTER_SENTRY_DSN ?? "") : "";
|
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" : "";
|
const posthogHost = posthogApiKey ? "https://us.i.posthog.com" : "";
|
||||||
// Enabled only when turned on AND there is somewhere to report to, so a
|
// Enabled only when turned on AND there is somewhere to report to, so a
|
||||||
// credential-less source build never initializes the SDKs.
|
// credential-less source build never initializes the SDKs.
|
||||||
const enabled = on && (posthogApiKey !== "" || sentryDsn !== "");
|
const enabled = on && (posthogApiKey !== "" || sentryDsn !== "" || sentryDsnWeb !== "");
|
||||||
// tracesSampleRate only governs performance transactions; errors are always
|
// PostHog event sampling only. Sentry tracing was removed in 2.0.1; do not
|
||||||
// captured. Keep low so fleet-wide tracing does not drain Sentry quota.
|
// reintroduce a shared "sampleRate" that doubles as a traces rate.
|
||||||
const sampleRate = sentryDsn ? 0.1 : 0;
|
const posthogSampleRate = on ? 0.1 : 0;
|
||||||
|
|
||||||
const content = `// AUTO-GENERATED by scripts/bake-analytics.mjs -- do not edit manually
|
const content = `// AUTO-GENERATED by scripts/bake-analytics.mjs -- do not edit manually
|
||||||
export const ANALYTICS_BAKED = {
|
export const ANALYTICS_BAKED = {
|
||||||
@@ -31,7 +32,8 @@ export const ANALYTICS_BAKED = {
|
|||||||
posthogApiKey: "${posthogApiKey}",
|
posthogApiKey: "${posthogApiKey}",
|
||||||
posthogHost: "${posthogHost}",
|
posthogHost: "${posthogHost}",
|
||||||
sentryDsn: "${sentryDsn}",
|
sentryDsn: "${sentryDsn}",
|
||||||
sampleRate: ${sampleRate},
|
sentryDsnWeb: "${sentryDsnWeb}",
|
||||||
|
posthogSampleRate: ${posthogSampleRate},
|
||||||
} as const;
|
} as const;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ interface AnalyticsConfig {
|
|||||||
posthogApiKey: string;
|
posthogApiKey: string;
|
||||||
posthogHost: string;
|
posthogHost: string;
|
||||||
sentryDsn: string;
|
sentryDsn: string;
|
||||||
sampleRate: number;
|
sentryDsnWeb: string;
|
||||||
|
posthogSampleRate: number;
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +88,8 @@ test.describe("Analytics opt-out toggle", () => {
|
|||||||
posthogApiKey: "",
|
posthogApiKey: "",
|
||||||
posthogHost: "",
|
posthogHost: "",
|
||||||
sentryDsn: "",
|
sentryDsn: "",
|
||||||
sampleRate: 0,
|
sentryDsnWeb: "",
|
||||||
|
posthogSampleRate: 0,
|
||||||
instanceId: "",
|
instanceId: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,14 @@ setup("authenticate for analytics tests", async ({ page }) => {
|
|||||||
|
|
||||||
await page.waitForURL("/", { timeout: 30_000 });
|
await page.waitForURL("/", { timeout: 30_000 });
|
||||||
await expect(page).toHaveURL("/");
|
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 });
|
await page.context().storageState({ path: authFile });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ test.describe("Privacy Policy Page", () => {
|
|||||||
|
|
||||||
test("mentions PostHog as analytics provider", async ({ loggedInPage: page }) => {
|
test("mentions PostHog as analytics provider", async ({ loggedInPage: page }) => {
|
||||||
await page.goto("/privacy");
|
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 }) => {
|
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("posthogApiKey");
|
||||||
expect(config).toHaveProperty("posthogHost");
|
expect(config).toHaveProperty("posthogHost");
|
||||||
expect(config).toHaveProperty("sentryDsn");
|
expect(config).toHaveProperty("sentryDsn");
|
||||||
expect(config).toHaveProperty("sampleRate");
|
expect(config).toHaveProperty("sentryDsnWeb");
|
||||||
|
expect(config).toHaveProperty("posthogSampleRate");
|
||||||
expect(config).toHaveProperty("instanceId");
|
expect(config).toHaveProperty("instanceId");
|
||||||
|
|
||||||
expect(typeof config.enabled).toBe("boolean");
|
expect(typeof config.enabled).toBe("boolean");
|
||||||
expect(typeof config.posthogApiKey).toBe("string");
|
expect(typeof config.posthogApiKey).toBe("string");
|
||||||
expect(typeof config.posthogHost).toBe("string");
|
expect(typeof config.posthogHost).toBe("string");
|
||||||
expect(typeof config.sentryDsn).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");
|
expect(typeof config.instanceId).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ test.describe("Analytics disabled by build-time config", () => {
|
|||||||
posthogApiKey: "",
|
posthogApiKey: "",
|
||||||
posthogHost: "",
|
posthogHost: "",
|
||||||
sentryDsn: "",
|
sentryDsn: "",
|
||||||
sampleRate: 0,
|
sentryDsnWeb: "",
|
||||||
|
posthogSampleRate: 0,
|
||||||
instanceId: "",
|
instanceId: "",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,8 +75,22 @@ async function main() {
|
|||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
// 1. Bare `docker run` with no DB env boots and becomes healthy.
|
// 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");
|
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);
|
const healthy = await waitHealthy(300000);
|
||||||
healthy
|
healthy
|
||||||
? ok("embedded container reached healthy")
|
? ok("embedded container reached healthy")
|
||||||
@@ -111,15 +125,23 @@ async function main() {
|
|||||||
|
|
||||||
// 4. Non-root fails fast.
|
// 4. Non-root fails fast.
|
||||||
console.log("\n[4] non-root fail-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")
|
? ok("non-root rejected with guidance")
|
||||||
: bad("non-root not rejected");
|
: bad("non-root not rejected");
|
||||||
|
|
||||||
// 5. Partial config fails fast.
|
// 5. Partial config fails fast.
|
||||||
console.log("\n[5] partial-config fail-fast");
|
console.log("\n[5] partial-config fail-fast");
|
||||||
combined(["run", "--rm", "-e", "REDIS_URL=redis://x:6379", IMAGE]).includes(
|
combined([
|
||||||
"set BOTH DATABASE_URL and REDIS_URL",
|
"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")
|
? ok("partial config rejected")
|
||||||
: bad("partial config not rejected");
|
: bad("partial config not rejected");
|
||||||
|
|
||||||
@@ -152,7 +174,19 @@ async function main() {
|
|||||||
"-c",
|
"-c",
|
||||||
`apk add --no-cache sqlite >/dev/null 2>&1 && sqlite3 /data/snapotter.db "${seedSql}"`,
|
`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))
|
(await waitHealthy(300000))
|
||||||
? ok("healthy after upgrade boot")
|
? ok("healthy after upgrade boot")
|
||||||
: bad("unhealthy 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("posthogApiKey");
|
||||||
expect(config).toHaveProperty("posthogHost");
|
expect(config).toHaveProperty("posthogHost");
|
||||||
expect(config).toHaveProperty("sentryDsn");
|
expect(config).toHaveProperty("sentryDsn");
|
||||||
expect(config).toHaveProperty("sampleRate");
|
expect(config).toHaveProperty("sentryDsnWeb");
|
||||||
|
expect(config).toHaveProperty("posthogSampleRate");
|
||||||
expect(config).toHaveProperty("instanceId");
|
expect(config).toHaveProperty("instanceId");
|
||||||
|
|
||||||
expect(typeof config.enabled).toBe("boolean");
|
expect(typeof config.enabled).toBe("boolean");
|
||||||
expect(typeof config.posthogApiKey).toBe("string");
|
expect(typeof config.posthogApiKey).toBe("string");
|
||||||
expect(typeof config.posthogHost).toBe("string");
|
expect(typeof config.posthogHost).toBe("string");
|
||||||
expect(typeof config.sentryDsn).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");
|
expect(typeof config.instanceId).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +78,8 @@ describe("GET /api/v1/config/analytics", () => {
|
|||||||
expect(config.posthogApiKey).toBe("");
|
expect(config.posthogApiKey).toBe("");
|
||||||
expect(config.posthogHost).toBe("");
|
expect(config.posthogHost).toBe("");
|
||||||
expect(config.sentryDsn).toBe("");
|
expect(config.sentryDsn).toBe("");
|
||||||
expect(config.sampleRate).toBe(0);
|
expect(config.sentryDsnWeb).toBe("");
|
||||||
|
expect(config.posthogSampleRate).toBe(0);
|
||||||
expect(config.instanceId).toBe("");
|
expect(config.instanceId).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -298,6 +298,23 @@ describe("importBundleArchive", () => {
|
|||||||
/invalid model path/,
|
/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", () => {
|
describe("site-packages import", () => {
|
||||||
@@ -441,6 +458,30 @@ describe("POST /api/v1/admin/features/import", () => {
|
|||||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
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 () => {
|
it("returns 409 when lock is held", async () => {
|
||||||
const locked = acquireInstallLock("blocking-bundle");
|
const locked = acquireInstallLock("blocking-bundle");
|
||||||
expect(locked).toBe(true);
|
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.stderr.emit("data", Buffer.from("RuntimeError: model not found\n"));
|
||||||
mock.emitEvent("close", 1, null);
|
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 () => {
|
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");
|
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 () => {
|
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");
|
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 () => {
|
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
|
// SIGKILL signal without exit code 137
|
||||||
mock.emitEvent("close", null, "SIGKILL");
|
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 () => {
|
it("treats SIGSEGV signal as segfault error", async () => {
|
||||||
@@ -483,7 +514,14 @@ describe("bridge - runPythonWithProgress (per-request fallback)", () => {
|
|||||||
|
|
||||||
mock.emitEvent("close", null, "SIGSEGV");
|
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 () => {
|
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`));
|
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 () => {
|
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`));
|
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 () => {
|
it("ignores stdout lines that are not valid JSON", async () => {
|
||||||
|
|||||||
@@ -5,21 +5,26 @@ import {
|
|||||||
analyticsEnabled,
|
analyticsEnabled,
|
||||||
bakedEnabled,
|
bakedEnabled,
|
||||||
refreshAnalyticsGate,
|
refreshAnalyticsGate,
|
||||||
|
telemetryEnvKilled,
|
||||||
} from "../../../apps/api/src/lib/analytics-gate.js";
|
} from "../../../apps/api/src/lib/analytics-gate.js";
|
||||||
|
|
||||||
const origEnv = process.env.NODE_ENV;
|
const origEnv = process.env.NODE_ENV;
|
||||||
const origOverride = process.env.ANALYTICS_BAKED_OVERRIDE;
|
const origOverride = process.env.ANALYTICS_BAKED_OVERRIDE;
|
||||||
|
const origTelemetry = process.env.SNAPOTTER_TELEMETRY;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
__resetGateForTests();
|
__resetGateForTests();
|
||||||
process.env.NODE_ENV = "test";
|
process.env.NODE_ENV = "test";
|
||||||
process.env.ANALYTICS_BAKED_OVERRIDE = "on"; // force bake on for these unit tests
|
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(() => {
|
afterEach(() => {
|
||||||
process.env.NODE_ENV = origEnv;
|
process.env.NODE_ENV = origEnv;
|
||||||
if (origOverride === undefined) delete process.env.ANALYTICS_BAKED_OVERRIDE;
|
if (origOverride === undefined) delete process.env.ANALYTICS_BAKED_OVERRIDE;
|
||||||
else process.env.ANALYTICS_BAKED_OVERRIDE = origOverride;
|
else process.env.ANALYTICS_BAKED_OVERRIDE = origOverride;
|
||||||
|
if (origTelemetry === undefined) delete process.env.SNAPOTTER_TELEMETRY;
|
||||||
|
else process.env.SNAPOTTER_TELEMETRY = origTelemetry;
|
||||||
__setReaderForTests(null);
|
__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", () => {
|
describe("fail closed", () => {
|
||||||
it("stays disabled across a transient read error once disabled was seen", async () => {
|
it("stays disabled across a transient read error once disabled was seen", async () => {
|
||||||
__setReaderForTests(async () => false);
|
__setReaderForTests(async () => false);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ const bakedConfig = vi.hoisted(() => ({
|
|||||||
posthogApiKey: "",
|
posthogApiKey: "",
|
||||||
posthogHost: "",
|
posthogHost: "",
|
||||||
sentryDsn: "",
|
sentryDsn: "",
|
||||||
sampleRate: 1.0,
|
sentryDsnWeb: "",
|
||||||
|
posthogSampleRate: 1.0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockCapture = vi.hoisted(() => vi.fn());
|
const mockCapture = vi.hoisted(() => vi.fn());
|
||||||
@@ -20,6 +21,13 @@ const MockPostHog = vi.hoisted(() =>
|
|||||||
const mockSentryCapture = vi.hoisted(() => vi.fn());
|
const mockSentryCapture = vi.hoisted(() => vi.fn());
|
||||||
const mockSentryClose = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
const mockSentryClose = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
const mockSentryInit = vi.hoisted(() => vi.fn());
|
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) => {
|
vi.mock("@snapotter/shared", async (importOriginal) => {
|
||||||
const actual: Record<string, unknown> = await importOriginal();
|
const actual: Record<string, unknown> = await importOriginal();
|
||||||
@@ -56,6 +64,7 @@ vi.mock("@sentry/node", () => ({
|
|||||||
init: mockSentryInit,
|
init: mockSentryInit,
|
||||||
captureException: mockSentryCapture,
|
captureException: mockSentryCapture,
|
||||||
close: mockSentryClose,
|
close: mockSentryClose,
|
||||||
|
withScope: mockSentryWithScope,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type AnalyticsModule = typeof import("../../../apps/api/src/lib/analytics.js");
|
type AnalyticsModule = typeof import("../../../apps/api/src/lib/analytics.js");
|
||||||
@@ -66,7 +75,8 @@ beforeEach(async () => {
|
|||||||
bakedConfig.posthogApiKey = "";
|
bakedConfig.posthogApiKey = "";
|
||||||
bakedConfig.posthogHost = "";
|
bakedConfig.posthogHost = "";
|
||||||
bakedConfig.sentryDsn = "";
|
bakedConfig.sentryDsn = "";
|
||||||
bakedConfig.sampleRate = 1.0;
|
bakedConfig.sentryDsnWeb = "";
|
||||||
|
bakedConfig.posthogSampleRate = 1.0;
|
||||||
|
|
||||||
mockCapture.mockClear();
|
mockCapture.mockClear();
|
||||||
mockShutdown.mockClear();
|
mockShutdown.mockClear();
|
||||||
@@ -74,6 +84,7 @@ beforeEach(async () => {
|
|||||||
mockSentryCapture.mockClear();
|
mockSentryCapture.mockClear();
|
||||||
mockSentryClose.mockClear();
|
mockSentryClose.mockClear();
|
||||||
mockSentryInit.mockClear();
|
mockSentryInit.mockClear();
|
||||||
|
mockSentryWithScope.mockClear();
|
||||||
|
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
mod = await import("../../../apps/api/src/lib/analytics.js");
|
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();
|
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.enabled = true;
|
||||||
bakedConfig.posthogApiKey = "phc_test_key";
|
bakedConfig.posthogApiKey = "phc_test_key";
|
||||||
bakedConfig.sampleRate = 0;
|
bakedConfig.posthogSampleRate = 0;
|
||||||
await mod.initAnalytics();
|
await mod.initAnalytics();
|
||||||
|
|
||||||
await mod.trackEvent("test_event", { key: "value" });
|
await mod.trackEvent("test_event", { key: "value" });
|
||||||
@@ -178,7 +189,7 @@ describe("trackEvent", () => {
|
|||||||
it("captures event with only allow-listed properties when enabled", async () => {
|
it("captures event with only allow-listed properties when enabled", async () => {
|
||||||
bakedConfig.enabled = true;
|
bakedConfig.enabled = true;
|
||||||
bakedConfig.posthogApiKey = "phc_test_key";
|
bakedConfig.posthogApiKey = "phc_test_key";
|
||||||
bakedConfig.sampleRate = 1.0;
|
bakedConfig.posthogSampleRate = 1.0;
|
||||||
await mod.initAnalytics();
|
await mod.initAnalytics();
|
||||||
|
|
||||||
await mod.trackEvent("tool_used", {
|
await mod.trackEvent("tool_used", {
|
||||||
@@ -197,7 +208,7 @@ describe("trackEvent", () => {
|
|||||||
it("uses provided distinctId when given", async () => {
|
it("uses provided distinctId when given", async () => {
|
||||||
bakedConfig.enabled = true;
|
bakedConfig.enabled = true;
|
||||||
bakedConfig.posthogApiKey = "phc_test_key";
|
bakedConfig.posthogApiKey = "phc_test_key";
|
||||||
bakedConfig.sampleRate = 1.0;
|
bakedConfig.posthogSampleRate = 1.0;
|
||||||
await mod.initAnalytics();
|
await mod.initAnalytics();
|
||||||
|
|
||||||
await mod.trackEvent("tool_used", { tool: "crop" }, "custom-id-123");
|
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 () => {
|
it("does not throw when capture throws internally", async () => {
|
||||||
bakedConfig.enabled = true;
|
bakedConfig.enabled = true;
|
||||||
bakedConfig.posthogApiKey = "phc_test_key";
|
bakedConfig.posthogApiKey = "phc_test_key";
|
||||||
bakedConfig.sampleRate = 1.0;
|
bakedConfig.posthogSampleRate = 1.0;
|
||||||
await mod.initAnalytics();
|
await mod.initAnalytics();
|
||||||
|
|
||||||
mockCapture.mockImplementationOnce(() => {
|
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 { randomUUID } from "node:crypto";
|
||||||
import { existsSync } from "node:fs";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -13,6 +13,18 @@ vi.mock("../../../apps/api/src/config.js", () => ({
|
|||||||
env: config,
|
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;
|
let testDir: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -23,6 +35,7 @@ beforeEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
diskState.lowDisk = false;
|
||||||
await rm(testDir, { recursive: true, force: true });
|
await rm(testDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,6 +43,13 @@ async function importModule() {
|
|||||||
return await import("../../../apps/api/src/lib/file-storage.js");
|
return await import("../../../apps/api/src/lib/file-storage.js");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StorageError = Error & {
|
||||||
|
isSafeMessage?: unknown;
|
||||||
|
kind?: string;
|
||||||
|
code?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
};
|
||||||
|
|
||||||
describe("saveFile", () => {
|
describe("saveFile", () => {
|
||||||
it("saves buffer and returns UUID-based filename with correct extension", async () => {
|
it("saves buffer and returns UUID-based filename with correct extension", async () => {
|
||||||
const { saveFile } = await importModule();
|
const { saveFile } = await importModule();
|
||||||
@@ -189,3 +209,87 @@ describe("thumbnail functions", () => {
|
|||||||
await expect(deleteThumbnail("ghost.png")).resolves.toBeUndefined();
|
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 decryptMock = vi.hoisted(() => vi.fn());
|
||||||
const isEncryptedMock = vi.hoisted(() => vi.fn());
|
const isEncryptedMock = vi.hoisted(() => vi.fn());
|
||||||
const getActiveLicenseMock = vi.hoisted(() => vi.fn());
|
const getActiveLicenseMock = vi.hoisted(() => vi.fn());
|
||||||
|
const isFeatureEnabledMock = vi.hoisted(() => vi.fn());
|
||||||
const selectMock = vi.hoisted(() => vi.fn());
|
const selectMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
function queryChain<T>(result: T) {
|
function queryChain<T>(result: T) {
|
||||||
@@ -24,6 +25,8 @@ async function loadAlertEvaluator() {
|
|||||||
decryptMock.mockReset();
|
decryptMock.mockReset();
|
||||||
isEncryptedMock.mockReset();
|
isEncryptedMock.mockReset();
|
||||||
getActiveLicenseMock.mockReset();
|
getActiveLicenseMock.mockReset();
|
||||||
|
isFeatureEnabledMock.mockReset();
|
||||||
|
isFeatureEnabledMock.mockReturnValue(true);
|
||||||
selectMock.mockReset();
|
selectMock.mockReset();
|
||||||
|
|
||||||
vi.doMock("node:fs/promises", () => ({
|
vi.doMock("node:fs/promises", () => ({
|
||||||
@@ -71,6 +74,7 @@ async function loadAlertEvaluator() {
|
|||||||
|
|
||||||
vi.doMock("@snapotter/enterprise", () => ({
|
vi.doMock("@snapotter/enterprise", () => ({
|
||||||
getActiveLicense: getActiveLicenseMock,
|
getActiveLicense: getActiveLicenseMock,
|
||||||
|
isFeatureEnabled: isFeatureEnabledMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return import("../../../../apps/api/src/jobs/alert-evaluator.js");
|
return import("../../../../apps/api/src/jobs/alert-evaluator.js");
|
||||||
@@ -81,6 +85,17 @@ describe("alert evaluator behavior", () => {
|
|||||||
vi.restoreAllMocks();
|
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 () => {
|
it("returns early when webhook destination settings are invalid JSON", async () => {
|
||||||
const { evaluateAlerts } = await loadAlertEvaluator();
|
const { evaluateAlerts } = await loadAlertEvaluator();
|
||||||
getSettingStringMock.mockResolvedValueOnce("{invalid");
|
getSettingStringMock.mockResolvedValueOnce("{invalid");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const deliverWebhookMock = vi.hoisted(() => vi.fn());
|
|||||||
const upsertSettingMock = vi.hoisted(() => vi.fn());
|
const upsertSettingMock = vi.hoisted(() => vi.fn());
|
||||||
const decryptMock = vi.hoisted(() => vi.fn());
|
const decryptMock = vi.hoisted(() => vi.fn());
|
||||||
const isEncryptedMock = vi.hoisted(() => vi.fn());
|
const isEncryptedMock = vi.hoisted(() => vi.fn());
|
||||||
|
const isFeatureEnabledMock = vi.hoisted(() => vi.fn());
|
||||||
const selectMock = vi.hoisted(() => vi.fn());
|
const selectMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
function queryChain<T>(result: T, terminalWhere = false) {
|
function queryChain<T>(result: T, terminalWhere = false) {
|
||||||
@@ -24,6 +25,8 @@ async function loadSiemForward() {
|
|||||||
upsertSettingMock.mockReset();
|
upsertSettingMock.mockReset();
|
||||||
decryptMock.mockReset();
|
decryptMock.mockReset();
|
||||||
isEncryptedMock.mockReset();
|
isEncryptedMock.mockReset();
|
||||||
|
isFeatureEnabledMock.mockReset();
|
||||||
|
isFeatureEnabledMock.mockReturnValue(true);
|
||||||
selectMock.mockReset();
|
selectMock.mockReset();
|
||||||
|
|
||||||
vi.doMock("drizzle-orm", () => ({
|
vi.doMock("drizzle-orm", () => ({
|
||||||
@@ -75,6 +78,10 @@ async function loadSiemForward() {
|
|||||||
readSiemConfig: readSiemConfigMock,
|
readSiemConfig: readSiemConfigMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.doMock("@snapotter/enterprise", () => ({
|
||||||
|
isFeatureEnabled: isFeatureEnabledMock,
|
||||||
|
}));
|
||||||
|
|
||||||
return import("../../../../apps/api/src/jobs/siem-forward.js");
|
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",
|
posthogApiKey: "phc_test123",
|
||||||
posthogHost: "https://us.i.posthog.com",
|
posthogHost: "https://us.i.posthog.com",
|
||||||
sentryDsn: "https://abc@sentry.io/123",
|
sentryDsn: "https://abc@sentry.io/123",
|
||||||
sampleRate: 1.0,
|
sentryDsnWeb: "https://abc@sentry.io/456",
|
||||||
|
posthogSampleRate: 1.0,
|
||||||
instanceId: "inst-abc-123",
|
instanceId: "inst-abc-123",
|
||||||
};
|
};
|
||||||
expect(config.enabled).toBe(true);
|
expect(config.enabled).toBe(true);
|
||||||
expect(config.posthogApiKey).toBe("phc_test123");
|
expect(config.posthogApiKey).toBe("phc_test123");
|
||||||
expect(config.posthogHost).toBe("https://us.i.posthog.com");
|
expect(config.posthogHost).toBe("https://us.i.posthog.com");
|
||||||
expect(config.sentryDsn).toBe("https://abc@sentry.io/123");
|
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");
|
expect(config.instanceId).toBe("inst-abc-123");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,11 +27,12 @@ describe("AnalyticsConfig type", () => {
|
|||||||
posthogApiKey: "",
|
posthogApiKey: "",
|
||||||
posthogHost: "",
|
posthogHost: "",
|
||||||
sentryDsn: "",
|
sentryDsn: "",
|
||||||
sampleRate: 0,
|
sentryDsnWeb: "",
|
||||||
|
posthogSampleRate: 0,
|
||||||
instanceId: "",
|
instanceId: "",
|
||||||
};
|
};
|
||||||
expect(config.enabled).toBe(false);
|
expect(config.enabled).toBe(false);
|
||||||
expect(config.sampleRate).toBe(0);
|
expect(config.posthogSampleRate).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts fractional sample rates", () => {
|
it("accepts fractional sample rates", () => {
|
||||||
@@ -38,10 +41,11 @@ describe("AnalyticsConfig type", () => {
|
|||||||
posthogApiKey: "key",
|
posthogApiKey: "key",
|
||||||
posthogHost: "https://host.com",
|
posthogHost: "https://host.com",
|
||||||
sentryDsn: "https://dsn",
|
sentryDsn: "https://dsn",
|
||||||
sampleRate: 0.5,
|
sentryDsnWeb: "https://dsn-web",
|
||||||
|
posthogSampleRate: 0.5,
|
||||||
instanceId: "id",
|
instanceId: "id",
|
||||||
};
|
};
|
||||||
expect(config.sampleRate).toBe(0.5);
|
expect(config.posthogSampleRate).toBe(0.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has exactly the expected keys", () => {
|
it("has exactly the expected keys", () => {
|
||||||
@@ -50,12 +54,21 @@ describe("AnalyticsConfig type", () => {
|
|||||||
posthogApiKey: "key",
|
posthogApiKey: "key",
|
||||||
posthogHost: "host",
|
posthogHost: "host",
|
||||||
sentryDsn: "dsn",
|
sentryDsn: "dsn",
|
||||||
sampleRate: 1,
|
sentryDsnWeb: "dsn-web",
|
||||||
|
posthogSampleRate: 1,
|
||||||
instanceId: "id",
|
instanceId: "id",
|
||||||
};
|
};
|
||||||
const keys = Object.keys(config).sort();
|
const keys = Object.keys(config).sort();
|
||||||
expect(keys).toEqual(
|
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",
|
posthogApiKey: "phc_test",
|
||||||
posthogHost: "https://ph.test",
|
posthogHost: "https://ph.test",
|
||||||
sentryDsn: "https://sentry.test/123",
|
sentryDsn: "https://sentry.test/123",
|
||||||
sampleRate: 1,
|
sentryDsnWeb: "https://sentry.test/web/456",
|
||||||
|
posthogSampleRate: 1,
|
||||||
instanceId: "inst-1",
|
instanceId: "inst-1",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,7 +57,8 @@ const disabledConfig = {
|
|||||||
posthogApiKey: "",
|
posthogApiKey: "",
|
||||||
posthogHost: "",
|
posthogHost: "",
|
||||||
sentryDsn: "",
|
sentryDsn: "",
|
||||||
sampleRate: 0,
|
sentryDsnWeb: "",
|
||||||
|
posthogSampleRate: 0,
|
||||||
instanceId: "",
|
instanceId: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,6 +70,7 @@ describe("Analytics No-Leak Invariant (baked model)", () => {
|
|||||||
mockPosthogInit.mockClear();
|
mockPosthogInit.mockClear();
|
||||||
mockCapture.mockClear();
|
mockCapture.mockClear();
|
||||||
mockSentryInit.mockClear();
|
mockSentryInit.mockClear();
|
||||||
|
mockBrowserTracingIntegration.mockClear();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
mod = await import("../../../apps/web/src/lib/analytics");
|
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" });
|
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);
|
await mod.initAnalytics(enabledConfig);
|
||||||
expect(mockSentryInit).toHaveBeenCalledOnce();
|
expect(mockSentryInit).toHaveBeenCalledOnce();
|
||||||
expect(mockSentryInit).toHaveBeenCalledWith(
|
expect(mockSentryInit).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
dsn: "https://sentry.test/123",
|
dsn: "https://sentry.test/web/456",
|
||||||
sendDefaultPii: false,
|
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", () => {
|
describe("PII never leaks even when analytics enabled", () => {
|
||||||
it("Sentry strips the entire user object from events", async () => {
|
it("Sentry strips the entire user object from events", async () => {
|
||||||
await mod.initAnalytics(enabledConfig);
|
await mod.initAnalytics(enabledConfig);
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ const enabledConfig = {
|
|||||||
posthogApiKey: "phc_test",
|
posthogApiKey: "phc_test",
|
||||||
posthogHost: "https://ph.test",
|
posthogHost: "https://ph.test",
|
||||||
sentryDsn: "https://sentry.test/123",
|
sentryDsn: "https://sentry.test/123",
|
||||||
sampleRate: 1,
|
sentryDsnWeb: "https://sentry.test/web/456",
|
||||||
|
posthogSampleRate: 1,
|
||||||
instanceId: "inst-1",
|
instanceId: "inst-1",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,7 +54,8 @@ const disabledConfig = {
|
|||||||
posthogApiKey: "key",
|
posthogApiKey: "key",
|
||||||
posthogHost: "https://ph.test",
|
posthogHost: "https://ph.test",
|
||||||
sentryDsn: "",
|
sentryDsn: "",
|
||||||
sampleRate: 1,
|
sentryDsnWeb: "",
|
||||||
|
posthogSampleRate: 1,
|
||||||
instanceId: "inst-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);
|
await mod.initAnalytics(enabledConfig);
|
||||||
expect(mockSentryInit).toHaveBeenCalledOnce();
|
expect(mockSentryInit).toHaveBeenCalledOnce();
|
||||||
expect(mockSentryInit).toHaveBeenCalledWith(
|
expect(mockSentryInit).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
dsn: "https://sentry.test/123",
|
dsn: "https://sentry.test/web/456",
|
||||||
sendDefaultPii: false,
|
sendDefaultPii: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips Sentry when sentryDsn is empty", async () => {
|
it("skips Sentry when sentryDsnWeb is empty", async () => {
|
||||||
await mod.initAnalytics({ ...enabledConfig, sentryDsn: "" });
|
await mod.initAnalytics({ ...enabledConfig, sentryDsnWeb: "" });
|
||||||
expect(mockSentryInit).not.toHaveBeenCalled();
|
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",
|
posthogApiKey: "phc_test",
|
||||||
posthogHost: "https://us.i.posthog.com",
|
posthogHost: "https://us.i.posthog.com",
|
||||||
sentryDsn: "",
|
sentryDsn: "",
|
||||||
sampleRate: 1,
|
sentryDsnWeb: "",
|
||||||
|
posthogSampleRate: 1,
|
||||||
instanceId: "instance-1",
|
instanceId: "instance-1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -70,7 +71,8 @@ describe("ToolFeedbackPrompt", () => {
|
|||||||
posthogApiKey: "",
|
posthogApiKey: "",
|
||||||
posthogHost: "",
|
posthogHost: "",
|
||||||
sentryDsn: "",
|
sentryDsn: "",
|
||||||
sampleRate: 0,
|
sentryDsnWeb: "",
|
||||||
|
posthogSampleRate: 0,
|
||||||
instanceId: "",
|
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",
|
posthogApiKey: "phc_test",
|
||||||
posthogHost: "https://ph.example.com",
|
posthogHost: "https://ph.example.com",
|
||||||
sentryDsn: "https://sentry.example.com",
|
sentryDsn: "https://sentry.example.com",
|
||||||
sampleRate: 0.5,
|
sentryDsnWeb: "https://sentry.example.com/web",
|
||||||
|
posthogSampleRate: 0.5,
|
||||||
instanceId: "inst-123",
|
instanceId: "inst-123",
|
||||||
};
|
};
|
||||||
fetchMock.mockResolvedValueOnce({
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
|||||||
Reference in New Issue
Block a user