mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(telemetry): Sentry + PostHog quality pass (#546)
Comprehensive telemetry quality improvements across Sentry and PostHog, grounded in an audit of the live data plus current best-practice research. Sentry: job_id/instance_id tags, operational fingerprinting, PII-safe settings context on bug events, web tag population + extension-noise filtering, an early-crash buffer, http status/method kept on breadcrumbs, and a gated-off-by-default performance-tracing re-enable (tracesSampler that zeroes db/redis/queue-poll root spans + drops the Redis integration) with worker job spans and canonical-host cron monitors. PostHog: history_change SPA pageviews, instance_id super property for fleet rollups, enriched tool_used (formats, byte sizes, is_batch, execution_hint, real error_kind taxonomy), the previously-dead result_saved/batch_processed/ai_bundle_prompted events fired, search click-through, editor + Automate authoring + auth instrumentation, a before_send PII boundary, and minimal opt-in landing-site pageviews.
This commit is contained in:
+10
-6
@@ -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<string> {
|
||||
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<string, string> = {
|
||||
|
||||
@@ -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<Parameters<typeof Sentry.init>[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
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, MonitorConfig> = {
|
||||
[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<unknown>): Promise<unknown> {
|
||||
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<typeof Sentry.withMonitor>[2],
|
||||
);
|
||||
} catch {
|
||||
// Monitoring must never break the actual job.
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Dispatcher ---------------------------------------------------------------
|
||||
|
||||
export async function runSystemJob(job: Job): Promise<unknown> {
|
||||
return withCronMonitor(job.name, () => dispatchSystemJob(job));
|
||||
}
|
||||
|
||||
async function dispatchSystemJob(job: Job): Promise<unknown> {
|
||||
switch (job.name) {
|
||||
case SYSTEM_JOBS.storageTtl:
|
||||
return storageTtlSweep();
|
||||
|
||||
+50
-16
@@ -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<string, unknown> {
|
||||
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<ToolJobData>): Promise<ToolJobResult> {
|
||||
const data = job.data;
|
||||
const { jobId } = data;
|
||||
@@ -227,6 +249,11 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
||||
"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<ToolJobData>): Promise<ToolJobResult> {
|
||||
|
||||
// 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<ToolJobData>): Promise<ToolJobResult> {
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, ReadonlySet<string>> = {
|
||||
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<string, ReadonlySet<string>> = {
|
||||
]),
|
||||
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 {
|
||||
|
||||
@@ -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<string, string | number | boolean> | undefined {
|
||||
if (!settings || typeof settings !== "object" || Array.isArray(settings)) return undefined;
|
||||
const out: Record<string, string | number | boolean> = {};
|
||||
for (const [k, v] of Object.entries(settings as Record<string, unknown>)) {
|
||||
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<void> {
|
||||
try {
|
||||
@@ -135,10 +166,42 @@ export async function reportError(err: unknown, ctx: ReportContext): Promise<voi
|
||||
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]);
|
||||
if (ctx.jobId) scope.setTag("job_id", ctx.jobId);
|
||||
if (net) {
|
||||
scope.setFingerprint(["connectivity", net]);
|
||||
} else if (cls === "operational") {
|
||||
// Collapse an operational class (bad DB creds, full disk, ...) into a
|
||||
// single issue keyed on its code, instead of fragmenting into a
|
||||
// separate issue per call site/stack frame (the pg-auth flood showed up
|
||||
// as 5 issues). bug-class errors keep default per-frame grouping, where
|
||||
// distinct frames usually are distinct bugs.
|
||||
scope.setFingerprint(["operational", code ?? (err as { name?: string }).name ?? "op"]);
|
||||
}
|
||||
if (cls === "bug" && ctx.settings !== undefined) {
|
||||
// Attach the knobs that triggered a bug (format, quality, mode, ...) so
|
||||
// it is reproducible, without leaking filenames, free text, or secrets.
|
||||
const vetted = vetSettings(ctx.settings);
|
||||
if (vetted) scope.setContext("tool", vetted);
|
||||
}
|
||||
Sentry.captureException(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
} catch {
|
||||
// telemetry must never throw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the anonymized instance id as a GLOBAL Sentry tag so every event (errors
|
||||
* and SDK-captured uncaught exceptions) carries it. Lets triage tell "one
|
||||
* broken install" from "the whole fleet" and cross-references a Sentry event to
|
||||
* that instance's PostHog stream (same instance_id). Called once at boot.
|
||||
*/
|
||||
export async function setSentryInstanceTag(instanceId: string): Promise<void> {
|
||||
try {
|
||||
if (!instanceId) return;
|
||||
const Sentry = await import("@sentry/node");
|
||||
Sentry.getGlobalScope().setTag("instance_id", instanceId);
|
||||
} catch {
|
||||
// telemetry must never throw
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
/** 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 <name>" (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 <name>" 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);
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -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<void> {
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -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 && (
|
||||
<script is:inline define:vars={{ key, host }}>
|
||||
(function () {
|
||||
try {
|
||||
var id = localStorage.getItem("ph_did");
|
||||
if (!id) {
|
||||
id =
|
||||
(window.crypto && crypto.randomUUID && crypto.randomUUID()) ||
|
||||
Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||
localStorage.setItem("ph_did", id);
|
||||
}
|
||||
function send(event, props) {
|
||||
var body = JSON.stringify({
|
||||
api_key: key,
|
||||
event: event,
|
||||
distinct_id: id,
|
||||
properties: Object.assign(
|
||||
// origin + pathname only: never the query string (no PII).
|
||||
{ $current_url: location.origin + location.pathname },
|
||||
props || {},
|
||||
),
|
||||
});
|
||||
var url = host + "/capture/";
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
|
||||
} else {
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body,
|
||||
keepalive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
send("$pageview");
|
||||
// Opt-in CTA tracking: <a data-ph="download_click"> etc.
|
||||
document.addEventListener("click", function (e) {
|
||||
var el = e.target && e.target.closest ? e.target.closest("[data-ph]") : null;
|
||||
if (el) send(el.getAttribute("data-ph"), {});
|
||||
});
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string> = 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 = (
|
||||
<Link
|
||||
to={tool.route}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 rounded-lg border border-border/60 bg-card transition-all",
|
||||
"hover:border-border hover:shadow-sm",
|
||||
@@ -156,6 +165,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge, showPin
|
||||
return (
|
||||
<Link
|
||||
to={tool.route}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-2.5 px-3 rounded-lg transition-colors",
|
||||
"hover:bg-muted",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// apps/web/src/components/editor/common/export-dialog.tsx
|
||||
|
||||
import { apiToolPath } from "@snapotter/shared";
|
||||
import { ANALYTICS_EVENTS, apiToolPath } from "@snapotter/shared";
|
||||
import {
|
||||
Check,
|
||||
ClipboardCopy,
|
||||
@@ -158,6 +158,9 @@ export function ExportDialog({ onClose }: { onClose: () => 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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string, ReadonlySet<string>> = {
|
||||
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<string>([]),
|
||||
editor_tool_used: new Set(["editor_tool"]),
|
||||
editor_exported: new Set(["output_format"]),
|
||||
pipeline_opened: new Set<string>([]),
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
@@ -46,11 +54,29 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
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<void> {
|
||||
} 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<string, string> = {
|
||||
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<void> {
|
||||
} 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<string, unknown>): void {
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -403,7 +403,22 @@ function SearchResults({
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{results.map((tool) => (
|
||||
<ToolCard key={tool.id} tool={tool} variant="descriptive" showModalityBadge showPin />
|
||||
<ToolCard
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
variant="descriptive"
|
||||
showModalityBadge
|
||||
showPin
|
||||
onNavigate={() => {
|
||||
// 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,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-1 text-center">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<EditorState & EditorStateExtensions>()(
|
||||
|
||||
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({
|
||||
|
||||
@@ -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<string, string | number | boolean>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <url> 200", category: "http" },
|
||||
{ message: "GET <url> 500", category: "http", data: { status_code: 500, method: "GET" } },
|
||||
{ message: "reading <path>", 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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 <url>", category: "fetch" },
|
||||
{ message: "fetch <url>", category: "fetch", data: { status_code: 500, method: "POST" } },
|
||||
{ message: "open <path>", category: "console", level: "warning" },
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user