mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(analytics): instance census, full capture, richer error context (#511)
Add a once-per-boot instance_started event (arch, os, deploy_mode, gpu_present) so the fleet architecture mix is measurable. It reuses the existing per-instance instance_id and is exempt from the volume sample rate, since a census that fires once per boot must not be thinned. Restore useful capture depth now that the sponsored plan removes the quota pressure behind the earlier hardening: - PostHog sample rate 0.1 to 1.0 (full analytics when enabled); the property allowlist still blocks file data. - Sentry per-instance ceiling 20 to 500/hr, breadcrumb trail restored (sanitized: urls/paths redacted, data payloads dropped), full stack paths kept; local vars, request bodies, and PII still dropped. Both api and web. Honor ANALYTICS_ENABLED=false as an opt-out alias: it was documented on the Docker Hub README but never wired in 2.x, so anyone who set it was still tracked. All capture stays behind the analytics opt-out gate.
This commit is contained in:
@@ -86,6 +86,7 @@ LOG_DIR=./data/logs # rotating log ring for support bundles
|
||||
# Runtime kill switch: set to 0 to disable ALL telemetry (Sentry + PostHog)
|
||||
# for this instance without rebuilding. The in-app admin toggle does the same
|
||||
# from Settings; this env var also covers boot-time crashes and CI fleets.
|
||||
# ANALYTICS_ENABLED=false is honored as an alias (0/off also work).
|
||||
# SNAPOTTER_TELEMETRY=1
|
||||
# Label this instance's error reports (shows as the Sentry environment).
|
||||
# SNAPOTTER_ENV=production
|
||||
|
||||
@@ -5,7 +5,7 @@ import cors from "@fastify/cors";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { getDispatcherStatus, initDispatcher, isGpuAvailable } from "@snapotter/ai";
|
||||
import { APP_VERSION, SafeError } from "@snapotter/shared";
|
||||
import { ANALYTICS_EVENTS, APP_VERSION, SafeError } from "@snapotter/shared";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import Fastify from "fastify";
|
||||
import { env } from "./config.js";
|
||||
@@ -17,7 +17,7 @@ import { closeFlowProducer, closeQueueEvents, warmQueueEvents } from "./jobs/enq
|
||||
import { closeQueues, perPoolHealth, queueCounts } from "./jobs/queues.js";
|
||||
import { enqueueSystemJob, SYSTEM_JOBS, scheduleSystemJobs } from "./jobs/system-jobs.js";
|
||||
import { closeWorkers, startWorkers } from "./jobs/worker.js";
|
||||
import { initAnalytics, shutdownAnalytics } from "./lib/analytics.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";
|
||||
@@ -27,6 +27,7 @@ import { logger } from "./lib/logger.js";
|
||||
import { requestDuration } from "./lib/metrics.js";
|
||||
import { getSettingString } from "./lib/settings-helpers.js";
|
||||
import { assertStorageWritable } from "./lib/storage-writable.js";
|
||||
import { gatherSystemProperties } from "./lib/system-info.js";
|
||||
import { requirePermission } from "./permissions.js";
|
||||
import {
|
||||
authMiddleware,
|
||||
@@ -183,6 +184,11 @@ if (!env.COOKIE_SECRET) {
|
||||
await initAnalytics();
|
||||
const { primeAnalyticsGate } = await import("./lib/analytics-gate.js");
|
||||
await primeAnalyticsGate();
|
||||
// ignoreSampleRate: this once-per-boot census must not be thinned by the
|
||||
// volume sample rate that exists to throttle high-frequency usage events.
|
||||
await trackEvent(ANALYTICS_EVENTS.INSTANCE_STARTED, { ...gatherSystemProperties() }, undefined, {
|
||||
ignoreSampleRate: true,
|
||||
});
|
||||
|
||||
// Enterprise features (license-gated)
|
||||
let enterpriseLicense: { org: string; plan: string } | null = null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync } from "node:fs";
|
||||
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";
|
||||
|
||||
// Sentry inits at process load, before the gate cache is primed. Until the
|
||||
@@ -8,17 +8,6 @@ import { buildBeforeSend } from "./lib/sentry-scrub.js";
|
||||
// so an opted-out instance never reports even a boot-window crash.
|
||||
const sentryActive = () => gatePrimed() && analyticsEnabled();
|
||||
|
||||
// All-in-one detection: docker/entrypoint.sh exports EMBEDDED_MODE=1 before
|
||||
// exec'ing s6-overlay, and the snapotter service run script is with-contenv,
|
||||
// so the marker reaches this process. URL absence is not a usable signal:
|
||||
// embedded mode sets loopback DATABASE_URL/REDIS_URL before boot, and native
|
||||
// dev commonly leaves DATABASE_URL unset (config.ts defaults it).
|
||||
function deployMode(): string {
|
||||
if (process.env.EMBEDDED_MODE) return "embedded";
|
||||
if (existsSync("/.dockerenv")) return "external";
|
||||
return "native";
|
||||
}
|
||||
|
||||
if (ANALYTICS_BAKED.sentryDsn && !telemetryEnvKilled()) {
|
||||
try {
|
||||
const Sentry = await import("@sentry/node");
|
||||
@@ -41,8 +30,8 @@ if (ANALYTICS_BAKED.sentryDsn && !telemetryEnvKilled()) {
|
||||
// again (the July 2026 quota incident).
|
||||
integrations: [Sentry.httpIntegration({ trackIncomingRequestsAsSessions: false })],
|
||||
sendClientReports: false,
|
||||
maxBreadcrumbs: 0,
|
||||
beforeBreadcrumb: () => null,
|
||||
// Capture the breadcrumb trail (default 100). beforeSend (sentry-scrub.ts)
|
||||
// sanitizes each breadcrumb before send: urls/paths redacted, data dropped.
|
||||
initialScope: { tags: { deploy_mode: deployMode() } },
|
||||
beforeSend: buildBeforeSend(sentryActive) as unknown as SentryOptions["beforeSend"],
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ const ALLOWED: Record<string, ReadonlySet<string>> = {
|
||||
"status",
|
||||
]),
|
||||
ai_bundle_action: new Set(["bundle_id", "action", "duration_ms"]),
|
||||
instance_started: new Set(["arch", "os_platform", "deploy_mode", "gpu_present"]),
|
||||
};
|
||||
|
||||
function isAllowedValue(value: unknown): boolean {
|
||||
|
||||
@@ -25,10 +25,17 @@ async function defaultReader(): Promise<boolean | undefined> {
|
||||
return rows[0].value !== "false";
|
||||
}
|
||||
|
||||
/** Runtime kill switch honored in ALL builds: SNAPOTTER_TELEMETRY=0|false|off. */
|
||||
/**
|
||||
* Runtime kill switch honored in ALL builds: SNAPOTTER_TELEMETRY=0|false|off.
|
||||
*
|
||||
* ANALYTICS_ENABLED=false|0|off is honored as an alias. It is the opt-out
|
||||
* variable historically documented on the Docker Hub README but never wired in
|
||||
* 2.x, so a user who set it was still tracked. Treating it as a kill signal
|
||||
* makes that documented opt-out genuinely stop egress.
|
||||
*/
|
||||
export function telemetryEnvKilled(): boolean {
|
||||
const v = process.env.SNAPOTTER_TELEMETRY;
|
||||
return v === "0" || v === "false" || v === "off";
|
||||
const off = (v: string | undefined) => v === "0" || v === "false" || v === "off";
|
||||
return off(process.env.SNAPOTTER_TELEMETRY) || off(process.env.ANALYTICS_ENABLED);
|
||||
}
|
||||
|
||||
/** Compile-time bake, with a NON-PRODUCTION-only override so tests can force it on. */
|
||||
|
||||
@@ -84,10 +84,14 @@ export async function trackEvent(
|
||||
event: string,
|
||||
properties: Record<string, unknown>,
|
||||
distinctId?: string,
|
||||
// ignoreSampleRate bypasses the volume sample for low-frequency, high-value
|
||||
// events (e.g. the once-per-boot instance_started census). It does NOT bypass
|
||||
// the opt-out gate or the property allowlist below.
|
||||
options?: { ignoreSampleRate?: boolean },
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!analyticsEnabled() || !posthogClient) return;
|
||||
if (ANALYTICS_BAKED.posthogSampleRate < 1.0) {
|
||||
if (!options?.ignoreSampleRate && ANALYTICS_BAKED.posthogSampleRate < 1.0) {
|
||||
if (
|
||||
ANALYTICS_BAKED.posthogSampleRate <= 0.0 ||
|
||||
Math.random() >= ANALYTICS_BAKED.posthogSampleRate
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
export type DeployMode = "embedded" | "external" | "native";
|
||||
|
||||
// All-in-one detection: docker/entrypoint.sh exports EMBEDDED_MODE=1 before
|
||||
// exec'ing s6-overlay, and the snapotter service run script is with-contenv,
|
||||
// so the marker reaches this process. URL absence is not a usable signal:
|
||||
// embedded mode sets loopback DATABASE_URL/REDIS_URL before boot, and native
|
||||
// dev commonly leaves DATABASE_URL unset (config.ts defaults it).
|
||||
export function deployMode(): DeployMode {
|
||||
if (process.env.EMBEDDED_MODE) return "embedded";
|
||||
if (existsSync("/.dockerenv")) return "external";
|
||||
return "native";
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
*/
|
||||
import { rebuildErrorValue } from "@snapotter/shared";
|
||||
|
||||
const CEILING_PER_HOUR = 20;
|
||||
// Per-process runaway guard, not a quota lever: under the sponsored plan we want
|
||||
// real errors, but a single instance stuck in an error loop must not spam. Sentry
|
||||
// de-dupes by fingerprint server-side, so 500 distinct events/hour is ample.
|
||||
const CEILING_PER_HOUR = 500;
|
||||
const HOUR_MS = 3600_000;
|
||||
|
||||
const TAG_ALLOWLIST = new Set([
|
||||
@@ -21,9 +24,15 @@ const TAG_ALLOWLIST = new Set([
|
||||
"status_code",
|
||||
]);
|
||||
|
||||
function basename(p: string): string {
|
||||
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
const URL_RE = /https?:\/\/[^\s"')]+/g;
|
||||
const BLOB_RE = /blob:[^\s"')]+/g;
|
||||
// Absolute paths under roots that can hold user files (uploads live in /data,
|
||||
// /tmp) or dev machines (/Users, /home). Over-redaction of a benign path is fine.
|
||||
const PATH_RE = /(?:\/(?:Users|home|root|data|tmp|var|app|opt|mnt|srv)|[A-Za-z]:\\)[^\s"')]*/g;
|
||||
|
||||
/** Redact urls, blob refs, and absolute paths from free text (breadcrumb messages). */
|
||||
function scrubText(s: string): string {
|
||||
return s.replace(BLOB_RE, "<blob>").replace(URL_RE, "<url>").replace(PATH_RE, "<path>");
|
||||
}
|
||||
|
||||
// Sentry event/hint are typed loosely on purpose: this module must not import
|
||||
@@ -38,6 +47,30 @@ function asObj(value: unknown): AnyEvent | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
// Keep the breadcrumb trail (the sequence of operations before the error) but
|
||||
// strip content that can carry user data: redact urls/paths from the message and
|
||||
// drop the structured `data` payload (http urls, query params) entirely.
|
||||
function scrubBreadcrumb(entry: unknown): AnyEvent | null {
|
||||
const b = asObj(entry);
|
||||
if (!b) return null;
|
||||
const out: AnyEvent = {};
|
||||
for (const k of ["type", "category", "level", "timestamp"]) {
|
||||
if (b[k] !== undefined) out[k] = b[k];
|
||||
}
|
||||
if (typeof b.message === "string") out.message = scrubText(b.message);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sanitize the breadcrumb list, tolerating both the array and {values} shapes. */
|
||||
function scrubBreadcrumbs(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(scrubBreadcrumb).filter(Boolean);
|
||||
const wrapped = asObj(value);
|
||||
if (Array.isArray(wrapped?.values)) {
|
||||
return { values: wrapped.values.map(scrubBreadcrumb).filter(Boolean) };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildBeforeSend(isActive: () => boolean) {
|
||||
let windowStart = 0;
|
||||
let sentInWindow = 0;
|
||||
@@ -52,13 +85,15 @@ export function buildBeforeSend(isActive: () => boolean) {
|
||||
}
|
||||
if (++sentInWindow > CEILING_PER_HOUR) return null;
|
||||
|
||||
// Dropped: these surfaces can carry user data or PII.
|
||||
event.message = undefined;
|
||||
event.logentry = undefined;
|
||||
event.server_name = undefined;
|
||||
event.request = undefined;
|
||||
event.extra = undefined;
|
||||
event.breadcrumbs = undefined;
|
||||
event.user = undefined;
|
||||
// Kept, sanitized: the operation trail leading up to the error.
|
||||
event.breadcrumbs = scrubBreadcrumbs(event.breadcrumbs);
|
||||
|
||||
const ctx = asObj(event.contexts);
|
||||
const keep: AnyEvent = {};
|
||||
@@ -88,8 +123,8 @@ export function buildBeforeSend(isActive: () => boolean) {
|
||||
for (const entry of frames) {
|
||||
const frame = asObj(entry);
|
||||
if (!frame) continue;
|
||||
if (typeof frame.filename === "string") frame.filename = basename(frame.filename);
|
||||
frame.abs_path = undefined;
|
||||
// Keep filename + abs_path (open-source code paths, not user data);
|
||||
// drop only vars, which can hold user file contents or secrets.
|
||||
frame.vars = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import type { InstanceStartedProperties } from "@snapotter/shared";
|
||||
import { deployMode } from "./deploy-mode.js";
|
||||
|
||||
// Facts about the running instance, shipped once at boot as the
|
||||
// `instance_started` analytics event. Returning the shared event-property type
|
||||
// keeps this gatherer in lockstep with the event contract (single source of
|
||||
// truth) rather than duplicating the shape here.
|
||||
//
|
||||
// The NVIDIA Container Toolkit exposes GPU device nodes when a GPU is passed
|
||||
// through (docker run --gpus / compose deploy.resources.reservations), so a
|
||||
// filesystem check is enough; no nvidia-smi spawn or AI dispatcher needed.
|
||||
// This is deliberately independent of packages/ai's isGpuAvailable(), which
|
||||
// only reflects hardware in use by an already-started dispatcher.
|
||||
export function gatherSystemProperties(): InstanceStartedProperties {
|
||||
return {
|
||||
arch: process.arch === "arm64" ? "arm64" : "amd64",
|
||||
os_platform: os.platform(),
|
||||
deploy_mode: deployMode(),
|
||||
gpu_present: existsSync("/dev/nvidia0"),
|
||||
};
|
||||
}
|
||||
@@ -92,8 +92,8 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
integrations: (defaults) => defaults.filter((i) => i.name !== "BrowserSession"),
|
||||
ignoreErrors: IGNORE_ERRORS,
|
||||
denyUrls: DENY_URLS,
|
||||
maxBreadcrumbs: 0,
|
||||
beforeBreadcrumb: () => null,
|
||||
// Capture the breadcrumb trail (default 100). beforeSend (sentry-scrub.ts)
|
||||
// sanitizes each breadcrumb before send: urls/paths redacted, data dropped.
|
||||
beforeSend: buildWebBeforeSend(() => enabled) as unknown as SentryOptions["beforeSend"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ const NATIVE_ERRORS = new Set([
|
||||
"AbortError",
|
||||
]);
|
||||
|
||||
const CEILING_PER_HOUR = 20;
|
||||
// Per-session runaway guard (Sentry de-dupes by fingerprint server-side, so 500
|
||||
// distinct events/hour is ample), not a quota lever under the sponsored plan.
|
||||
const CEILING_PER_HOUR = 500;
|
||||
const HOUR_MS = 3600_000;
|
||||
|
||||
// BLOB_RE must be applied before URL_RE: "blob:http://..." would otherwise
|
||||
@@ -43,10 +45,15 @@ const URL_RE = /https?:\/\/[^\s"')]+/g;
|
||||
const BLOB_RE = /blob:[^\s"')]+/g;
|
||||
const PATH_RE = /(?:\/Users|\/home|[A-Za-z]:\\)[^\s"')]*/g;
|
||||
|
||||
/** Redact urls, blob refs, and absolute paths from free text. */
|
||||
function scrubText(s: string): string {
|
||||
return s.replace(BLOB_RE, "<blob>").replace(URL_RE, "<url>").replace(PATH_RE, "<path>");
|
||||
}
|
||||
|
||||
/** Redacted native-error message, or null when the name is not allowlisted. */
|
||||
export function scrubBrowserMessage(name: string, message: string): string | null {
|
||||
if (!NATIVE_ERRORS.has(name)) return null;
|
||||
return message.replace(BLOB_RE, "<blob>").replace(URL_RE, "<url>").replace(PATH_RE, "<path>");
|
||||
return scrubText(message);
|
||||
}
|
||||
|
||||
const TAG_ALLOWLIST = new Set(["route", "tool_id", "locale", "error_class"]);
|
||||
@@ -63,6 +70,30 @@ function asObj(value: unknown): AnyEvent | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
// Keep the breadcrumb trail (the sequence of user actions / requests before the
|
||||
// error) but strip content that can carry user data: redact urls/paths from the
|
||||
// message and drop the structured `data` payload entirely.
|
||||
function scrubBreadcrumb(entry: unknown): AnyEvent | null {
|
||||
const b = asObj(entry);
|
||||
if (!b) return null;
|
||||
const out: AnyEvent = {};
|
||||
for (const k of ["type", "category", "level", "timestamp"]) {
|
||||
if (b[k] !== undefined) out[k] = b[k];
|
||||
}
|
||||
if (typeof b.message === "string") out.message = scrubText(b.message);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sanitize the breadcrumb list, tolerating both the array and {values} shapes. */
|
||||
function scrubBreadcrumbs(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(scrubBreadcrumb).filter(Boolean);
|
||||
const wrapped = asObj(value);
|
||||
if (Array.isArray(wrapped?.values)) {
|
||||
return { values: wrapped.values.map(scrubBreadcrumb).filter(Boolean) };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildWebBeforeSend(isActive: () => boolean) {
|
||||
let windowStart = 0;
|
||||
let sentInWindow = 0;
|
||||
@@ -77,12 +108,14 @@ export function buildWebBeforeSend(isActive: () => boolean) {
|
||||
}
|
||||
if (++sentInWindow > CEILING_PER_HOUR) return null;
|
||||
|
||||
// Dropped: these surfaces can carry user data or PII.
|
||||
event.message = undefined;
|
||||
event.logentry = undefined;
|
||||
event.request = undefined;
|
||||
event.extra = undefined;
|
||||
event.breadcrumbs = undefined;
|
||||
event.user = undefined;
|
||||
// Kept, sanitized: the trail of user actions / requests before the error.
|
||||
event.breadcrumbs = scrubBreadcrumbs(event.breadcrumbs);
|
||||
|
||||
// React's componentStack is component names only; every other context goes.
|
||||
const react = asObj(event.contexts)?.react;
|
||||
|
||||
@@ -13,6 +13,7 @@ export const ANALYTICS_EVENTS = {
|
||||
BATCH_PROCESSED: "batch_processed",
|
||||
FEEDBACK_SUBMITTED: "feedback_submitted",
|
||||
SPONSOR_CLICKED: "sponsor_clicked",
|
||||
INSTANCE_STARTED: "instance_started",
|
||||
} as const;
|
||||
|
||||
export type AnalyticsEvent = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS];
|
||||
@@ -47,3 +48,10 @@ export interface AiBundleActionProperties {
|
||||
action: "installed" | "uninstalled";
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
export interface InstanceStartedProperties {
|
||||
arch: "arm64" | "amd64";
|
||||
os_platform: string;
|
||||
deploy_mode: "embedded" | "external" | "native";
|
||||
gpu_present: boolean;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ const posthogHost = posthogApiKey ? "https://us.i.posthog.com" : "";
|
||||
const enabled = on && (posthogApiKey !== "" || sentryDsn !== "" || sentryDsnWeb !== "");
|
||||
// PostHog event sampling only. Sentry tracing was removed in 2.0.1; do not
|
||||
// reintroduce a shared "sampleRate" that doubles as a traces rate.
|
||||
const posthogSampleRate = on ? 0.1 : 0;
|
||||
// 1.0 = full analytics when enabled: the property allowlist (analytics-allowlist.ts)
|
||||
// already blocks file data, and PostHog event volume is cheap under the sponsored
|
||||
// plan. The old 0.1 was an inherited Sentry-tracing cost figure, not an event rate.
|
||||
const posthogSampleRate = on ? 1.0 : 0;
|
||||
|
||||
const content = `// AUTO-GENERATED by scripts/bake-analytics.mjs -- do not edit manually
|
||||
export const ANALYTICS_BAKED = {
|
||||
|
||||
@@ -36,4 +36,21 @@ describe("sanitizeEventProperties", () => {
|
||||
const out = sanitizeEventProperties("totally_new_event", { anything: "x" });
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps only allow-listed keys for instance_started", () => {
|
||||
const out = sanitizeEventProperties("instance_started", {
|
||||
arch: "arm64",
|
||||
os_platform: "linux",
|
||||
deploy_mode: "embedded",
|
||||
gpu_present: false,
|
||||
hostname: "leaked-hostname",
|
||||
});
|
||||
expect(out).toEqual({
|
||||
arch: "arm64",
|
||||
os_platform: "linux",
|
||||
deploy_mode: "embedded",
|
||||
gpu_present: false,
|
||||
});
|
||||
expect(out).not.toHaveProperty("hostname");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
const origOverride = process.env.ANALYTICS_BAKED_OVERRIDE;
|
||||
const origTelemetry = process.env.SNAPOTTER_TELEMETRY;
|
||||
const origAnalyticsEnabled = process.env.ANALYTICS_ENABLED;
|
||||
|
||||
beforeEach(() => {
|
||||
__resetGateForTests();
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.ANALYTICS_BAKED_OVERRIDE = "on"; // force bake on for these unit tests
|
||||
delete process.env.SNAPOTTER_TELEMETRY; // ambient kill switch would poison the suite
|
||||
delete process.env.ANALYTICS_ENABLED; // same: an ambient opt-out alias would poison the suite
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -25,6 +27,8 @@ afterEach(() => {
|
||||
else process.env.ANALYTICS_BAKED_OVERRIDE = origOverride;
|
||||
if (origTelemetry === undefined) delete process.env.SNAPOTTER_TELEMETRY;
|
||||
else process.env.SNAPOTTER_TELEMETRY = origTelemetry;
|
||||
if (origAnalyticsEnabled === undefined) delete process.env.ANALYTICS_ENABLED;
|
||||
else process.env.ANALYTICS_ENABLED = origAnalyticsEnabled;
|
||||
__setReaderForTests(null);
|
||||
});
|
||||
|
||||
@@ -87,6 +91,36 @@ describe("SNAPOTTER_TELEMETRY runtime kill switch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ANALYTICS_ENABLED is the env var historically documented on the Docker Hub
|
||||
// README as the analytics opt-out. It was never wired in 2.x, so a user who set
|
||||
// ANALYTICS_ENABLED=false was still tracked. Honor it as an alias of the kill
|
||||
// switch so the documented opt-out genuinely stops egress.
|
||||
describe("ANALYTICS_ENABLED opt-out alias", () => {
|
||||
it("ANALYTICS_ENABLED=false kills telemetry like SNAPOTTER_TELEMETRY=0", () => {
|
||||
process.env.ANALYTICS_ENABLED = "false";
|
||||
expect(telemetryEnvKilled()).toBe(true);
|
||||
expect(bakedEnabled()).toBe(false);
|
||||
});
|
||||
it("matches 0, false, off and nothing else", () => {
|
||||
for (const v of ["0", "false", "off"]) {
|
||||
process.env.ANALYTICS_ENABLED = v;
|
||||
expect(telemetryEnvKilled()).toBe(true);
|
||||
}
|
||||
process.env.ANALYTICS_ENABLED = "true";
|
||||
expect(telemetryEnvKilled()).toBe(false);
|
||||
process.env.ANALYTICS_ENABLED = "1";
|
||||
expect(telemetryEnvKilled()).toBe(false);
|
||||
delete process.env.ANALYTICS_ENABLED;
|
||||
expect(telemetryEnvKilled()).toBe(false);
|
||||
});
|
||||
it("is honored in production builds", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.ANALYTICS_ENABLED = "false";
|
||||
expect(telemetryEnvKilled()).toBe(true);
|
||||
expect(bakedEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fail closed", () => {
|
||||
it("stays disabled across a transient read error once disabled was seen", async () => {
|
||||
__setReaderForTests(async () => false);
|
||||
|
||||
@@ -186,6 +186,22 @@ describe("trackEvent", () => {
|
||||
expect(mockCapture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("captures despite sampleRate 0 when ignoreSampleRate is set (once-per-boot census)", async () => {
|
||||
bakedConfig.enabled = true;
|
||||
bakedConfig.posthogApiKey = "phc_test_key";
|
||||
bakedConfig.posthogSampleRate = 0;
|
||||
await mod.initAnalytics();
|
||||
|
||||
await mod.trackEvent("instance_started", { arch: "arm64" }, "did-boot", {
|
||||
ignoreSampleRate: true,
|
||||
});
|
||||
expect(mockCapture).toHaveBeenCalledWith({
|
||||
distinctId: "did-boot",
|
||||
event: "instance_started",
|
||||
properties: { arch: "arm64" },
|
||||
});
|
||||
});
|
||||
|
||||
it("captures event with only allow-listed properties when enabled", async () => {
|
||||
bakedConfig.enabled = true;
|
||||
bakedConfig.posthogApiKey = "phc_test_key";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockExistsSync = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: mockExistsSync,
|
||||
}));
|
||||
|
||||
import { deployMode } from "../../../apps/api/src/lib/deploy-mode.js";
|
||||
|
||||
describe("deployMode", () => {
|
||||
const origEmbedded = process.env.EMBEDDED_MODE;
|
||||
|
||||
afterEach(() => {
|
||||
if (origEmbedded === undefined) delete process.env.EMBEDDED_MODE;
|
||||
else process.env.EMBEDDED_MODE = origEmbedded;
|
||||
mockExistsSync.mockReset();
|
||||
});
|
||||
|
||||
it("returns embedded when EMBEDDED_MODE is set", () => {
|
||||
process.env.EMBEDDED_MODE = "1";
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
expect(deployMode()).toBe("embedded");
|
||||
});
|
||||
|
||||
it("returns external when /.dockerenv exists and EMBEDDED_MODE is unset", () => {
|
||||
delete process.env.EMBEDDED_MODE;
|
||||
mockExistsSync.mockImplementation((p: string) => p === "/.dockerenv");
|
||||
expect(deployMode()).toBe("external");
|
||||
});
|
||||
|
||||
it("returns native when neither signal is present", () => {
|
||||
delete process.env.EMBEDDED_MODE;
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
expect(deployMode()).toBe("native");
|
||||
});
|
||||
|
||||
it("prefers embedded over external when both signals are present", () => {
|
||||
process.env.EMBEDDED_MODE = "1";
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
expect(deployMode()).toBe("embedded");
|
||||
});
|
||||
});
|
||||
@@ -40,21 +40,39 @@ describe("buildBeforeSend (api)", () => {
|
||||
it("returns null when the gate is off", () => {
|
||||
expect(buildBeforeSend(() => false)(evt(), {})).toBeNull();
|
||||
});
|
||||
it("strips PII surfaces and rebuilds the value from the hint error", () => {
|
||||
it("strips high-risk surfaces but keeps full stack paths for debugging", () => {
|
||||
const hint = {
|
||||
originalException: Object.assign(new Error("x"), { code: "EACCES", syscall: "mkdir" }),
|
||||
};
|
||||
const out = send(evt(), hint)!;
|
||||
// Still dropped: these can carry user data / PII.
|
||||
expect(out.message).toBeUndefined();
|
||||
expect(out.server_name).toBeUndefined();
|
||||
expect(out.request).toBeUndefined();
|
||||
expect(out.extra).toBeUndefined();
|
||||
expect(out.breadcrumbs).toBeUndefined();
|
||||
expect(out.user).toBeUndefined();
|
||||
expect(out.exception.values[0].value).toBe("EACCES mkdir");
|
||||
expect(out.exception.values[0].stacktrace.frames[0].filename).toBe("cleanup.ts");
|
||||
expect(out.exception.values[0].stacktrace.frames[0].abs_path).toBeUndefined();
|
||||
expect(out.exception.values[0].stacktrace.frames[0].vars).toBeUndefined();
|
||||
// Restored for debugging: full source path (open-source code, not user data).
|
||||
expect(out.exception.values[0].stacktrace.frames[0].filename).toBe(
|
||||
"/app/apps/api/src/lib/cleanup.ts",
|
||||
);
|
||||
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", () => {
|
||||
const out = send(
|
||||
evt({
|
||||
breadcrumbs: [
|
||||
{ message: "GET https://host/u/photo.jpg 200", category: "http", data: { url: "x" } },
|
||||
{ message: "reading /Users/me/secret.txt", category: "console", level: "info" },
|
||||
],
|
||||
}),
|
||||
{},
|
||||
)!;
|
||||
expect(out.breadcrumbs).toEqual([
|
||||
{ message: "GET <url> 200", category: "http" },
|
||||
{ message: "reading <path>", category: "console", level: "info" },
|
||||
]);
|
||||
});
|
||||
it("falls back to type-only for unknown errors", () => {
|
||||
const out = send(evt(), { originalException: new Error("user path /tmp/z") })!;
|
||||
@@ -87,8 +105,8 @@ describe("buildBeforeSend (api)", () => {
|
||||
const out = send(evt({ contexts: { device: { hostname: "leak" } } }), {})!;
|
||||
expect(out.contexts).toBeUndefined();
|
||||
});
|
||||
it("enforces the 20-events-per-hour ceiling", () => {
|
||||
for (let i = 0; i < 20; i++) expect(send(evt(), {})).not.toBeNull();
|
||||
it("enforces the 500-events-per-hour ceiling", () => {
|
||||
for (let i = 0; i < 500; i++) expect(send(evt(), {})).not.toBeNull();
|
||||
expect(send(evt(), {})).toBeNull();
|
||||
});
|
||||
it("never throws on malformed events (fail-closed to a scrubbed event)", () => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockExistsSync = vi.hoisted(() => vi.fn());
|
||||
const mockDeployMode = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: mockExistsSync,
|
||||
}));
|
||||
vi.mock("../../../apps/api/src/lib/deploy-mode.js", () => ({
|
||||
deployMode: mockDeployMode,
|
||||
}));
|
||||
|
||||
import { gatherSystemProperties } from "../../../apps/api/src/lib/system-info.js";
|
||||
|
||||
describe("gatherSystemProperties", () => {
|
||||
const origArch = process.arch;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "arch", { value: origArch, configurable: true });
|
||||
mockExistsSync.mockReset();
|
||||
mockDeployMode.mockReset();
|
||||
});
|
||||
|
||||
it("reports arm64 for an arm64 process", () => {
|
||||
Object.defineProperty(process, "arch", { value: "arm64", configurable: true });
|
||||
mockDeployMode.mockReturnValue("native");
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
expect(gatherSystemProperties().arch).toBe("arm64");
|
||||
});
|
||||
|
||||
it("reports amd64 for an x64 process", () => {
|
||||
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
|
||||
mockDeployMode.mockReturnValue("native");
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
expect(gatherSystemProperties().arch).toBe("amd64");
|
||||
});
|
||||
|
||||
it("reports gpu_present true when /dev/nvidia0 exists", () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === "/dev/nvidia0");
|
||||
mockDeployMode.mockReturnValue("external");
|
||||
expect(gatherSystemProperties().gpu_present).toBe(true);
|
||||
});
|
||||
|
||||
it("reports gpu_present false when /dev/nvidia0 is absent", () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockDeployMode.mockReturnValue("external");
|
||||
expect(gatherSystemProperties().gpu_present).toBe(false);
|
||||
});
|
||||
|
||||
it("passes through deploy mode and os platform", () => {
|
||||
mockDeployMode.mockReturnValue("embedded");
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
const props = gatherSystemProperties();
|
||||
expect(props.deploy_mode).toBe("embedded");
|
||||
expect(props.os_platform).toBe(process.platform);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import { ANALYTICS_EVENTS } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ANALYTICS_EVENTS", () => {
|
||||
it("has exactly 14 event keys", () => {
|
||||
expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(14);
|
||||
it("has exactly 15 event keys", () => {
|
||||
expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("contains the expected keys", () => {
|
||||
@@ -21,6 +21,7 @@ describe("ANALYTICS_EVENTS", () => {
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("BATCH_PROCESSED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("FEEDBACK_SUBMITTED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("SPONSOR_CLICKED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("INSTANCE_STARTED");
|
||||
});
|
||||
|
||||
it("all event values are strings", () => {
|
||||
@@ -49,6 +50,10 @@ describe("ANALYTICS_EVENTS", () => {
|
||||
expect(ANALYTICS_EVENTS.FEEDBACK_SUBMITTED).toBe("feedback_submitted");
|
||||
});
|
||||
|
||||
it("INSTANCE_STARTED has the correct snake_case value", () => {
|
||||
expect(ANALYTICS_EVENTS.INSTANCE_STARTED).toBe("instance_started");
|
||||
});
|
||||
|
||||
it("all values follow snake_case convention", () => {
|
||||
for (const value of Object.values(ANALYTICS_EVENTS)) {
|
||||
expect(value).toMatch(/^[a-z][a-z0-9_]*$/);
|
||||
|
||||
@@ -219,7 +219,8 @@ describe("analytics lib (baked model)", () => {
|
||||
expect(result.user).toBeUndefined();
|
||||
expect(result.message).toBeUndefined();
|
||||
expect(result.request).toBeUndefined();
|
||||
expect(result.breadcrumbs).toBeUndefined();
|
||||
// Breadcrumbs are kept for debugging but sanitized (paths/urls redacted).
|
||||
expect(result.breadcrumbs).toEqual([{ message: "<path>" }]);
|
||||
// The exception message is replaced by its type so no free text leaves.
|
||||
expect(result.exception.values[0].value).toBe("TypeError");
|
||||
// Filesystem paths collapse to the basename (directory and any username
|
||||
@@ -248,64 +249,4 @@ describe("analytics lib (baked model)", () => {
|
||||
expect(result.exception.values[0].value).toBe("RangeError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sentry beforeBreadcrumb callback", () => {
|
||||
async function getBeforeBreadcrumb() {
|
||||
mockSentryInit.mockClear();
|
||||
await mod.initAnalytics(enabledConfig);
|
||||
const sentryCall = mockSentryInit.mock.calls.find(
|
||||
(call: unknown[]) => call[0]?.beforeBreadcrumb,
|
||||
);
|
||||
return sentryCall ? sentryCall[0].beforeBreadcrumb : null;
|
||||
}
|
||||
|
||||
it("returns null for ui.click breadcrumbs", async () => {
|
||||
const beforeBreadcrumb = await getBeforeBreadcrumb();
|
||||
if (!beforeBreadcrumb) return;
|
||||
|
||||
const result = beforeBreadcrumb({ category: "ui.click" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for fetch breadcrumbs with file extension URLs", async () => {
|
||||
const beforeBreadcrumb = await getBeforeBreadcrumb();
|
||||
if (!beforeBreadcrumb) return;
|
||||
|
||||
const result = beforeBreadcrumb({
|
||||
category: "fetch",
|
||||
data: { url: "https://example.com/uploads/photo.png" },
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("drops console breadcrumbs even with file paths", async () => {
|
||||
const beforeBreadcrumb = await getBeforeBreadcrumb();
|
||||
if (!beforeBreadcrumb) return;
|
||||
|
||||
const result = beforeBreadcrumb({
|
||||
category: "console",
|
||||
message: "Error loading /tmp/workspace/file.jpg",
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("drops fetch breadcrumbs to non-file URLs too", async () => {
|
||||
const beforeBreadcrumb = await getBeforeBreadcrumb();
|
||||
if (!beforeBreadcrumb) return;
|
||||
|
||||
const result = beforeBreadcrumb({
|
||||
category: "fetch",
|
||||
data: { url: "https://example.com/api/v1/health" },
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("drops navigation breadcrumbs without a message", async () => {
|
||||
const beforeBreadcrumb = await getBeforeBreadcrumb();
|
||||
if (!beforeBreadcrumb) return;
|
||||
|
||||
const result = beforeBreadcrumb({ category: "navigation" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("static filter lists", () => {
|
||||
});
|
||||
|
||||
describe("buildWebBeforeSend", () => {
|
||||
const baseEvent = (): Record<string, any> => ({
|
||||
const baseEvent = (over: Record<string, any> = {}): Record<string, any> => ({
|
||||
request: { url: "http://192.168.0.4:1349/image/resize" },
|
||||
breadcrumbs: [{}],
|
||||
user: { id: "x" },
|
||||
@@ -54,13 +54,13 @@ describe("buildWebBeforeSend", () => {
|
||||
],
|
||||
},
|
||||
tags: { tool_id: "resize", drop_me: "x" },
|
||||
...over,
|
||||
});
|
||||
|
||||
it("gates, strips, keeps react componentStack, redacts values", () => {
|
||||
const send = buildWebBeforeSend(() => true);
|
||||
const out = send(baseEvent(), { originalException: new TypeError("boom /Users/a/b") })!;
|
||||
expect(out.request).toBeUndefined();
|
||||
expect(out.breadcrumbs).toBeUndefined();
|
||||
expect(out.user).toBeUndefined();
|
||||
expect(out.contexts.react.componentStack).toBe("at ToolPage");
|
||||
expect(out.contexts.device).toBeUndefined();
|
||||
@@ -71,6 +71,23 @@ describe("buildWebBeforeSend", () => {
|
||||
expect(buildWebBeforeSend(() => false)(baseEvent(), {})).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the breadcrumb trail, redacting paths/urls and dropping data payloads", () => {
|
||||
const send = buildWebBeforeSend(() => true);
|
||||
const out = send(
|
||||
baseEvent({
|
||||
breadcrumbs: [
|
||||
{ message: "fetch https://host/user.png", category: "fetch", data: { url: "x" } },
|
||||
{ message: "open /Users/a/secret.pdf", category: "console", level: "warning" },
|
||||
],
|
||||
}),
|
||||
{},
|
||||
)!;
|
||||
expect(out.breadcrumbs).toEqual([
|
||||
{ message: "fetch <url>", category: "fetch" },
|
||||
{ message: "open <path>", category: "console", level: "warning" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to type-only for non-native exceptions without a rebuild", () => {
|
||||
const send = buildWebBeforeSend(() => true);
|
||||
const custom = Object.assign(new Error("user secret"), { name: "WeirdLibError" });
|
||||
@@ -78,9 +95,9 @@ describe("buildWebBeforeSend", () => {
|
||||
expect(out.exception.values[0].value).toBe("TypeError");
|
||||
});
|
||||
|
||||
it("enforces the 20-per-hour ceiling", () => {
|
||||
it("enforces the 500-per-hour ceiling", () => {
|
||||
const send = buildWebBeforeSend(() => true);
|
||||
for (let i = 0; i < 20; i++) expect(send(baseEvent(), {})).not.toBeNull();
|
||||
for (let i = 0; i < 500; i++) expect(send(baseEvent(), {})).not.toBeNull();
|
||||
expect(send(baseEvent(), {})).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user