diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 48be1cfd..935672f1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -20,7 +20,7 @@ import { closeWorkers, startWorkers } from "./jobs/worker.js"; import { initAnalytics, shutdownAnalytics, trackEvent } from "./lib/analytics.js"; import { shouldRunStartupCleanup } from "./lib/cleanup.js"; import { buildCsp } from "./lib/csp.js"; -import { reportError } from "./lib/error-report.js"; +import { reportError, setSentryInstanceTag } from "./lib/error-report.js"; import { stripInternalPaths } from "./lib/errors.js"; import { acquireInstallLock, @@ -180,17 +180,21 @@ if (env.AUTH_ENABLED) { await ensureAnonymousUser(); } -async function ensureInstanceId() { +async function ensureInstanceId(): Promise { const [existing] = await db .select() .from(schema.settings) .where(eq(schema.settings.key, "instance_id")); - if (!existing) { - await db.insert(schema.settings).values({ key: "instance_id", value: randomUUID() }); - } + if (existing) return existing.value; + const value = randomUUID(); + await db.insert(schema.settings).values({ key: "instance_id", value }); + return value; } -await ensureInstanceId(); +// Tag every Sentry event with the anonymized instance id so triage can tell one +// broken install from the whole fleet, and cross-reference an event to the same +// instance_id used by the PostHog server-side stream. +await setSentryInstanceTag(await ensureInstanceId()); async function ensureDefaultSettings() { const defaults: Record = { diff --git a/apps/api/src/instrument.ts b/apps/api/src/instrument.ts index e793b827..2a46ec0f 100644 --- a/apps/api/src/instrument.ts +++ b/apps/api/src/instrument.ts @@ -2,6 +2,7 @@ import { ANALYTICS_BAKED } from "@snapotter/shared"; import { analyticsEnabled, gatePrimed, telemetryEnvKilled } from "./lib/analytics-gate.js"; import { deployMode } from "./lib/deploy-mode.js"; import { buildBeforeSend } from "./lib/sentry-scrub.js"; +import { buildTracesSampler } from "./lib/sentry-tracing.js"; // 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, @@ -20,15 +21,37 @@ if (ANALYTICS_BAKED.sentryDsn && !telemetryEnvKilled()) { // imports @sentry/node; cast at this one boundary to the SDK callback type. type SentryOptions = NonNullable[0]>; + // Performance tracing is OFF by default. The July 2026 quota incident was + // the default redis + postgres integrations turning BullMQ blocking polls + // and pg idle pings into transactions. Opt in with a positive + // SENTRY_TRACES_SAMPLE_RATE (e.g. 0.05): a tracesSampler then zeroes every + // standalone db/redis/queue-poll root span and the Redis integration is + // dropped, so that poll storm can never recur. + const tracesSampleRate = Number(process.env.SENTRY_TRACES_SAMPLE_RATE) || 0; + const tracingEnabled = tracesSampleRate > 0 && tracesSampleRate <= 1; + Sentry.init({ dsn: ANALYTICS_BAKED.sentryDsn, release, environment: process.env.SNAPOTTER_ENV || "production", sendDefaultPii: false, - // Errors only. No traces options are set at all, so the SDK never - // starts traces and BullMQ/pg idle polling can't become transactions - // again (the July 2026 quota incident). - integrations: [Sentry.httpIntegration({ trackIncomingRequestsAsSessions: false })], + // With tracing on, use the function form to DROP the default Redis + // integration (the array form is additive and would keep it). With + // tracing off, the array form is fine: no sampler means the defaults + // never start a transaction, so they stay inert. + integrations: tracingEnabled + ? (defaults) => + defaults + .filter((i) => i.name !== "Redis") + .concat(Sentry.httpIntegration({ trackIncomingRequestsAsSessions: false })) + : [Sentry.httpIntegration({ trackIncomingRequestsAsSessions: false })], + ...(tracingEnabled + ? { + tracesSampler: buildTracesSampler( + tracesSampleRate, + ) as unknown as SentryOptions["tracesSampler"], + } + : {}), sendClientReports: false, // Capture the breadcrumb trail (default 100). beforeSend (sentry-scrub.ts) // sanitizes each breadcrumb before send: urls/paths redacted, data dropped. @@ -36,7 +59,11 @@ if (ANALYTICS_BAKED.sentryDsn && !telemetryEnvKilled()) { beforeSend: buildBeforeSend(sentryActive) as unknown as SentryOptions["beforeSend"], }); - console.log("[sentry] initialized (errors only), release:", release); + console.log( + tracingEnabled + ? `[sentry] initialized (errors + traces @ ${tracesSampleRate}), release: ${release}` + : `[sentry] initialized (errors only), release: ${release}`, + ); } catch { // @sentry/node not available } diff --git a/apps/api/src/jobs/system-jobs.ts b/apps/api/src/jobs/system-jobs.ts index 4e5c30d9..0678393e 100644 --- a/apps/api/src/jobs/system-jobs.ts +++ b/apps/api/src/jobs/system-jobs.ts @@ -13,6 +13,7 @@ import type { Job } from "bullmq"; import { and, eq, inArray, isNotNull, lt, sql } from "drizzle-orm"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; +import { analyticsEnabled } from "../lib/analytics-gate.js"; import { getMaxAgeMs } from "../lib/cleanup.js"; import { deletePrefix, listJobDirs, type ObjectInfo } from "../lib/object-storage.js"; import { getSettingNumber } from "../lib/settings-helpers.js"; @@ -67,9 +68,85 @@ export async function enqueueSystemJob(name: string): Promise { await q.add(name, {} as never); } +// -- Cron monitors (canonical host only) -------------------------------------- + +// Sentry cron monitors for the repeatable sweeps, so a silently-dead scheduler +// (disks fill, audit rows never prune) is caught. OFF by default: only the +// maintainers' canonical hosted instance sets SENTRY_CRON_MONITORS=1, because +// thousands of self-hosted installs checking into one org's monitors would make +// a "missed" signal meaningless. High-frequency pollers (siemForward, +// alertEvaluator) and on-demand jobs (gdprExport) are intentionally excluded. +type MonitorSchedule = + | { type: "crontab"; value: string } + | { type: "interval"; value: number; unit: "minute" | "hour" | "day" }; + +interface MonitorConfig { + schedule: MonitorSchedule; + checkinMargin: number; + maxRuntime: number; +} + +const MONITOR_CONFIG: Record = { + [SYSTEM_JOBS.storageTtl]: { + schedule: { + type: "interval", + value: Math.max(1, env.CLEANUP_INTERVAL_MINUTES), + unit: "minute", + }, + checkinMargin: 5, + maxRuntime: 20, + }, + [SYSTEM_JOBS.sessionPurge]: { + schedule: { type: "interval", value: 1, unit: "hour" }, + checkinMargin: 10, + maxRuntime: 5, + }, + [SYSTEM_JOBS.retention]: { + schedule: { type: "interval", value: 6, unit: "hour" }, + checkinMargin: 15, + maxRuntime: 30, + }, + [SYSTEM_JOBS.auditArchive]: { + schedule: { type: "crontab", value: "0 2 1 * *" }, + checkinMargin: 30, + maxRuntime: 60, + }, + [SYSTEM_JOBS.storageReconciliation]: { + schedule: { type: "crontab", value: "0 3 * * 0" }, + checkinMargin: 30, + maxRuntime: 60, + }, +}; + +function cronMonitorsEnabled(): boolean { + const v = process.env.SENTRY_CRON_MONITORS; + return (v === "1" || v === "true") && analyticsEnabled(); +} + +async function withCronMonitor(name: string, fn: () => Promise): Promise { + const cfg = MONITOR_CONFIG[name]; + if (!cfg || !cronMonitorsEnabled()) return fn(); + try { + const Sentry = await import("@sentry/node"); + // Monitor slugs cannot contain ":". + return await Sentry.withMonitor( + name.replace(/:/g, "-"), + fn, + cfg as Parameters[2], + ); + } catch { + // Monitoring must never break the actual job. + return fn(); + } +} + // -- Dispatcher --------------------------------------------------------------- export async function runSystemJob(job: Job): Promise { + return withCronMonitor(job.name, () => dispatchSystemJob(job)); +} + +async function dispatchSystemJob(job: Job): Promise { switch (job.name) { case SYSTEM_JOBS.storageTtl: return storageTtlSweep(); diff --git a/apps/api/src/jobs/worker.ts b/apps/api/src/jobs/worker.ts index bc1c531d..0e1973d0 100644 --- a/apps/api/src/jobs/worker.ts +++ b/apps/api/src/jobs/worker.ts @@ -27,6 +27,7 @@ import { join } from "node:path"; import { context, propagation, ROOT_CONTEXT, SpanStatusCode, trace } from "@opentelemetry/api"; import { ANALYTICS_EVENTS, + extractErrorCode, getBundleForTool, getOptionalBundleForTool, isToolInputError, @@ -39,7 +40,7 @@ import { db, schema } from "../db/index.js"; import { trackEvent } from "../lib/analytics.js"; import { analyticsEnabled } from "../lib/analytics-gate.js"; import { resolveConcurrency } from "../lib/env.js"; -import { reportError, safeFormatTag } from "../lib/error-report.js"; +import { classifyError, reportError, safeFormatTag } from "../lib/error-report.js"; import { friendlyError } from "../lib/errors.js"; import { logger } from "../lib/logger.js"; import { jobDuration, jobsTotal } from "../lib/metrics.js"; @@ -209,6 +210,27 @@ export function buildLegacyResultPayload( // ── Tool job processor ───────────────────────────────────────── +/** + * Dimensions shared by both tool_used branches, kept in one place so the + * success and failure events never drift. input_format is the safe extension + * only (never the filename); is_batch flags a per-file batch child so batch + * volume is distinguishable from single runs; execution_hint records the tool's + * sync/async class. + */ +function toolUsedBaseProps(data: ToolJobData, durationMs: number): Record { + const tool = TOOLS.find((t) => t.id === data.toolId); + return { + tool_id: data.toolId, + duration_ms: durationMs, + category: tool?.category ?? "unknown", + is_ai_tool: + getBundleForTool(data.toolId) !== null || getOptionalBundleForTool(data.toolId) !== null, + is_batch: data.kind === "batch-child", + input_format: safeFormatTag(data.filename) ?? "unknown", + execution_hint: tool?.executionHint ?? "fast", + }; +} + async function processToolJob(job: Job): Promise { const data = job.data; const { jobId } = data; @@ -227,6 +249,11 @@ async function processToolJob(job: Job): Promise { "snapotter.tool_id": data.toolId, "snapotter.pool": data.pool, "snapotter.attempt_number": job.attemptsMade + 1, + // Standard messaging semantics so Sentry (when tracing is enabled) + // labels this as a queue-consumer span and the tracesSampler + // recognizes a real job execution vs a poll. + "messaging.system": "bullmq", + "messaging.destination.name": queueName(data.pool), }, }, parentCtx, @@ -451,17 +478,14 @@ async function processToolJob(job: Job): Promise { // Analytics: emit tool_used on success if (analyticsEnabled()) { - const tool = TOOLS.find((t) => t.id === data.toolId); void trackEvent( ANALYTICS_EVENTS.TOOL_USED, { - tool_id: data.toolId, + ...toolUsedBaseProps(data, durationMs), status: "completed", - duration_ms: durationMs, - category: tool?.category ?? "unknown", - is_ai_tool: - getBundleForTool(data.toolId) !== null || - getOptionalBundleForTool(data.toolId) !== null, + output_format: safeFormatTag(outName) ?? "unknown", + bytes_in: originalSize, + bytes_out: resultBuffer.length, }, data.analyticsDistinctId, ); @@ -549,18 +573,25 @@ async function processToolJob(job: Job): Promise { // Analytics: emit tool_used on failure if (analyticsEnabled()) { - const tool = TOOLS.find((t) => t.id === data.toolId); + const errorClass = isTimeout + ? "timeout" + : isCanceled + ? "cancelled" + : classifyError(err, "worker"); void trackEvent( ANALYTICS_EVENTS.TOOL_USED, { - tool_id: data.toolId, + ...toolUsedBaseProps(data, durationMs), status: "failed", - duration_ms: durationMs, - category: tool?.category ?? "unknown", - is_ai_tool: - getBundleForTool(data.toolId) !== null || - getOptionalBundleForTool(data.toolId) !== null, - error_code: isTimeout ? "timeout" : isCanceled ? "cancelled" : "processing", + error_code: isTimeout + ? "timeout" + : isCanceled + ? "cancelled" + : (extractErrorCode(err) ?? "processing"), + // A coarse, low-cardinality reason bucket so "why do tools fail" is + // answerable without leaking messages: bad input, environment, or + // our bug (classifyError's "expected" == bad user input). + error_kind: errorClass === "expected" ? "input" : errorClass, }, data.analyticsDistinctId, ); @@ -1016,6 +1047,7 @@ export function startWorkers(): void { source: "worker", pool, toolId: (job.data as ToolJobData | undefined)?.toolId, + jobId: job.id, }); }); @@ -1047,7 +1079,9 @@ export function startWorkers(): void { source: "worker", pool, toolId: (job.data as ToolJobData | undefined)?.toolId, + jobId: job.id, inputFormat: safeFormatTag((job.data as ToolJobData | undefined)?.filename), + settings: (job.data as ToolJobData | undefined)?.settings, }); }); diff --git a/apps/api/src/lib/analytics-allowlist.ts b/apps/api/src/lib/analytics-allowlist.ts index 7a86b1c9..3f2ba2e8 100644 --- a/apps/api/src/lib/analytics-allowlist.ts +++ b/apps/api/src/lib/analytics-allowlist.ts @@ -2,7 +2,21 @@ // primitives. Free-text fields (error_message, params, search query) are never // allow-listed, so tool settings and filenames cannot reach PostHog. const ALLOWED: Record> = { - tool_used: new Set(["tool_id", "status", "duration_ms", "category", "is_ai_tool", "error_code"]), + tool_used: new Set([ + "tool_id", + "status", + "duration_ms", + "category", + "is_ai_tool", + "is_batch", + "input_format", + "output_format", + "bytes_in", + "bytes_out", + "execution_hint", + "error_code", + "error_kind", + ]), pipeline_executed: new Set([ "step_count", "tool_ids", @@ -13,6 +27,8 @@ const ALLOWED: Record> = { ]), ai_bundle_action: new Set(["bundle_id", "action", "duration_ms"]), instance_started: new Set(["arch", "os_platform", "deploy_mode", "gpu_present"]), + auth_login: new Set(["method"]), + auth_login_failed: new Set(["method"]), }; function isAllowedValue(value: unknown): boolean { diff --git a/apps/api/src/lib/error-report.ts b/apps/api/src/lib/error-report.ts index 05d031d2..05c63738 100644 --- a/apps/api/src/lib/error-report.ts +++ b/apps/api/src/lib/error-report.ts @@ -37,6 +37,10 @@ export interface ReportContext { subsystem?: string; /** Safe input format (file extension) for triage; never the filename. */ inputFormat?: string; + /** BullMQ job id, so the event cross-references the jobs DB row and logs. */ + jobId?: string; + /** Tool settings; a vetted (PII-safe) projection is attached for bug events. */ + settings?: unknown; } export function classifyError(err: unknown, source?: ReportContext["source"]): ErrorClass { @@ -112,6 +116,33 @@ export function safeFormatTag(filename?: string): string | undefined { return m ? m[1].toLowerCase() : undefined; } +// Setting keys whose values can carry user data (filenames, free text, secrets) +// even when short, and which must never reach the Sentry `tool` context. +const UNSAFE_SETTING_KEY = + /pass|secret|token|credential|auth|file|name|path|url|email|user|title|text|label|query|prompt|message|caption|content|watermark/i; +const SAFE_ENUM_VALUE = /^[A-Za-z0-9_.-]{1,32}$/; + +/** + * A privacy-safe projection of tool settings for the Sentry `tool` context, so + * a bug-class event carries the knobs that triggered it (format, quality, mode, + * ...) without ever including filenames, free text, or secrets. Keeps only + * numbers, booleans, and short enum-like strings under non-sensitive keys; + * drops objects, arrays, and long strings entirely. Returns undefined when + * nothing safe survives. + */ +export function vetSettings( + settings: unknown, +): Record | undefined { + if (!settings || typeof settings !== "object" || Array.isArray(settings)) return undefined; + const out: Record = {}; + for (const [k, v] of Object.entries(settings as Record)) { + if (UNSAFE_SETTING_KEY.test(k)) continue; + if (typeof v === "number" || typeof v === "boolean") out[k] = v; + else if (typeof v === "string" && SAFE_ENUM_VALUE.test(v)) out[k] = v; + } + return Object.keys(out).length ? out : undefined; +} + /** Fire-and-forget; never throws, never blocks. */ export async function reportError(err: unknown, ctx: ReportContext): Promise { try { @@ -135,10 +166,42 @@ export async function reportError(err: unknown, ctx: ReportContext): Promise { + try { + if (!instanceId) return; + const Sentry = await import("@sentry/node"); + Sentry.getGlobalScope().setTag("instance_id", instanceId); + } catch { + // telemetry must never throw + } +} diff --git a/apps/api/src/lib/sentry-scrub.ts b/apps/api/src/lib/sentry-scrub.ts index 44f438eb..0ac907ed 100644 --- a/apps/api/src/lib/sentry-scrub.ts +++ b/apps/api/src/lib/sentry-scrub.ts @@ -23,6 +23,8 @@ const TAG_ALLOWLIST = new Set([ "subsystem", "status_code", "input_format", + "job_id", + "instance_id", ]); const URL_RE = /https?:\/\/[^\s"')]+/g; @@ -59,6 +61,16 @@ function scrubBreadcrumb(entry: unknown): AnyEvent | null { if (b[k] !== undefined) out[k] = b[k]; } if (typeof b.message === "string") out.message = scrubText(b.message); + // For http breadcrumbs keep the non-PII status_code + method (the url is the + // sensitive part, dropped with the rest of `data`): they answer "what request + // failed right before the error". + if (b.category === "http") { + const data = asObj(b.data); + const safe: AnyEvent = {}; + if (data?.status_code !== undefined) safe.status_code = data.status_code; + if (typeof data?.method === "string") safe.method = data.method; + if (Object.keys(safe).length) out.data = safe; + } return out; } @@ -102,6 +114,17 @@ export function buildBeforeSend(isActive: () => boolean) { 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 }; + // The `tool` context is set only by reportError from an already-vetted + // settings projection; re-enforce primitives-only here as a final boundary. + const tool = asObj(ctx?.tool); + if (tool) { + const safe: AnyEvent = {}; + for (const [k, v] of Object.entries(tool)) { + if (typeof v === "number" || typeof v === "boolean") safe[k] = v; + else if (typeof v === "string" && v.length <= 32) safe[k] = v; + } + if (Object.keys(safe).length) keep.tool = safe; + } event.contexts = Object.keys(keep).length ? keep : undefined; const tags = asObj(event.tags); diff --git a/apps/api/src/lib/sentry-tracing.ts b/apps/api/src/lib/sentry-tracing.ts new file mode 100644 index 00000000..678497b9 --- /dev/null +++ b/apps/api/src/lib/sentry-tracing.ts @@ -0,0 +1,52 @@ +/** + * Sentry performance-tracing sampler for the API, kept pure (no @sentry/node + * import) so it is unit-testable without the SDK. + * + * Tracing is OFF unless SENTRY_TRACES_SAMPLE_RATE is a positive number (see + * instrument.ts). The July 2026 quota incident came from the default redis and + * postgres integrations turning BullMQ blocking-poll commands and pg idle + * connection pings into standalone root transactions the moment any sampler was + * enabled. This sampler returns 0 for exactly those, three independent ways + * (op prefix, db.system, messaging.system + name), so a poll storm can never + * count against quota again, while real HTTP requests and named job executions + * still sample. + */ +export interface SamplingContext { + name?: string; + attributes?: Record; + /** Sentry's helper: returns the parent's decision when there is one, else the fallback. */ + inheritOrSampleWith?: (fallback: number) => number; +} + +export function buildTracesSampler(httpRate: number): (ctx: SamplingContext) => number { + return (ctx: SamplingContext): number => { + const attrs = ctx.attributes ?? {}; + const name = ctx.name ?? ""; + const inherit = (fallback: number): number => + typeof ctx.inheritOrSampleWith === "function" ? ctx.inheritOrSampleWith(fallback) : fallback; + + // Standalone db / redis root transactions (pg idle pings, BullMQ blocking + // polls). The exact incident source: zero them outright. + const op = String(attrs["sentry.op"] ?? ""); + if (op.startsWith("db")) return 0; + if (attrs["db.system"] === "redis") return 0; + + // Any queue root span: only sample a real job execution we explicitly named + // "job " (see worker.ts); drop the continuous poll transactions. + if (attrs["messaging.system"] !== undefined) { + // Sample a real job execution (the worker's "job.process" span, or a + // "job " span); drop the continuous BullMQ poll transactions. + const isJob = name === "job.process" || name.startsWith("job "); + return isJob ? Math.min(httpRate, 0.2) : 0; + } + + // Never sample infra endpoints, even though they are real HTTP. + if (/\/(healthz|readyz|metrics)\b/.test(name)) return 0; + + // Real inbound HTTP: sample at the configured rate, honoring parent traces. + if (attrs["http.request.method"] !== undefined) return inherit(httpRate); + + // Anything else: follow the parent decision, else drop. + return inherit(0); + }; +} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index aa84516c..edf32ecd 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -1,11 +1,13 @@ import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto"; import { promisify } from "node:util"; +import { ANALYTICS_EVENTS } from "@snapotter/shared"; import { and, asc, eq, ne, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { sharedRedis } from "../jobs/connection.js"; +import { trackEvent } from "../lib/analytics.js"; import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js"; import { authAttempts } from "../lib/metrics.js"; import { getSettingNumber, getSettingString } from "../lib/settings-helpers.js"; @@ -380,6 +382,7 @@ export async function authRoutes(app: FastifyInstance): Promise { // response timing doesn't reveal whether the username exists. await verifyPassword(body.password, await getDummyHash()); authAttempts.inc({ method: "password", result: "failure" }); + void trackEvent(ANALYTICS_EVENTS.AUTH_LOGIN_FAILED, { method: "password" }); await audit("LOGIN_FAILED", { username: sanitizeAuditInput(body.username), reason: "unknown_user", @@ -390,6 +393,7 @@ export async function authRoutes(app: FastifyInstance): Promise { const valid = await verifyPassword(body.password, user.passwordHash); if (!valid) { authAttempts.inc({ method: "password", result: "failure" }); + void trackEvent(ANALYTICS_EVENTS.AUTH_LOGIN_FAILED, { method: "password" }); await audit("LOGIN_FAILED", { username: sanitizeAuditInput(body.username), reason: "bad_password", @@ -463,6 +467,7 @@ export async function authRoutes(app: FastifyInstance): Promise { } authAttempts.inc({ method: "password", result: "success" }); + void trackEvent(ANALYTICS_EVENTS.AUTH_LOGIN, { method: "password" }); await audit("LOGIN_SUCCESS", { userId: user.id, username: user.username }); const [teamRow] = await db.select().from(schema.teams).where(eq(schema.teams.id, user.team)); diff --git a/apps/api/src/plugins/oidc.ts b/apps/api/src/plugins/oidc.ts index 6fddcf39..bcf6f1b8 100644 --- a/apps/api/src/plugins/oidc.ts +++ b/apps/api/src/plugins/oidc.ts @@ -1,11 +1,13 @@ import { randomUUID } from "node:crypto"; import type {} from "@fastify/cookie"; +import { ANALYTICS_EVENTS } from "@snapotter/shared"; import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import * as oidc from "openid-client"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { sharedRedis } from "../jobs/connection.js"; +import { trackEvent } from "../lib/analytics.js"; import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js"; import { resolveExternalUser, sanitizeUsername } from "../lib/external-auth-resolver.js"; import { authAttempts } from "../lib/metrics.js"; @@ -347,6 +349,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise { }); authAttempts.inc({ method: "oidc", result: "success" }); + void trackEvent(ANALYTICS_EVENTS.AUTH_LOGIN, { method: "oidc" }); await audit("OIDC_LOGIN_SUCCESS", { userId: resolvedUser.id, username: resolvedUser.username, diff --git a/apps/landing/src/components/Analytics.astro b/apps/landing/src/components/Analytics.astro new file mode 100644 index 00000000..051fbc93 --- /dev/null +++ b/apps/landing/src/components/Analytics.astro @@ -0,0 +1,59 @@ +--- +// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes these via define:vars + conditional render. +// Minimal, opt-in landing analytics. Respects the site's near-zero-client-JS +// ethos: no SDK, just a tiny inline $pageview capture to PostHog's endpoint, +// and ONLY when PUBLIC_POSTHOG_KEY is set at build time (nothing is emitted to +// the page otherwise). This is a STANDALONE acquisition funnel: a self-hosted +// app instance mints its own anonymous id, so landing -> app cannot be joined; +// correlate landing conversions to fleet growth via the app's instance_started +// event instead. To track a CTA, add data-ph="event_name" to the element. +const key = import.meta.env.PUBLIC_POSTHOG_KEY; +const host = (import.meta.env.PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com").replace(/\/$/, ""); +--- + +{ + key && ( + + ) +} diff --git a/apps/landing/src/layouts/Base.astro b/apps/landing/src/layouts/Base.astro index 4effe96b..1f98b5e6 100644 --- a/apps/landing/src/layouts/Base.astro +++ b/apps/landing/src/layouts/Base.astro @@ -2,6 +2,7 @@ // biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values. import "@/styles/globals.css"; import { TOOLS } from "@snapotter/shared"; +import Analytics from "@/components/Analytics.astro"; import LaunchBanner from "@/components/LaunchBanner.astro"; import MachineTranslationBanner from "@/components/MachineTranslationBanner.astro"; import { LANDING_LOCALES, t } from "@/i18n"; @@ -161,5 +162,6 @@ const htmlDir = dirFor(locale); }); })(); + diff --git a/apps/web/src/components/common/review-panel.tsx b/apps/web/src/components/common/review-panel.tsx index 180b9e4f..8690d095 100644 --- a/apps/web/src/components/common/review-panel.tsx +++ b/apps/web/src/components/common/review-panel.tsx @@ -75,7 +75,7 @@ export function ReviewPanel({ const handleDownload = () => { import("@/lib/analytics").then(({ track }) => { - track(ANALYTICS_EVENTS.RESULT_DOWNLOADED, {}); + track(ANALYTICS_EVENTS.RESULT_DOWNLOADED, { tool_id: currentToolId }); }); triggerDownload(downloadUrl, filename); }; @@ -99,6 +99,12 @@ export function ReviewPanel({ }); if (!uploadRes.ok) throw new Error("Upload failed"); setSaveStatus("saved"); + // "Save to library" is the real success signal for a self-hosted tool + // (there is no purchase). result_saved was defined + allowlisted but never + // fired, so save-rate was unmeasurable. + import("@/lib/analytics").then(({ track }) => { + track(ANALYTICS_EVENTS.RESULT_SAVED, { tool_id: currentToolId }); + }); } catch { setSaveStatus("error"); setTimeout(() => setSaveStatus("idle"), 3000); diff --git a/apps/web/src/components/common/tool-card.tsx b/apps/web/src/components/common/tool-card.tsx index 6df246f2..5dfa0436 100644 --- a/apps/web/src/components/common/tool-card.tsx +++ b/apps/web/src/components/common/tool-card.tsx @@ -20,6 +20,8 @@ interface ToolCardProps { variant?: "compact" | "descriptive"; showModalityBadge?: boolean; showPin?: boolean; + /** Fired when the card is clicked (before navigation), for search attribution. */ + onNavigate?: () => void; } function PinButton({ toolId }: { toolId: string }) { @@ -57,7 +59,13 @@ const SECTION_COLOR_MAP: Record = Object.fromEntries( SECTIONS.map((s) => [s.id, s.color]), ); -export function ToolCard({ tool, variant = "compact", showModalityBadge, showPin }: ToolCardProps) { +export function ToolCard({ + tool, + variant = "compact", + showModalityBadge, + showPin, + onNavigate, +}: ToolCardProps) { const { t } = useTranslation(); const IconComponent = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage; @@ -110,6 +118,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge, showPin const card = ( void }) { // Issue #6: Export using Konva stage.toDataURL for correct output const handleExport = useCallback(() => { + import("@/lib/analytics").then(({ track }) => + track(ANALYTICS_EVENTS.EDITOR_EXPORTED, { output_format: settings.format }), + ); const stage = editorStageRefHolder.current; if (!stage) return; diff --git a/apps/web/src/components/features/feature-install-prompt.tsx b/apps/web/src/components/features/feature-install-prompt.tsx index 2dd050a1..d00a5f46 100644 --- a/apps/web/src/components/features/feature-install-prompt.tsx +++ b/apps/web/src/components/features/feature-install-prompt.tsx @@ -1,4 +1,5 @@ import { + ANALYTICS_EVENTS, FEATURE_BUNDLES, type FeatureBundleState, getRequiredBundlesForTool, @@ -145,6 +146,16 @@ export function FeatureInstallPrompt({ return () => clearInterval(interval); }, [isInstalling]); + useEffect(() => { + if (!isAdmin || bundle.status === "installed") return; + // Install-prompt impression: the top of the AI-adoption funnel + // (prompted -> ai_bundle_action install -> tool_used is_ai_tool). Non-admins + // see a different "not enabled" message, not this prompt. + import("@/lib/analytics").then(({ track }) => { + track(ANALYTICS_EVENTS.AI_BUNDLE_PROMPTED, { bundle_id: bundle.id }); + }); + }, [bundle.id, bundle.status, isAdmin]); + const eta = (() => { if (!progress || !startTime || progress.percent <= 2) return null; const elapsed = now - startTime; diff --git a/apps/web/src/contexts/i18n-context.tsx b/apps/web/src/contexts/i18n-context.tsx index 33476248..bdab986a 100644 --- a/apps/web/src/contexts/i18n-context.tsx +++ b/apps/web/src/contexts/i18n-context.tsx @@ -116,6 +116,9 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { useEffect(() => { let cancelled = false; + // Tag Sentry with the active locale so locale-specific i18n/interpolation + // crashes are identifiable as such. + import("@/lib/analytics").then(({ setSentryTag }) => setSentryTag("locale", locale)); loadLocale(locale).then((t) => { if (!cancelled) setTranslations(t); }); diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index 26a24ea0..b1e084c0 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -566,6 +566,17 @@ export function useToolProcessor(toolId: string) { return; } + // batch_processed fires once for the batch as a unit (distinct from the N + // per-file tool_used events), so batch usage is separable from single runs. + const trackBatch = (status: "completed" | "failed") => + void import("@/lib/analytics").then(({ track }) => + track(ANALYTICS_EVENTS.BATCH_PROCESSED, { + tool_id: toolId, + file_count: files.length, + status, + }), + ); + const { updateEntry, setBatchZip } = useFileStore.getState(); setError(null); @@ -648,6 +659,7 @@ export function useToolProcessor(toolId: string) { setError(errorMsg); setProcessing(false); setProgress(IDLE_PROGRESS); + trackBatch("failed"); return; } @@ -693,6 +705,7 @@ export function useToolProcessor(toolId: string) { setProcessing(false); setProgress(IDLE_PROGRESS); clearActiveJob(); + trackBatch("completed"); } catch (err) { if (elapsedRef.current) clearInterval(elapsedRef.current); if (eventSourceRef.current) { @@ -703,6 +716,7 @@ export function useToolProcessor(toolId: string) { setProcessing(false); setProgress(IDLE_PROGRESS); clearActiveJob(); + trackBatch("failed"); } }, [toolId, processFiles, setProcessing, setError, clearActiveJob, toolName], diff --git a/apps/web/src/lib/analytics.ts b/apps/web/src/lib/analytics.ts index 7aaca318..a0e99161 100644 --- a/apps/web/src/lib/analytics.ts +++ b/apps/web/src/lib/analytics.ts @@ -1,4 +1,5 @@ import type { AnalyticsConfig } from "@snapotter/shared"; +import { flushEarlyErrors } from "./early-errors"; type PostHogInstance = import("posthog-js").PostHog; @@ -17,6 +18,13 @@ const ALLOWED: Record> = { search: new Set(["results_count", "clicked_tool_id"]), ai_bundle_prompted: new Set(["bundle_id"]), batch_processed: new Set(["tool_id", "file_count", "status"]), + editor_opened: new Set([]), + editor_tool_used: new Set(["editor_tool"]), + editor_exported: new Set(["output_format"]), + pipeline_opened: new Set([]), + pipeline_step_added: new Set(["tool_id"]), + pipeline_saved: new Set(["step_count"]), + pipeline_template_selected: new Set(["template_id"]), }; function sanitize(event: string, properties?: Record): Record { @@ -46,11 +54,29 @@ export async function initAnalytics(config: AnalyticsConfig): Promise { posthogJs.init(config.posthogApiKey, { api_host: config.posthogHost, autocapture: false, - capture_pageview: true, + // Fire $pageview on SPA history changes, not just the initial hard + // load, so react-router route changes (tool pages, editor, automate, + // files) are captured. capture_pageleave gives accurate time-on-page. + capture_pageview: "history_change", + capture_pageleave: true, disable_session_recording: true, ip: false, persistence: "localStorage", person_profiles: "identified_only", + // Last-line PII boundary at the SDK, independent of track()'s per-call + // sanitize(): strip any query string / fragment from URL properties. + // SnapOtter routes carry no PII, but pageview, survey, and other + // SDK-generated events never pass through track()'s allowlist, so the + // invariant is enforced here too. + before_send: (event) => { + const props = event?.properties; + if (props) { + const strip = (u: unknown) => (typeof u === "string" ? u.replace(/[?#].*$/, "") : u); + props.$current_url = strip(props.$current_url); + props.$referrer = strip(props.$referrer); + } + return event; + }, }) ?? null; initialized = true; enabled = true; @@ -68,8 +94,15 @@ export async function initAnalytics(config: AnalyticsConfig): Promise { } catch { // ignore } - // app_version only; no instance_id, so plain events stay person-less. - posthog.register({ app_version: (await import("@snapotter/shared")).APP_VERSION }); + // Super properties on every event. instance_id is an event PROPERTY (not an + // identify() call), so events stay anonymous and person-less while enabling + // fleet rollups ("how many distinct instances use tool X") via a HogQL + // uniq(). Omitted when empty so we never register a blank value. + const superProps: Record = { + app_version: (await import("@snapotter/shared")).APP_VERSION, + }; + if (config.instanceId) superProps.instance_id = config.instanceId; + posthog.register(superProps); } try { @@ -100,6 +133,24 @@ export async function initAnalytics(config: AnalyticsConfig): Promise { } catch (err) { console.warn("[analytics] Sentry init failed:", err); } + + // Replay crashes captured before Sentry was ready (no-op if Sentry did not + // init, e.g. analytics disabled, so opt-out is respected). + if (enabled) void flushEarlyErrors(); +} + +/** + * Set an allowlisted Sentry tag (route / tool_id / locale / error_class, see + * sentry-scrub.ts TAG_ALLOWLIST) so web errors become filterable by which tool + * and route the user was on. No-op until Sentry is initialized; lazy so this + * module keeps no static @sentry/react import. + */ +export function setSentryTag(key: string, value: string): void { + void import("@sentry/react") + .then((Sentry) => { + if (Sentry.getClient()) Sentry.setTag(key, value); + }) + .catch(() => {}); } export function track(event: string, properties?: Record): void { diff --git a/apps/web/src/lib/early-errors.ts b/apps/web/src/lib/early-errors.ts new file mode 100644 index 00000000..799b9e3f --- /dev/null +++ b/apps/web/src/lib/early-errors.ts @@ -0,0 +1,55 @@ +/** + * Early-error buffer. Sentry initializes late in the web app (inside App, after + * the analytics config round-trip), so any crash during initial bundle eval or + * first render, often the most important ones, happens with no Sentry client + * installed and is lost. This captures those in a small buffer from the very + * first line of the entry, then replays them once Sentry is initialized. + * + * The buffer respects opt-out for free: flushEarlyErrors only sends if a Sentry + * client exists, and Sentry is initialized only when analytics is enabled. + */ +const MAX_BUFFERED = 10; +const buffer: unknown[] = []; +let capturing = false; + +function onError(e: ErrorEvent): void { + if (e.error !== undefined && e.error !== null) push(e.error); +} +function onRejection(e: PromiseRejectionEvent): void { + push(e.reason); +} +function push(err: unknown): void { + if (buffer.length < MAX_BUFFERED) buffer.push(err); +} + +/** Install global handlers before Sentry exists. Idempotent. */ +export function startEarlyErrorCapture(): void { + if (capturing || typeof window === "undefined") return; + capturing = true; + window.addEventListener("error", onError); + window.addEventListener("unhandledrejection", onRejection); +} + +/** Stop buffering and replay to Sentry. Called once Sentry is initialized. */ +export async function flushEarlyErrors(): Promise { + if (typeof window !== "undefined" && capturing) { + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onRejection); + } + capturing = false; + if (buffer.length === 0) return; + const pending = buffer.splice(0); + try { + const Sentry = await import("@sentry/react"); + if (!Sentry.getClient()) return; + for (const err of pending) Sentry.captureException(err); + } catch { + // never throw from telemetry + } +} + +/** Test-only reset. */ +export function resetEarlyErrorsForTests(): void { + buffer.length = 0; + capturing = false; +} diff --git a/apps/web/src/lib/sentry-scrub.ts b/apps/web/src/lib/sentry-scrub.ts index 97fe12a7..29b6cda2 100644 --- a/apps/web/src/lib/sentry-scrub.ts +++ b/apps/web/src/lib/sentry-scrub.ts @@ -14,6 +14,14 @@ export const IGNORE_ERRORS: (string | RegExp)[] = [ "Load failed", /^ResizeObserver loop/, "The operation was aborted.", + // Third-party browser-extension and injected-webview noise. These throw from + // the page context, so DENY_URLS on the extension origin never sees them; + // match the telltale message instead. Seen as WEB-2 (password-manager + // autofill) and WEB-7 (Android WebView bridge). Not our code. + /sendExtensionMessage/i, + /getUrlAutofillTargetingRules/i, + /onLongParse/i, + /Java exception was raised during method invocation/i, ]; export const DENY_URLS: RegExp[] = [ @@ -81,6 +89,15 @@ function scrubBreadcrumb(entry: unknown): AnyEvent | null { if (b[k] !== undefined) out[k] = b[k]; } if (typeof b.message === "string") out.message = scrubText(b.message); + // For network breadcrumbs keep the non-PII status_code + method (the url is + // dropped with the rest of `data`): "what request failed before the crash". + if (b.category === "fetch" || b.category === "xhr") { + const data = asObj(b.data); + const safe: AnyEvent = {}; + if (data?.status_code !== undefined) safe.status_code = data.status_code; + if (typeof data?.method === "string") safe.method = data.method; + if (Object.keys(safe).length) out.data = safe; + } return out; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 7e297263..7385e15f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,8 +1,13 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { startEarlyErrorCapture } from "./lib/early-errors"; import "./styles/globals.css"; +// Buffer crashes that happen before Sentry initializes (it inits late, after +// the analytics config fetch); flushEarlyErrors replays them once it is ready. +startEarlyErrorCapture(); + const rootElement = document.getElementById("root"); if (!rootElement) throw new Error("Root element not found"); createRoot(rootElement).render( diff --git a/apps/web/src/pages/automate-page.tsx b/apps/web/src/pages/automate-page.tsx index 6879c0bd..6aa7f96c 100644 --- a/apps/web/src/pages/automate-page.tsx +++ b/apps/web/src/pages/automate-page.tsx @@ -1,4 +1,4 @@ -import { modalityForExtension, type PipelineTemplate } from "@snapotter/shared"; +import { ANALYTICS_EVENTS, modalityForExtension, type PipelineTemplate } from "@snapotter/shared"; import { CheckCircle2, ChevronDown, @@ -69,6 +69,10 @@ function previewIcon(kind: string) { export function AutomatePage() { const { t } = useTranslation(); usePageTitle(t.sidebar.automate); + + useEffect(() => { + import("@/lib/analytics").then(({ track }) => track(ANALYTICS_EVENTS.PIPELINE_OPENED, {})); + }, []); const { files, entries, @@ -232,6 +236,9 @@ export function AutomatePage() { }), }); if (res.ok) { + import("@/lib/analytics").then(({ track }) => + track(ANALYTICS_EVENTS.PIPELINE_SAVED, { step_count: steps.length }), + ); const listRes = await fetch("/api/v1/pipeline/list", { headers: formatHeaders(), }); @@ -281,6 +288,9 @@ export function AutomatePage() { const handleUseTemplate = useCallback( (template: PipelineTemplate) => { loadSteps(template.steps); + import("@/lib/analytics").then(({ track }) => + track(ANALYTICS_EVENTS.PIPELINE_TEMPLATE_SELECTED, { template_id: template.id }), + ); }, [loadSteps], ); @@ -412,6 +422,9 @@ export function AutomatePage() { const handleAddStep = useCallback( (toolId: string) => { addStep(toolId); + import("@/lib/analytics").then(({ track }) => + track(ANALYTICS_EVENTS.PIPELINE_STEP_ADDED, { tool_id: toolId }), + ); if (isMobile) setMobileToolPaletteOpen(false); }, [addStep, isMobile], diff --git a/apps/web/src/pages/editor-page.tsx b/apps/web/src/pages/editor-page.tsx index 057ae817..8b664ef0 100644 --- a/apps/web/src/pages/editor-page.tsx +++ b/apps/web/src/pages/editor-page.tsx @@ -1,5 +1,5 @@ // apps/web/src/pages/editor-page.tsx -import { apiToolPath } from "@snapotter/shared"; +import { ANALYTICS_EVENTS, apiToolPath } from "@snapotter/shared"; import { Monitor } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { CanvasResizeDialog } from "@/components/editor/common/canvas-resize-dialog"; @@ -52,6 +52,10 @@ export function EditorPage() { onFillDialog: () => setFillDialogOpen(true), }); + useEffect(() => { + import("@/lib/analytics").then(({ track }) => track(ANALYTICS_EVENTS.EDITOR_OPENED, {})); + }, []); + // Listen for fill-dialog custom event (dispatched from Shift+Backspace shortcut) useEffect(() => { const handler = () => setFillDialogOpen(true); diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 22ca30e7..a844b99d 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -403,7 +403,22 @@ function SearchResults({
{results.map((tool) => ( - + { + // search -> click attribution: which result the user opened. + import("@/lib/analytics").then(({ track }) => + track(ANALYTICS_EVENTS.SEARCH, { + results_count: results.length, + clicked_tool_id: tool.id, + }), + ); + }} + /> ))}
diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index ff843c16..99958668 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -268,7 +268,11 @@ export function ToolPage() { useEffect(() => { if (tool) { recordRecentTool(tool.id); - import("@/lib/analytics").then(({ track }) => { + import("@/lib/analytics").then(({ track, setSentryTag }) => { + // Tag Sentry so a frontend crash is filterable by which tool/section the + // user was on (the web TAG_ALLOWLIST reserves these but nothing set them). + setSentryTag("tool_id", tool.id); + setSentryTag("route", toolSection(tool)); track(ANALYTICS_EVENTS.TOOL_OPENED, { tool_id: tool.id, modality: tool.modality, diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index b950b5e8..88adbac9 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -1,5 +1,6 @@ // apps/web/src/stores/editor-store.ts +import { ANALYTICS_EVENTS } from "@snapotter/shared"; import { temporal } from "zundo"; import { create } from "zustand"; import { generateId } from "@/lib/utils"; @@ -253,6 +254,13 @@ export const useEditorStore = create()( setTool: (tool) => { const { activeTool, canvasSize, cropState } = get(); + if (tool !== activeTool) { + // Which editor tools users actually engage (brush/shape/adjust/...); + // the single chokepoint, so UI clicks and keyboard shortcuts both count. + import("@/lib/analytics").then(({ track }) => + track(ANALYTICS_EVENTS.EDITOR_TOOL_USED, { editor_tool: tool }), + ); + } const leavingCrop = activeTool === "crop" && tool !== "crop"; const enteringCrop = tool === "crop" && activeTool !== "crop"; set({ diff --git a/packages/shared/src/analytics/events.ts b/packages/shared/src/analytics/events.ts index 83441929..ede0df36 100644 --- a/packages/shared/src/analytics/events.ts +++ b/packages/shared/src/analytics/events.ts @@ -14,6 +14,15 @@ export const ANALYTICS_EVENTS = { FEEDBACK_SUBMITTED: "feedback_submitted", SPONSOR_CLICKED: "sponsor_clicked", INSTANCE_STARTED: "instance_started", + EDITOR_OPENED: "editor_opened", + EDITOR_TOOL_USED: "editor_tool_used", + EDITOR_EXPORTED: "editor_exported", + PIPELINE_OPENED: "pipeline_opened", + PIPELINE_STEP_ADDED: "pipeline_step_added", + PIPELINE_SAVED: "pipeline_saved", + PIPELINE_TEMPLATE_SELECTED: "pipeline_template_selected", + AUTH_LOGIN: "auth_login", + AUTH_LOGIN_FAILED: "auth_login_failed", } as const; export type AnalyticsEvent = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS]; @@ -24,9 +33,16 @@ export interface ToolUsedProperties { duration_ms: number; category: string; is_ai_tool: boolean; - params?: Record; + is_batch: boolean; + /** Safe input extension (never the filename), e.g. "heic"; "unknown" if none. */ + input_format: string; + execution_hint: "fast" | "long"; + output_format?: string; + bytes_in?: number; + bytes_out?: number; error_code?: string; - error_message?: string; + /** Coarse failure reason so "why do tools fail" is answerable without messages. */ + error_kind?: "input" | "operational" | "bug" | "timeout" | "cancelled"; } export interface SearchProperties { @@ -55,3 +71,24 @@ export interface InstanceStartedProperties { deploy_mode: "embedded" | "external" | "native"; gpu_present: boolean; } + +export interface EditorToolUsedProperties { + /** The editor tool selected (move, brush, crop, ...); a fixed low-cardinality set. */ + editor_tool: string; +} + +export interface EditorExportedProperties { + output_format?: string; +} + +export interface PipelineStepAddedProperties { + tool_id: string; +} + +export interface PipelineSavedProperties { + step_count: number; +} + +export interface PipelineTemplateSelectedProperties { + template_id: string; +} diff --git a/tests/unit/api/analytics-allowlist.test.ts b/tests/unit/api/analytics-allowlist.test.ts index 4d82de81..d6ce6986 100644 --- a/tests/unit/api/analytics-allowlist.test.ts +++ b/tests/unit/api/analytics-allowlist.test.ts @@ -2,22 +2,38 @@ import { describe, expect, it } from "vitest"; import { sanitizeEventProperties } from "../../../apps/api/src/lib/analytics-allowlist.js"; describe("sanitizeEventProperties", () => { - it("keeps only allow-listed keys for tool_used", () => { + it("keeps the enriched allow-listed keys for tool_used and drops free-text/PII", () => { const out = sanitizeEventProperties("tool_used", { tool_id: "resize", - status: "completed", + status: "failed", duration_ms: 12, category: "image", is_ai_tool: false, + is_batch: true, + input_format: "heic", + output_format: "png", + bytes_in: 4096, + bytes_out: 2048, + execution_hint: "fast", + error_kind: "input", + error_code: "corrupt-header", error_message: "stack with /uploads/secret.docx", params: { watermark_text: "CONFIDENTIAL" }, }); expect(out).toEqual({ tool_id: "resize", - status: "completed", + status: "failed", duration_ms: 12, category: "image", is_ai_tool: false, + is_batch: true, + input_format: "heic", + output_format: "png", + bytes_in: 4096, + bytes_out: 2048, + execution_hint: "fast", + error_kind: "input", + error_code: "corrupt-header", }); expect(out).not.toHaveProperty("error_message"); expect(out).not.toHaveProperty("params"); diff --git a/tests/unit/api/capture-path.test.ts b/tests/unit/api/capture-path.test.ts index 18d90a74..486a0ce9 100644 --- a/tests/unit/api/capture-path.test.ts +++ b/tests/unit/api/capture-path.test.ts @@ -8,20 +8,33 @@ * 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"; +import { + reportError, + resetThrottleForTests, + setSentryInstanceTag, +} from "../../../apps/api/src/lib/error-report.js"; const h = vi.hoisted(() => { - const scope = { setTag: vi.fn(), setLevel: vi.fn(), setFingerprint: vi.fn() }; + const scope = { + setTag: vi.fn(), + setLevel: vi.fn(), + setFingerprint: vi.fn(), + setContext: vi.fn(), + }; + const globalScope = { setTag: vi.fn() }; return { scope, + globalScope, captureException: vi.fn(), withScope: vi.fn((cb: (s: typeof scope) => unknown) => cb(scope)), + getGlobalScope: vi.fn(() => globalScope), }; }); vi.mock("@sentry/node", () => ({ captureException: h.captureException, withScope: h.withScope, + getGlobalScope: h.getGlobalScope, })); vi.mock("../../../apps/api/src/lib/analytics-gate.js", () => ({ @@ -71,4 +84,58 @@ describe("capture path", () => { await reportError(denied, { source: "worker", pool: "image" }); expect(h.captureException).toHaveBeenCalledTimes(2); }); + + it("tags job_id so the event cross-references the DB row, logs, and PostHog stream", async () => { + await reportError(new Error("boom"), { source: "worker", pool: "image", jobId: "job-abc" }); + expect(h.scope.setTag).toHaveBeenCalledWith("job_id", "job-abc"); + }); + + it("collapses operational errors to one issue per code via fingerprint", async () => { + const full = Object.assign(new Error("disk full"), { code: "ENOSPC" }); + await reportError(full, { source: "worker", pool: "image" }); + expect(h.scope.setFingerprint).toHaveBeenCalledWith(["operational", "ENOSPC"]); + }); + + it("prefers the connectivity fingerprint for infra-connectivity operational errors", async () => { + const pg = Object.assign(new Error("Failed query: select 1"), { + cause: Object.assign(new Error("57P01"), { code: "57P01" }), + }); + await reportError(pg, { source: "worker", pool: "docs" }); + expect(h.scope.setFingerprint).toHaveBeenCalledWith(["connectivity", "pg-unavailable"]); + }); + + it("leaves bug-class errors on default per-frame grouping (no fingerprint)", async () => { + await reportError(new Error("undefined is not a function"), { + source: "worker", + pool: "image", + }); + expect(h.scope.setFingerprint).not.toHaveBeenCalled(); + }); + + it("attaches a vetted tool context for bug-class events to aid reproduction", async () => { + await reportError(new Error("boom"), { + source: "worker", + pool: "image", + settings: { format: "png", quality: 80, filename: "my secret vacation.png" }, + }); + expect(h.scope.setContext).toHaveBeenCalledWith("tool", { format: "png", quality: 80 }); + }); + + it("does not attach a settings context for non-bug errors", async () => { + const full = Object.assign(new Error("disk full"), { code: "ENOSPC" }); + await reportError(full, { source: "worker", pool: "image", settings: { format: "png" } }); + expect(h.scope.setContext).not.toHaveBeenCalled(); + }); +}); + +describe("setSentryInstanceTag", () => { + it("sets instance_id on the global scope so every event carries it", async () => { + await setSentryInstanceTag("inst-xyz"); + expect(h.globalScope.setTag).toHaveBeenCalledWith("instance_id", "inst-xyz"); + }); + + it("is a no-op on a falsy id and never throws", async () => { + await expect(setSentryInstanceTag("")).resolves.toBeUndefined(); + expect(h.globalScope.setTag).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/api/error-report.test.ts b/tests/unit/api/error-report.test.ts index 06cc78c5..65e5c15d 100644 --- a/tests/unit/api/error-report.test.ts +++ b/tests/unit/api/error-report.test.ts @@ -6,6 +6,7 @@ import { resetThrottleForTests, safeFormatTag, shouldReport, + vetSettings, } from "../../../apps/api/src/lib/error-report.js"; describe("safeFormatTag", () => { @@ -132,3 +133,37 @@ describe("errorSignature", () => { expect(errorSignature(null)).toBe("Unknown:-:-"); }); }); + +describe("vetSettings", () => { + it("keeps numbers, booleans, and short enum-like string values", () => { + expect(vetSettings({ quality: 80, lossless: true, format: "png", fit: "cover" })).toEqual({ + quality: 80, + lossless: true, + format: "png", + fit: "cover", + }); + }); + it("drops sensitive keys, free-text/PII-shaped values, objects and arrays", () => { + // A password, a filename, and watermark text must never reach Sentry; nested + // objects/arrays and long strings can carry user data, so drop them too. + expect( + vetSettings({ + password: "hunter2", + filename: "IMG_1234.png", + watermarkText: "Property of Jane", + crop: { x: 1, y: 2 }, + sizes: [1, 2, 3], + width: 1024, + format: "webp", + }), + ).toEqual({ width: 1024, format: "webp" }); + }); + it("returns undefined for non-objects and when nothing safe survives", () => { + expect(vetSettings(undefined)).toBeUndefined(); + expect(vetSettings("nope")).toBeUndefined(); + expect(vetSettings([1, 2])).toBeUndefined(); + expect( + vetSettings({ note: "a long free-text field well beyond the safe length" }), + ).toBeUndefined(); + }); +}); diff --git a/tests/unit/api/sentry-scrub.test.ts b/tests/unit/api/sentry-scrub.test.ts index 5602fe8c..c84ad576 100644 --- a/tests/unit/api/sentry-scrub.test.ts +++ b/tests/unit/api/sentry-scrub.test.ts @@ -59,18 +59,22 @@ describe("buildBeforeSend (api)", () => { ); expect(out.exception.values[0].stacktrace.frames[0].abs_path).toBe("/app/x"); }); - it("keeps the breadcrumb trail, redacting paths/urls and dropping data payloads", () => { + it("keeps the breadcrumb trail, redacting urls but keeping safe http status/method", () => { const out = send( evt({ breadcrumbs: [ - { message: "GET https://host/u/photo.jpg 200", category: "http", data: { url: "x" } }, + { + message: "GET https://host/u/photo.jpg 500", + category: "http", + data: { url: "https://host/u/photo.jpg", status_code: 500, method: "GET" }, + }, { message: "reading /Users/me/secret.txt", category: "console", level: "info" }, ], }), {}, )!; expect(out.breadcrumbs).toEqual([ - { message: "GET 200", category: "http" }, + { message: "GET 500", category: "http", data: { status_code: 500, method: "GET" } }, { message: "reading ", category: "console", level: "info" }, ]); }); @@ -102,6 +106,21 @@ describe("buildBeforeSend (api)", () => { expect(out.tags.input_format).toBe("webp"); expect(out.tags.secret_tag).toBeUndefined(); }); + it("keeps job_id and instance_id tags for cross-referencing and blast-radius triage", () => { + const out = send(evt({ tags: { job_id: "j1", instance_id: "i1", secret_tag: "x" } }), {})!; + expect(out.tags.job_id).toBe("j1"); + expect(out.tags.instance_id).toBe("i1"); + expect(out.tags.secret_tag).toBeUndefined(); + }); + it("keeps a vetted tool context (primitives) and drops non-primitive fields", () => { + const out = send( + evt({ + contexts: { tool: { format: "png", quality: 80, blob: { x: 1 }, long: "x".repeat(40) } }, + }), + {}, + )!; + expect(out.contexts.tool).toEqual({ format: "png", quality: 80 }); + }); it("drops contexts entirely when nothing allowlisted survives", () => { const out = send(evt({ contexts: { device: { hostname: "leak" } } }), {})!; expect(out.contexts).toBeUndefined(); diff --git a/tests/unit/api/sentry-tracing.test.ts b/tests/unit/api/sentry-tracing.test.ts new file mode 100644 index 00000000..bd6499da --- /dev/null +++ b/tests/unit/api/sentry-tracing.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { buildTracesSampler } from "../../../apps/api/src/lib/sentry-tracing.js"; + +const sampler = buildTracesSampler(0.05); + +describe("buildTracesSampler (the July-incident guard)", () => { + it("zeroes standalone redis root transactions (BullMQ blocking polls)", () => { + expect(sampler({ name: "BRPOPLPUSH", attributes: { "db.system": "redis" } })).toBe(0); + }); + + it("zeroes standalone db root transactions (pg idle pings) by op prefix", () => { + expect(sampler({ name: "SELECT 1", attributes: { "sentry.op": "db.query" } })).toBe(0); + expect(sampler({ name: "pg", attributes: { "sentry.op": "db.redis" } })).toBe(0); + }); + + it("drops queue poll transactions but samples real job executions", () => { + expect(sampler({ name: "queue.poll", attributes: { "messaging.system": "bullmq" } })).toBe(0); + expect( + sampler({ name: "job.process", attributes: { "messaging.system": "bullmq" } }), + ).toBeGreaterThan(0); + expect( + sampler({ name: "job resize", attributes: { "messaging.system": "bullmq" } }), + ).toBeGreaterThan(0); + }); + + it("never samples infra endpoints even as HTTP", () => { + expect(sampler({ name: "GET /healthz", attributes: { "http.request.method": "GET" } })).toBe(0); + expect(sampler({ name: "GET /readyz", attributes: { "http.request.method": "GET" } })).toBe(0); + expect(sampler({ name: "GET /metrics", attributes: { "http.request.method": "GET" } })).toBe(0); + }); + + it("samples real inbound HTTP at the configured rate", () => { + expect( + sampler({ + name: "POST /api/v1/tools/image/resize", + attributes: { "http.request.method": "POST" }, + }), + ).toBe(0.05); + }); + + it("respects an explicit parent sampling decision via inheritOrSampleWith", () => { + expect( + sampler({ + name: "GET /api/v1/x", + attributes: { "http.request.method": "GET" }, + inheritOrSampleWith: () => 1, + }), + ).toBe(1); + }); + + it("drops anything unrecognized when there is no parent", () => { + expect(sampler({ name: "mystery", attributes: {} })).toBe(0); + expect(sampler({})).toBe(0); + }); +}); diff --git a/tests/unit/shared/analytics-events.test.ts b/tests/unit/shared/analytics-events.test.ts index 09db30ac..f31888ad 100644 --- a/tests/unit/shared/analytics-events.test.ts +++ b/tests/unit/shared/analytics-events.test.ts @@ -2,8 +2,8 @@ import { ANALYTICS_EVENTS } from "@snapotter/shared"; import { describe, expect, it } from "vitest"; describe("ANALYTICS_EVENTS", () => { - it("has exactly 15 event keys", () => { - expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(15); + it("has exactly 24 event keys", () => { + expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(24); }); it("contains the expected keys", () => { @@ -22,6 +22,15 @@ describe("ANALYTICS_EVENTS", () => { expect(ANALYTICS_EVENTS).toHaveProperty("FEEDBACK_SUBMITTED"); expect(ANALYTICS_EVENTS).toHaveProperty("SPONSOR_CLICKED"); expect(ANALYTICS_EVENTS).toHaveProperty("INSTANCE_STARTED"); + expect(ANALYTICS_EVENTS).toHaveProperty("EDITOR_OPENED"); + expect(ANALYTICS_EVENTS).toHaveProperty("EDITOR_TOOL_USED"); + expect(ANALYTICS_EVENTS).toHaveProperty("EDITOR_EXPORTED"); + expect(ANALYTICS_EVENTS).toHaveProperty("PIPELINE_OPENED"); + expect(ANALYTICS_EVENTS).toHaveProperty("PIPELINE_STEP_ADDED"); + expect(ANALYTICS_EVENTS).toHaveProperty("PIPELINE_SAVED"); + expect(ANALYTICS_EVENTS).toHaveProperty("PIPELINE_TEMPLATE_SELECTED"); + expect(ANALYTICS_EVENTS).toHaveProperty("AUTH_LOGIN"); + expect(ANALYTICS_EVENTS).toHaveProperty("AUTH_LOGIN_FAILED"); }); it("all event values are strings", () => { diff --git a/tests/unit/web/early-errors.test.ts b/tests/unit/web/early-errors.test.ts new file mode 100644 index 00000000..25e4735f --- /dev/null +++ b/tests/unit/web/early-errors.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + flushEarlyErrors, + resetEarlyErrorsForTests, + startEarlyErrorCapture, +} from "../../../apps/web/src/lib/early-errors.js"; + +const h = vi.hoisted(() => ({ captureException: vi.fn(), client: {} as object | undefined })); +vi.mock("@sentry/react", () => ({ + captureException: h.captureException, + getClient: () => h.client, +})); + +// Guard so jsdom does not surface these synthetic error events as "uncaught" +// (production code intentionally never preventDefaults real errors). +const guard = (e: Event) => e.preventDefault(); +beforeEach(() => { + resetEarlyErrorsForTests(); + vi.clearAllMocks(); + h.client = {}; + window.addEventListener("error", guard); +}); +afterEach(() => window.removeEventListener("error", guard)); + +describe("early error buffer", () => { + it("buffers a pre-init error and replays it to Sentry on flush", async () => { + startEarlyErrorCapture(); + const boom = new Error("early boom"); + window.dispatchEvent(new ErrorEvent("error", { error: boom, cancelable: true })); + + await flushEarlyErrors(); + + expect(h.captureException).toHaveBeenCalledTimes(1); + expect(h.captureException).toHaveBeenCalledWith(boom); + }); + + it("does not replay when Sentry never initialized (opt-out safe)", async () => { + startEarlyErrorCapture(); + window.dispatchEvent(new ErrorEvent("error", { error: new Error("x"), cancelable: true })); + h.client = undefined; // no Sentry client -> analytics off + + await flushEarlyErrors(); + + expect(h.captureException).not.toHaveBeenCalled(); + }); + + it("stops buffering after flush so post-init errors are left to the SDK", async () => { + startEarlyErrorCapture(); + await flushEarlyErrors(); + window.dispatchEvent(new ErrorEvent("error", { error: new Error("late"), cancelable: true })); + await flushEarlyErrors(); + + expect(h.captureException).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/web/sentry-scrub.test.ts b/tests/unit/web/sentry-scrub.test.ts index aa8b89ff..aa21a50a 100644 --- a/tests/unit/web/sentry-scrub.test.ts +++ b/tests/unit/web/sentry-scrub.test.ts @@ -71,19 +71,23 @@ describe("buildWebBeforeSend", () => { expect(buildWebBeforeSend(() => false)(baseEvent(), {})).toBeNull(); }); - it("keeps the breadcrumb trail, redacting paths/urls and dropping data payloads", () => { + it("keeps the breadcrumb trail, redacting urls but keeping safe fetch status/method", () => { const send = buildWebBeforeSend(() => true); const out = send( baseEvent({ breadcrumbs: [ - { message: "fetch https://host/user.png", category: "fetch", data: { url: "x" } }, + { + message: "fetch https://host/user.png", + category: "fetch", + data: { url: "https://host/user.png", status_code: 500, method: "POST" }, + }, { message: "open /Users/a/secret.pdf", category: "console", level: "warning" }, ], }), {}, )!; expect(out.breadcrumbs).toEqual([ - { message: "fetch ", category: "fetch" }, + { message: "fetch ", category: "fetch", data: { status_code: 500, method: "POST" } }, { message: "open ", category: "console", level: "warning" }, ]); });