diff --git a/.env.example b/.env.example index a41d8335..0cd59584 100644 --- a/.env.example +++ b/.env.example @@ -72,10 +72,11 @@ LOG_DIR=./data/logs # rotating log ring for support bundles # Prometheus metrics: GET /api/v1/metrics requires an authenticated admin # (scrapers need a session cookie or API key with system:health permission). -# Analytics: defaults OFF. Enable only with your own PostHog/Sentry keys. -# ANALYTICS_ENABLED=false -# POSTHOG_API_KEY= -# SENTRY_DSN= +# --- Analytics --- +# Product analytics (PostHog) and error monitoring (Sentry) are baked into +# the published Docker image and cannot be disabled at runtime. +# To build without analytics: docker compose build --build-arg SNAPOTTER_ANALYTICS=off + # One-time SQLite import on first boot (1.x upgrade path). Leave unset normally. # SQLITE_MIGRATE_PATH=/data/snapotter.db diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b2e26b6..c72ad5c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -237,6 +237,8 @@ jobs: context: . file: docker/Dockerfile platforms: ${{ matrix.platform }} + build-args: | + SNAPOTTER_ANALYTICS=on labels: ${{ steps.meta.outputs.labels }} outputs: type=image,"name=snapotter/snapotter,ghcr.io/snapotter-hq/snapotter",push-by-digest=true,name-canonical=true,push=true cache-from: type=registry,ref=ghcr.io/snapotter-hq/snapotter:cache-${{ env.PLATFORM_PAIR }} diff --git a/apps/api/drizzle/0005_special_exodus.sql b/apps/api/drizzle/0005_special_exodus.sql new file mode 100644 index 00000000..558c0294 --- /dev/null +++ b/apps/api/drizzle/0005_special_exodus.sql @@ -0,0 +1,3 @@ +ALTER TABLE "users" DROP COLUMN "analytics_enabled";--> statement-breakpoint +ALTER TABLE "users" DROP COLUMN "analytics_consent_shown_at";--> statement-breakpoint +ALTER TABLE "users" DROP COLUMN "analytics_consent_remind_at"; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0005_snapshot.json b/apps/api/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000..44bd1313 --- /dev/null +++ b/apps/api/drizzle/meta/0005_snapshot.json @@ -0,0 +1,976 @@ +{ + "id": "9e790b1b-8a07-4885-b3d9-3d3f81d0d4e8", + "prevId": "3c5d26d4-9789-462f-a07a-6894e29a403d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default API Key'" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_username": { + "name": "actor_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "audit_log_created_at_idx": { + "name": "audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_id_idx": { + "name": "audit_log_actor_id_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_id": { + "name": "tool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pool": { + "name": "pool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_refs": { + "name": "input_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_refs": { + "name": "output_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "bytes_in": { + "name": "bytes_in", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delete_after": { + "name": "delete_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "jobs_created_at_idx": { + "name": "jobs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_status_idx": { + "name": "jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_user_id_users_id_fk": { + "name": "jobs_user_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipelines": { + "name": "pipelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "pipelines_user_id_users_id_fk": { + "name": "pipelines_user_id_users_id_fk", + "tableFrom": "pipelines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tool_permissions": { + "name": "tool_permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "roles_created_by_users_id_fk": { + "name": "roles_created_by_users_id_fk", + "tableFrom": "roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_activity": { + "name": "last_activity", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "legal_hold": { + "name": "legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "storage_quota": { + "name": "storage_quota", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "retention_hours": { + "name": "retention_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_files": { + "name": "user_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_chain": { + "name": "tool_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_files_user_id_users_id_fk": { + "name": "user_files_user_id_users_id_fk", + "tableFrom": "user_files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id_key_pk": { + "name": "user_preferences_user_id_key_pk", + "columns": ["user_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "team": { + "name": "team", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legal_hold": { + "name": "legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "storage_used": { + "name": "storage_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_quota": { + "name": "storage_quota", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "recovery_codes_hash": { + "name": "recovery_codes_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": ["queued", "processing", "completed", "failed", "canceled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 69ce219c..0d2bce5e 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1781359444186, "tag": "0004_handy_warhawk", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1782266352290, + "tag": "0005_special_exodus", + "breakpoints": true } ] } diff --git a/apps/api/package.json b/apps/api/package.json index c3d87405..b79b7262 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "PORT=13490 tsx watch --import ./src/tracing.ts src/index.ts", + "dev": "PORT=13490 tsx watch --import ./src/tracing.ts --import ./src/instrument.ts src/index.ts", "build": "tsc", - "start": "tsx --import ./src/tracing.ts src/index.ts", + "start": "tsx --import ./src/tracing.ts --import ./src/instrument.ts src/index.ts", "lint": "biome check src/", "typecheck": "tsc --noEmit", "clean": "rm -rf dist", diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 139aac86..24d88421 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -41,9 +41,6 @@ export const users = pgTable("users", { totpSecret: text("totp_secret"), totpEnabled: boolean("totp_enabled").notNull().default(false), recoveryCodesHash: text("recovery_codes_hash"), - analyticsEnabled: boolean("analytics_enabled"), - analyticsConsentShownAt: timestamp("analytics_consent_shown_at", { withTimezone: true }), - analyticsConsentRemindAt: timestamp("analytics_consent_remind_at", { withTimezone: true }), }); export const teams = pgTable("teams", { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e089e803..1f846c44 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -254,7 +254,7 @@ app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => { err: error, url: request.url, method: request.method }, "Unhandled request error", ); - captureException(error, request); + captureException(error); } else { request.log.warn({ err: error, url: request.url, method: request.method }, "Request error"); } diff --git a/apps/api/src/instrument.ts b/apps/api/src/instrument.ts new file mode 100644 index 00000000..89020747 --- /dev/null +++ b/apps/api/src/instrument.ts @@ -0,0 +1,67 @@ +import { ANALYTICS_BAKED } from "@snapotter/shared"; + +const FILE_EXT_PATTERN = + /\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi; +const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai)\//g; + +if (ANALYTICS_BAKED.enabled && ANALYTICS_BAKED.sentryDsn) { + try { + const Sentry = await import("@sentry/node"); + const { APP_VERSION } = await import("@snapotter/shared"); + + Sentry.init({ + dsn: ANALYTICS_BAKED.sentryDsn, + release: APP_VERSION, + environment: process.env.NODE_ENV || "production", + tracesSampleRate: ANALYTICS_BAKED.sampleRate, + sendDefaultPii: false, + beforeSend(event) { + if (event.user) { + delete event.user.email; + delete event.user.username; + } + if (event.exception?.values) { + for (const ex of event.exception.values) { + if ( + ex.value && + (ex.value.includes("Rate limit exceeded") || + ex.value.includes("Body cannot be empty") || + ex.value.includes("Unsupported Media Type") || + ex.value.includes("Request body size did not match") || + ex.value.includes("Premature close")) + ) { + return null; + } + if (ex.value) { + ex.value = ex.value + .replace(FILE_EXT_PATTERN, ".[REDACTED]") + .replace(FILE_PATH_PATTERN, "/[REDACTED]/"); + } + if (ex.stacktrace?.frames) { + for (const frame of ex.stacktrace.frames) { + if (frame.filename) { + frame.filename = frame.filename + .replace(FILE_EXT_PATTERN, ".[REDACTED]") + .replace(FILE_PATH_PATTERN, "/[REDACTED]/"); + } + } + } + } + } + return event; + }, + beforeBreadcrumb(breadcrumb) { + if (breadcrumb.message) { + breadcrumb.message = breadcrumb.message + .replace(FILE_EXT_PATTERN, ".[REDACTED]") + .replace(FILE_PATH_PATTERN, "/[REDACTED]/"); + } + return breadcrumb; + }, + }); + + console.log("[sentry] initialized with performance tracing, release:", APP_VERSION); + } catch { + // @sentry/node not available + } +} diff --git a/apps/api/src/jobs/types.ts b/apps/api/src/jobs/types.ts index d8689f75..336a956f 100644 --- a/apps/api/src/jobs/types.ts +++ b/apps/api/src/jobs/types.ts @@ -46,6 +46,7 @@ export interface ToolJobData { parentId?: string; totalFiles?: number; fileIndex?: number; + analyticsDistinctId?: string; _otel?: { traceparent: string; tracestate?: string }; } diff --git a/apps/api/src/jobs/worker.ts b/apps/api/src/jobs/worker.ts index 455d2a86..fbb5f246 100644 --- a/apps/api/src/jobs/worker.ts +++ b/apps/api/src/jobs/worker.ts @@ -25,10 +25,12 @@ import { mkdir, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { context, propagation, ROOT_CONTEXT, SpanStatusCode, trace } from "@opentelemetry/api"; +import { ANALYTICS_BAKED, ANALYTICS_EVENTS, getBundleForTool, TOOLS } from "@snapotter/shared"; import { type Job, UnrecoverableError, Worker } from "bullmq"; import { eq } from "drizzle-orm"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; +import { captureException, trackEvent } from "../lib/analytics.js"; import { resolveConcurrency } from "../lib/env.js"; import { friendlyError } from "../lib/errors.js"; import { logger } from "../lib/logger.js"; @@ -312,6 +314,22 @@ async function processToolJob(job: Job): Promise { jobsTotal.inc({ pool: data.pool, status: "completed" }); jobDuration.observe({ pool: data.pool }, durationMs / 1000); + // Analytics: emit tool_used on success + if (ANALYTICS_BAKED.enabled) { + const tool = TOOLS.find((t) => t.id === data.toolId); + void trackEvent( + ANALYTICS_EVENTS.TOOL_USED, + { + tool_id: data.toolId, + status: "completed", + duration_ms: durationMs, + category: tool?.category ?? "unknown", + is_ai_tool: getBundleForTool(data.toolId) !== null, + }, + data.analyticsDistinctId, + ); + } + // Emit terminal progress event with legacy result payload const legacyResult = buildLegacyResultPayload(jobResult, jobId); updateSingleFileProgress({ @@ -401,6 +419,26 @@ async function processToolJob(job: Job): Promise { } } + // Analytics: emit tool_used on failure + if (ANALYTICS_BAKED.enabled) { + const tool = TOOLS.find((t) => t.id === data.toolId); + void trackEvent( + ANALYTICS_EVENTS.TOOL_USED, + { + tool_id: data.toolId, + status: "failed", + duration_ms: durationMs, + category: tool?.category ?? "unknown", + is_ai_tool: getBundleForTool(data.toolId) !== null, + error_code: isTimeout ? "timeout" : isCanceled ? "cancelled" : "processing", + }, + data.analyticsDistinctId, + ); + if (!isCanceled && !isTimeout) { + void captureException(err instanceof Error ? err : new Error(String(err))); + } + } + if (isCanceled) throw new UnrecoverableError("Canceled"); if (isTimeout) throw new Error(finalError); throw err; @@ -547,6 +585,7 @@ function contentTypeForFilename(name: string): string { async function processPipelineFinalize(job: Job): Promise { const data = job.data; + const startTime = Date.now(); const totalSteps = data.totalSteps ?? 0; const steps: Array<{ step: number; toolId: string; size: number }> = []; @@ -608,6 +647,21 @@ async function processPipelineFinalize(job: Job): Promise s.toolId), + is_batch: data.kind === "batch-finalize", + duration_ms: Date.now() - startTime, + status: "failed", + }, + data.analyticsDistinctId, + ); + } + return { outputRefs: [], filename: data.filename, @@ -677,6 +731,21 @@ async function processPipelineFinalize(job: Job): Promise s.toolId), + is_batch: data.kind === "batch-finalize", + duration_ms: Date.now() - startTime, + status: "completed", + }, + data.analyticsDistinctId, + ); + } + return result; } @@ -805,6 +874,12 @@ export function startWorkers(): void { logger.error({ err, pool }, "Worker error"); }); + worker.on("failed", (job, err) => { + if (ANALYTICS_BAKED.enabled && job) { + void captureException(err instanceof Error ? err : new Error(String(err))); + } + }); + workers.push(worker); continue; } @@ -827,6 +902,12 @@ export function startWorkers(): void { logger.error({ err, pool }, "Worker error"); }); + worker.on("failed", (job, err) => { + if (ANALYTICS_BAKED.enabled && job) { + void captureException(err instanceof Error ? err : new Error(String(err))); + } + }); + workers.push(worker); } diff --git a/apps/api/src/lib/analytics.ts b/apps/api/src/lib/analytics.ts index 26f517b4..1093a77c 100644 --- a/apps/api/src/lib/analytics.ts +++ b/apps/api/src/lib/analytics.ts @@ -1,92 +1,32 @@ +import { ANALYTICS_BAKED } from "@snapotter/shared"; import { eq } from "drizzle-orm"; -import type { FastifyRequest } from "fastify"; import type { PostHog } from "posthog-node"; -import { env } from "../config.js"; import { db, schema } from "../db/index.js"; -import { getAuthUser } from "../plugins/auth.js"; - -const FILE_EXT_PATTERN = - /\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi; -const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai)\//g; let posthogClient: PostHog | null = null; -let sentryModule: typeof import("@sentry/node") | null = null; export async function initAnalytics(): Promise { - if (!env.ANALYTICS_ENABLED || !env.POSTHOG_API_KEY) return; + if (!ANALYTICS_BAKED.enabled) return; - try { - const { PostHog } = await import("posthog-node"); - posthogClient = new PostHog(env.POSTHOG_API_KEY, { - host: env.POSTHOG_HOST, - flushAt: 20, - flushInterval: 30000, - }); - } catch { - // posthog-node not available — analytics disabled - } - - if (env.SENTRY_DSN) { + if (ANALYTICS_BAKED.posthogApiKey) { try { - sentryModule = await import("@sentry/node"); - sentryModule.init({ - dsn: env.SENTRY_DSN, - sendDefaultPii: false, - beforeSend(event) { - if (event.user) { - delete event.user.email; - delete event.user.username; - } - if (event.exception?.values) { - for (const ex of event.exception.values) { - if ( - ex.value && - (ex.value.includes("Rate limit exceeded") || - ex.value.includes("Body cannot be empty") || - ex.value.includes("Unsupported Media Type") || - ex.value.includes("Request body size did not match") || - ex.value.includes("Premature close")) - ) { - return null; - } - if (ex.value) { - ex.value = ex.value - .replace(FILE_EXT_PATTERN, ".[REDACTED]") - .replace(FILE_PATH_PATTERN, "/[REDACTED]/"); - } - if (ex.stacktrace?.frames) { - for (const frame of ex.stacktrace.frames) { - if (frame.filename) { - frame.filename = frame.filename - .replace(FILE_EXT_PATTERN, ".[REDACTED]") - .replace(FILE_PATH_PATTERN, "/[REDACTED]/"); - } - } - } - } - } - return event; - }, - beforeBreadcrumb(breadcrumb) { - if (breadcrumb.message) { - breadcrumb.message = breadcrumb.message - .replace(FILE_EXT_PATTERN, ".[REDACTED]") - .replace(FILE_PATH_PATTERN, "/[REDACTED]/"); - } - return breadcrumb; - }, + const { PostHog } = await import("posthog-node"); + posthogClient = new PostHog(ANALYTICS_BAKED.posthogApiKey, { + host: ANALYTICS_BAKED.posthogHost, + flushAt: 20, + flushInterval: 30000, }); } catch { - // @sentry/node not available + // posthog-node not available } } } -export async function captureException(error: unknown, request?: FastifyRequest): Promise { +export async function captureException(error: unknown): Promise { try { - if (!sentryModule) return; - if (request && !(await isRequestOptedIn(request))) return; - sentryModule.captureException(error); + if (!ANALYTICS_BAKED.enabled) return; + const Sentry = await import("@sentry/node"); + Sentry.captureException(error); } catch { // analytics must never throw } @@ -97,11 +37,6 @@ export async function shutdownAnalytics(): Promise { await posthogClient.shutdown(); posthogClient = null; } - - if (sentryModule) { - await sentryModule.close(2000); - sentryModule = null; - } } async function getInstanceId(): Promise { @@ -112,39 +47,18 @@ async function getInstanceId(): Promise { return row?.value ?? "unknown"; } -async function isUserOptedIn(userId: string): Promise { - if (!env.ANALYTICS_ENABLED) return false; - if (userId === "anonymous") return false; - const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); - return user?.analyticsEnabled === true; -} - -async function isRequestOptedIn(request: FastifyRequest): Promise { - if (!env.ANALYTICS_ENABLED) return false; - const user = getAuthUser(request); - if (!user) return false; - if (user.id === "anonymous") { - const header = request.headers["x-analytics-consent"]; - return header === "true"; - } - return await isUserOptedIn(user.id); -} - -function shouldSample(): boolean { - if (env.ANALYTICS_SAMPLE_RATE >= 1.0) return true; - if (env.ANALYTICS_SAMPLE_RATE <= 0.0) return false; - return Math.random() < env.ANALYTICS_SAMPLE_RATE; -} - export async function trackEvent( - request: FastifyRequest, event: string, properties: Record, + distinctId?: string, ): Promise { try { - if (!posthogClient || !(await isRequestOptedIn(request)) || !shouldSample()) return; + if (!ANALYTICS_BAKED.enabled || !posthogClient) return; + if (ANALYTICS_BAKED.sampleRate < 1.0) { + if (ANALYTICS_BAKED.sampleRate <= 0.0 || Math.random() >= ANALYTICS_BAKED.sampleRate) return; + } posthogClient.capture({ - distinctId: await getInstanceId(), + distinctId: distinctId ?? (await getInstanceId()), event, properties, }); diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 8af803d1..262ed9df 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -110,14 +110,6 @@ const envSchema = z AUDIT_RETENTION_DAYS: z.coerce.number().default(0), LOG_DIR: z.string().default("./data/logs"), SCRATCH_PATH: z.string().default(""), - ANALYTICS_ENABLED: z - .enum(["true", "false"]) - .default("false") - .transform((v) => v === "true"), - ANALYTICS_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1.0), - POSTHOG_API_KEY: z.string().default(""), - POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), - SENTRY_DSN: z.string().default(""), DATA_ENCRYPTION_KEY: z.string().default(""), DATA_ENCRYPTION_KEY_PREVIOUS: z.string().default(""), }) diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index a3c59a03..c5d1261d 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -435,9 +435,6 @@ export async function authRoutes(app: FastifyInstance): Promise { mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : user.mustChangePassword, permissions: await getPermissions(user.role), teamName: teamRow?.name ?? user.team, - analyticsEnabled: user.analyticsEnabled ?? null, - analyticsConsentShownAt: user.analyticsConsentShownAt?.getTime() ?? null, - analyticsConsentRemindAt: user.analyticsConsentRemindAt?.getTime() ?? null, }, expiresAt: expiresAt.toISOString(), ...(mfaRequired && { mfaRequired: true }), @@ -498,9 +495,6 @@ export async function authRoutes(app: FastifyInstance): Promise { role: "admin", mustChangePassword: false, permissions: await getPermissions("admin"), - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, }, expiresAt: null, }); @@ -538,9 +532,6 @@ export async function authRoutes(app: FastifyInstance): Promise { email: user.email ?? null, hasLocalPassword: !!user.passwordHash, hasOidcLink: !!user.externalId, - analyticsEnabled: user.analyticsEnabled ?? null, - analyticsConsentShownAt: user.analyticsConsentShownAt?.getTime() ?? null, - analyticsConsentRemindAt: user.analyticsConsentRemindAt?.getTime() ?? null, }, expiresAt: session.expiresAt.toISOString(), }); diff --git a/apps/api/src/plugins/mfa.ts b/apps/api/src/plugins/mfa.ts index 0a5880b4..99de6cbf 100644 --- a/apps/api/src/plugins/mfa.ts +++ b/apps/api/src/plugins/mfa.ts @@ -350,9 +350,6 @@ export async function registerMfa(app: FastifyInstance): Promise { mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : dbUser.mustChangePassword, permissions: await getPermissions(dbUser.role), teamName: teamRow?.name ?? dbUser.team, - analyticsEnabled: dbUser.analyticsEnabled ?? null, - analyticsConsentShownAt: dbUser.analyticsConsentShownAt?.getTime() ?? null, - analyticsConsentRemindAt: dbUser.analyticsConsentRemindAt?.getTime() ?? null, }, expiresAt: expiresAt.toISOString(), }); diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts index abc275a8..12755206 100644 --- a/apps/api/src/routes/analytics.ts +++ b/apps/api/src/routes/analytics.ts @@ -1,86 +1,22 @@ +import { ANALYTICS_BAKED } from "@snapotter/shared"; import { eq } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; -import { z } from "zod"; -import { env } from "../config.js"; import { db, schema } from "../db/index.js"; -import { requireAuth } from "../plugins/auth.js"; - -const analyticsConsentSchema = z.object({ - enabled: z.boolean().optional(), - remindLater: z.boolean().optional(), -}); export async function analyticsRoutes(app: FastifyInstance): Promise { app.get("/api/v1/config/analytics", async () => { - if (!env.ANALYTICS_ENABLED) { - return { - enabled: false, - posthogApiKey: "", - posthogHost: "", - sentryDsn: "", - sampleRate: 0, - instanceId: "", - }; - } - const [row] = await db .select() .from(schema.settings) .where(eq(schema.settings.key, "instance_id")); return { - enabled: true, - posthogApiKey: env.POSTHOG_API_KEY, - posthogHost: env.POSTHOG_HOST, - sentryDsn: env.SENTRY_DSN, - sampleRate: env.ANALYTICS_SAMPLE_RATE, + enabled: ANALYTICS_BAKED.enabled, + posthogApiKey: ANALYTICS_BAKED.posthogApiKey, + posthogHost: ANALYTICS_BAKED.posthogHost, + sentryDsn: ANALYTICS_BAKED.sentryDsn, + sampleRate: ANALYTICS_BAKED.sampleRate, instanceId: row?.value ?? "", }; }); - - app.put( - "/api/v1/user/analytics", - { config: { rateLimit: { max: 300, timeWindow: "1 minute" } } }, - async (request, reply) => { - const user = requireAuth(request, reply); - if (!user) return; - - const parsed = analyticsConsentSchema.safeParse(request.body ?? {}); - if (!parsed.success) { - return reply.status(400).send({ - error: parsed.error.issues.map((i) => i.message).join("; "), - code: "VALIDATION_ERROR", - }); - } - const body = parsed.data; - - const now = new Date(); - - if (body.remindLater) { - const remindAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - await db - .update(schema.users) - .set({ - analyticsEnabled: null, - analyticsConsentShownAt: now, - analyticsConsentRemindAt: remindAt, - updatedAt: now, - }) - .where(eq(schema.users.id, user.id)); - return reply.send({ ok: true, analyticsEnabled: null }); - } - - const enabled = body.enabled === true; - await db - .update(schema.users) - .set({ - analyticsEnabled: enabled, - analyticsConsentShownAt: now, - analyticsConsentRemindAt: null, - updatedAt: now, - }) - .where(eq(schema.users.id, user.id)); - return reply.send({ ok: true, analyticsEnabled: enabled }); - }, - ); } diff --git a/apps/api/src/routes/batch.ts b/apps/api/src/routes/batch.ts index 90155a39..51e737ef 100644 --- a/apps/api/src/routes/batch.ts +++ b/apps/api/src/routes/batch.ts @@ -304,6 +304,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise { inputRefs: [key], filename: processFilename, settings, + analyticsDistinctId: request.headers["x-posthog-distinct-id"] as string | undefined, } satisfies ToolJobData, // Children swallow failures via return markers, so a retry would // never run; attempts: 1 makes that explicit. @@ -340,6 +341,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise { inputRefs: [], filename: "", settings: { flowChildCount: flowChildren.length }, + analyticsDistinctId: request.headers["x-posthog-distinct-id"] as string | undefined, } satisfies ToolJobData, opts: { jobId: parentId, attempts: 1 }, children: flowChildren, diff --git a/apps/api/src/routes/features.ts b/apps/api/src/routes/features.ts index 87b4818b..d2adfc06 100644 --- a/apps/api/src/routes/features.ts +++ b/apps/api/src/routes/features.ts @@ -165,7 +165,6 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise const modelsDir = getModelsDir(); const installStartTime = Date.now(); - const reqRef = request; // Hold the venv lock across the whole install so no AI tool job loads // native libs from the venv while pip is rewriting them (that segfaults @@ -240,7 +239,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise shutdownDispatcher(); setInstallProgress(null, null, null); updateSingleFileProgress({ jobId, phase: "complete", percent: 100, stage: "Complete" }); - trackEvent(reqRef, ANALYTICS_EVENTS.AI_BUNDLE_ACTION, { + trackEvent(ANALYTICS_EVENTS.AI_BUNDLE_ACTION, { bundle_id: bundleId, action: "installed", duration_ms: Date.now() - installStartTime, @@ -366,7 +365,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise markUninstalled(bundleId); shutdownDispatcher(); - trackEvent(request, ANALYTICS_EVENTS.AI_BUNDLE_ACTION, { + trackEvent(ANALYTICS_EVENTS.AI_BUNDLE_ACTION, { bundle_id: bundleId, action: "uninstalled", duration_ms: 0, @@ -410,7 +409,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise const result = await importBundleArchive(part.file); invalidateCache(); shutdownDispatcher(); - trackEvent(request, ANALYTICS_EVENTS.AI_BUNDLE_ACTION, { + trackEvent(ANALYTICS_EVENTS.AI_BUNDLE_ACTION, { bundle_id: result.bundleId, action: "imported", duration_ms: 0, diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index 7642daf6..6ef5b5e8 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -11,7 +11,7 @@ import { randomUUID } from "node:crypto"; import { mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ANALYTICS_EVENTS, FEATURE_BUNDLES, MODALITY_POOL, TOOLS } from "@snapotter/shared"; +import { FEATURE_BUNDLES, MODALITY_POOL, TOOLS } from "@snapotter/shared"; import archiver from "archiver"; import type { FlowJob } from "bullmq"; import { eq } from "drizzle-orm"; @@ -22,7 +22,6 @@ import { db, schema } from "../db/index.js"; import { recordChildOutcome } from "../jobs/batch-progress.js"; import { getFlowProducer, injectTraceContext, waitForJob } from "../jobs/enqueue.js"; import { type Pool, queueName, type ToolJobData } from "../jobs/types.js"; -import { trackEvent } from "../lib/analytics.js"; import { autoOrient } from "../lib/auto-orient.js"; import { getSecurityHeaders } from "../lib/csp.js"; import { formatZodErrors } from "../lib/errors.js"; @@ -115,6 +114,7 @@ function buildPipelineFlowTree(opts: { clientJobId?: string; parentId?: string; totalFiles?: number; + analyticsDistinctId?: string; }): { tree: FlowJob; stepJobIds: string[] } { const { jobId, @@ -126,6 +126,7 @@ function buildPipelineFlowTree(opts: { clientJobId, parentId, totalFiles, + analyticsDistinctId, } = opts; const totalSteps = parsedSteps.length; const stepJobIds = parsedSteps.map((_: unknown, i: number) => `${jobId}-s${i}`); @@ -149,6 +150,7 @@ function buildPipelineFlowTree(opts: { inputRefs: [uploadKey], filename, settings: parsedSteps[0].parsedSettings, + analyticsDistinctId, } satisfies ToolJobData, opts: { jobId: stepJobIds[0], attempts: 1 }, }; @@ -170,6 +172,7 @@ function buildPipelineFlowTree(opts: { inputRefs: [], filename, settings: parsedSteps[i].parsedSettings, + analyticsDistinctId, } satisfies ToolJobData, opts: { jobId: stepJobIds[i], attempts: 1 }, children: [currentNode], @@ -195,6 +198,7 @@ function buildPipelineFlowTree(opts: { inputRefs: [], filename, settings: {}, + analyticsDistinctId, } satisfies ToolJobData, opts: { jobId, attempts: 1 }, children: [currentNode], @@ -423,7 +427,6 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise s.toolId), - is_batch: false, - duration_ms: Date.now() - startTime, - status: "failed", - }); return reply.status(422).send({ error: "Pipeline processing timed out", }); @@ -511,27 +508,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise s.toolId), - is_batch: false, - duration_ms: Date.now() - startTime, - status: "failed", - }); return reply.status(422).send({ error: result.resultPayload.error as string, completedSteps: result.resultPayload.steps, }); } - trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, { - step_count: pipeline.steps.length, - tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId), - is_batch: false, - duration_ms: Date.now() - startTime, - status: "completed", - }); - return reply.send({ jobId, downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`, @@ -544,13 +526,6 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise s.toolId), - is_batch: false, - duration_ms: Date.now() - startTime, - status: "failed", - }); return reply.status(422).send({ error: err instanceof Error ? err.message : "Pipeline processing failed", }); @@ -856,7 +831,6 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise s.toolId), - is_batch: true, - file_count: files.length, - duration_ms: Date.now() - batchStartTime, - status: "failed", - }); return reply.status(422).send({ error: "All files failed processing", errors: preFailures.map((f) => ({ filename: f.filename, error: f.error })), @@ -1056,6 +1023,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise s.toolId), - is_batch: true, - file_count: files.length, - duration_ms: Date.now() - batchStartTime, - status: "failed", - }); return reply.status(422).send({ error: "Pipeline batch processing timed out" }); } @@ -1150,29 +1110,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise !r.outputRef); if (successEntries.length === 0) { - trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, { - step_count: pipeline.steps.length, - tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId), - is_batch: true, - file_count: files.length, - duration_ms: Date.now() - batchStartTime, - status: "failed", - }); return reply.status(422).send({ error: "All files failed processing", errors: failedEntries.map((f) => ({ filename: f.filename, error: f.error ?? "Failed" })), }); } - trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, { - step_count: pipeline.steps.length, - tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId), - is_batch: true, - file_count: files.length, - duration_ms: Date.now() - batchStartTime, - status: "completed", - }); - const fileResultsMap: Record = {}; for (const entry of successEntries) { const uniqueName = getUniqueName(entry.filename); diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index c30605f9..390c89f1 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -3,7 +3,6 @@ import { mkdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { extname, join } from "node:path"; import { - ANALYTICS_EVENTS, apiToolPath, getBundleForTool, type Section, @@ -16,7 +15,6 @@ import type { z } from "zod"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { enqueueToolJob, waitForJob } from "../jobs/enqueue.js"; -import { trackEvent } from "../lib/analytics.js"; import { formatZodErrors, friendlyError, stripInternalPaths } from "../lib/errors.js"; import { isToolInstalled } from "../lib/feature-status.js"; import { getObjectBuffer, putObject } from "../lib/object-storage.js"; @@ -556,6 +554,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig fileId: fileId ?? undefined, clientJobId: clientJobId ?? undefined, kind: "tool", + analyticsDistinctId: request.headers["x-posthog-distinct-id"] as string | undefined, }); // Long tools never block the HTTP request (spec 4.5): straight to SSE. @@ -566,14 +565,6 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig try { const result = await waitForJob(pool, jobId); if (result) { - trackEvent(request, ANALYTICS_EVENTS.TOOL_USED, { - tool_id: config.toolId, - status: "completed", - duration_ms: Date.now() - startTime, - category: TOOLS.find((t) => t.id === config.toolId)?.category ?? "unknown", - is_ai_tool: getBundleForTool(config.toolId) !== null, - }); - // Fire-and-forget: audit log must never block the response import("../lib/audit.js") .then(({ isToolAuditEnabled, auditFromRequest }) => @@ -608,15 +599,6 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig } return reply.status(202).send({ jobId: clientJobId || jobId, async: true }); } catch (err) { - trackEvent(request, ANALYTICS_EVENTS.TOOL_USED, { - tool_id: config.toolId, - status: "failed", - duration_ms: Date.now() - startTime, - category: TOOLS.find((t) => t.id === config.toolId)?.category ?? "unknown", - is_ai_tool: getBundleForTool(config.toolId) !== null, - error_code: err instanceof Error ? err.constructor.name : "UnknownError", - error_message: err instanceof Error ? err.message.slice(0, 200) : "Processing failed", - }); // Keep the full error (incl. raw ffmpeg/tool stderr) in server logs, // but return only a user-safe detail to the client. request.log.error({ err, toolId: config.toolId }, "tool processing failed"); diff --git a/apps/docs/guide/deployment.md b/apps/docs/guide/deployment.md index 949205a1..79e9518f 100644 --- a/apps/docs/guide/deployment.md +++ b/apps/docs/guide/deployment.md @@ -522,3 +522,13 @@ gh workflow run release.yml ``` Semantic-release determines the version from commit history. The `latest` Docker tag always points to the most recent release. + +## Analytics + +The official SnapOtter Docker image includes anonymous usage analytics (PostHog) and crash reporting (Sentry) to help improve the software. No IP addresses, personal identifiers, or file contents are collected. + +To disable analytics, build from source with the analytics flag off: + +```bash +docker compose build --build-arg SNAPOTTER_ANALYTICS=off +``` diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c3571ac6..d675a802 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { APP_VERSION, en, shouldShowConsent } from "@snapotter/shared"; +import { en } from "@snapotter/shared"; import { Component, type ErrorInfo, lazy, type ReactNode, Suspense, useEffect } from "react"; import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Toaster, toast } from "sonner"; @@ -8,7 +8,7 @@ import { RouteAnnouncer } from "./components/common/route-announcer"; import { I18nProvider } from "./contexts/i18n-context"; import { useAuth } from "./hooks/use-auth"; import { useMobile } from "./hooks/use-mobile"; -import { identify, initAnalytics, setAnalyticsConsent } from "./lib/analytics"; +import { initAnalytics } from "./lib/analytics"; import { useAnalyticsStore } from "./stores/analytics-store"; // Lazy-load all pages so each page's JS (and its icons/deps) is only @@ -25,9 +25,6 @@ const LoginPage = lazy(() => import("./pages/login-page").then((m) => ({ default const PrivacyPolicyPage = lazy(() => import("./pages/privacy-policy-page").then((m) => ({ default: m.PrivacyPolicyPage })), ); -const AnalyticsConsentPage = lazy(() => - import("./pages/analytics-consent-page").then((m) => ({ default: m.AnalyticsConsentPage })), -); const EditorPage = lazy(() => import("./pages/editor-page").then((m) => ({ default: m.EditorPage })), ); @@ -51,6 +48,13 @@ class ErrorBoundary extends Component< componentDidCatch(error: Error, info: ErrorInfo) { console.error("Uncaught render error:", error, info.componentStack); + import("@sentry/react") + .then((Sentry) => { + Sentry.captureException(error, { + contexts: { react: { componentStack: info.componentStack ?? undefined } }, + }); + }) + .catch(() => {}); } render() { @@ -81,36 +85,9 @@ class ErrorBoundary extends Component< } function AuthGuard({ children }: { children: React.ReactNode }) { - const { - loading, - authEnabled, - isAuthenticated, - mustChangePassword, - analyticsEnabled, - analyticsConsentShownAt, - analyticsConsentRemindAt, - } = useAuth(); - const storeConsent = useAnalyticsStore((s) => s.consent); - const setStoreConsent = useAnalyticsStore((s) => s.setConsent); - const analyticsConfig = useAnalyticsStore((s) => s.config); + const { loading, authEnabled, isAuthenticated, mustChangePassword } = useAuth(); const location = useLocation(); - // biome-ignore lint/correctness/useExhaustiveDependencies: only hydrate on session load, not on store changes - useEffect(() => { - if ( - !loading && - analyticsEnabled !== undefined && - storeConsent.analyticsConsentShownAt === null && - storeConsent.analyticsEnabled === null - ) { - setStoreConsent({ - analyticsEnabled: analyticsEnabled ?? null, - analyticsConsentShownAt: analyticsConsentShownAt ?? null, - analyticsConsentRemindAt: analyticsConsentRemindAt ?? null, - }); - } - }, [loading, analyticsEnabled, analyticsConsentShownAt, setStoreConsent]); - // When auth is disabled, redirect away from login/change-password to prevent escalation if ( !loading && @@ -124,8 +101,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) { if ( location.pathname === "/login" || location.pathname === "/change-password" || - location.pathname === "/privacy" || - location.pathname === "/analytics-consent" + location.pathname === "/privacy" ) { return <>{children}; } @@ -150,22 +126,6 @@ function AuthGuard({ children }: { children: React.ReactNode }) { return ; } - const effectiveConsent = { - analyticsEnabled: storeConsent.analyticsEnabled ?? analyticsEnabled ?? null, - analyticsConsentShownAt: - storeConsent.analyticsConsentShownAt ?? analyticsConsentShownAt ?? null, - analyticsConsentRemindAt: - storeConsent.analyticsConsentRemindAt ?? analyticsConsentRemindAt ?? null, - }; - const serverEnabled = analyticsConfig?.enabled ?? false; - if ( - authEnabled && - analyticsConfig !== null && - shouldShowConsent(effectiveConsent, serverEnabled) - ) { - return ; - } - return <>{children}; } @@ -183,7 +143,6 @@ export function App() { const analyticsConfig = useAnalyticsStore((s) => s.config); const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded); const fetchAnalyticsConfig = useAnalyticsStore((s) => s.fetchConfig); - const analyticsConsent = useAnalyticsStore((s) => s.consent); useEffect(() => { fetchAnalyticsConfig(); @@ -199,22 +158,9 @@ export function App() { }, []); useEffect(() => { - if ( - !analyticsConfigLoaded || - !analyticsConfig?.enabled || - analyticsConsent.analyticsEnabled !== true - ) - return; - void (async () => { - setAnalyticsConsent(true); - await initAnalytics(analyticsConfig); - identify( - analyticsConfig.instanceId, - { version: APP_VERSION }, - { instance_id: analyticsConfig.instanceId }, - ); - })(); - }, [analyticsConfigLoaded, analyticsConfig, analyticsConsent.analyticsEnabled]); + if (!analyticsConfigLoaded || !analyticsConfig?.enabled) return; + void initAnalytics(analyticsConfig); + }, [analyticsConfigLoaded, analyticsConfig]); return ( @@ -238,7 +184,6 @@ export function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/apps/web/src/components/common/review-panel.tsx b/apps/web/src/components/common/review-panel.tsx index bb4ee165..5f872356 100644 --- a/apps/web/src/components/common/review-panel.tsx +++ b/apps/web/src/components/common/review-panel.tsx @@ -1,3 +1,4 @@ +import { ANALYTICS_EVENTS } from "@snapotter/shared"; import { AlertCircle, ArrowLeft, CheckCircle2, Download, FileText, FolderPlus } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { Link } from "react-router-dom"; @@ -71,6 +72,9 @@ export function ReviewPanel({ }, [originalSize, fileSize]); const handleDownload = () => { + import("@/lib/analytics").then(({ track }) => { + track(ANALYTICS_EVENTS.RESULT_DOWNLOADED, {}); + }); triggerDownload(downloadUrl, filename); }; diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index f7704481..ca682c55 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -36,7 +36,6 @@ import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@ import { format, plural } from "@/lib/format"; import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n"; import { cn, copyToClipboard } from "@/lib/utils"; -import { useAnalyticsStore } from "@/stores/analytics-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useThemeStore } from "@/stores/theme-store"; import { OtterLogo } from "../common/otter-logo"; @@ -60,7 +59,6 @@ type Section = | "api-keys" | "ai-features" | "tools" - | "analytics" | "about"; interface NavItem { @@ -124,7 +122,6 @@ function useNavItems() { requiredPermission: "settings:write", }, { id: "tools", label: t.settings.nav.tools, icon: Wrench }, - { id: "analytics", label: t.settings.nav.productAnalytics, icon: Eye }, { id: "about", label: t.settings.nav.about, icon: Info }, ], [t], @@ -217,7 +214,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { {section === "api-keys" && } {section === "ai-features" && } {section === "tools" && } - {section === "analytics" && } + {section === "about" && } @@ -288,7 +285,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { {section === "api-keys" && } {section === "ai-features" && } {section === "tools" && } - {section === "analytics" && } + {section === "about" && } @@ -3384,71 +3381,6 @@ function ToolsSection() { ); } -/* ────────────────────── Analytics ────────────────────── */ - -function AnalyticsSection() { - const { t } = useTranslation(); - const { consent, config, configLoaded, fetchConfig, toggleAnalytics } = useAnalyticsStore(); - - useEffect(() => { - fetchConfig(); - }, [fetchConfig]); - - if (!configLoaded) return null; - - const disabled = !config?.enabled; - const enabled = consent.analyticsEnabled === true; - - return ( -
-
-

{t.analytics.settingsTitle}

-

{t.analytics.settingsDescription}

-

{t.analytics.settingsPrivacy}

-
- - {disabled ? ( -

- {t.analytics.settingsDisabledByAdmin} -

- ) : ( -
- - {enabled ? "Analytics enabled" : "Analytics disabled"} - - -
- )} - - - {t.analytics.learnMore} - -
- ); -} - /* ────────────────────── About ────────────────────── */ function AboutSection() { diff --git a/apps/web/src/hooks/use-auth.ts b/apps/web/src/hooks/use-auth.ts index 6161fcad..95d42b16 100644 --- a/apps/web/src/hooks/use-auth.ts +++ b/apps/web/src/hooks/use-auth.ts @@ -10,9 +10,6 @@ interface AuthState { mfaRequired: boolean; role: string | null; permissions: string[]; - analyticsEnabled: boolean | null; - analyticsConsentShownAt: number | null; - analyticsConsentRemindAt: number | null; oidcEnabled: boolean; oidcProviderName: string | null; samlEnabled: boolean; @@ -48,9 +45,6 @@ export function useAuth() { mfaRequired: false, role: null, permissions: [], - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, oidcEnabled: false, oidcProviderName: null, samlEnabled: false, @@ -78,9 +72,6 @@ export function useAuth() { mfaRequired: false, role: "admin", permissions: ANON_ADMIN_PERMISSIONS, - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, oidcEnabled: false, oidcProviderName: null, samlEnabled: false, @@ -110,9 +101,6 @@ export function useAuth() { mfaRequired: session.user?.mfaRequired === true, role: session.user?.role ?? null, permissions: session.user?.permissions ?? [], - analyticsEnabled: session.user?.analyticsEnabled ?? null, - analyticsConsentShownAt: session.user?.analyticsConsentShownAt ?? null, - analyticsConsentRemindAt: session.user?.analyticsConsentRemindAt ?? null, oidcEnabled: config.oidcEnabled ?? false, oidcProviderName: config.oidcProviderName ?? null, samlEnabled: config.samlEnabled ?? false, @@ -132,9 +120,6 @@ export function useAuth() { mfaRequired: false, role: null, permissions: [], - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, oidcEnabled: config.oidcEnabled ?? false, oidcProviderName: config.oidcProviderName ?? null, samlEnabled: config.samlEnabled ?? false, diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index ea014506..843edd70 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -1,4 +1,4 @@ -import { apiToolPath, PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared"; +import { ANALYTICS_EVENTS, apiToolPath, PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders, parseApiError } from "@/lib/api"; @@ -243,6 +243,14 @@ export function useToolProcessor(toolId: string) { return; } + import("@/lib/analytics").then(({ track }) => { + track(ANALYTICS_EVENTS.TOOL_STARTED, { + tool_id: toolId, + is_batch: false, + file_count: files.length, + }); + }); + const capturedIndex = useFileStore.getState().selectedIndex; setError(null); @@ -521,6 +529,15 @@ export function useToolProcessor(toolId: string) { setError("No files selected"); return; } + + import("@/lib/analytics").then(({ track }) => { + track(ANALYTICS_EVENTS.TOOL_STARTED, { + tool_id: toolId, + is_batch: true, + file_count: files.length, + }); + }); + if (files.length === 1) { processFiles(files, settings); return; diff --git a/apps/web/src/lib/analytics.ts b/apps/web/src/lib/analytics.ts index 4545884c..a33b5cf8 100644 --- a/apps/web/src/lib/analytics.ts +++ b/apps/web/src/lib/analytics.ts @@ -4,10 +4,11 @@ type PostHogInstance = import("posthog-js").PostHog; let posthog: PostHogInstance | null = null; let initialized = false; -let consentGranted = false; const FILE_EXT_PATTERN = /\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi; +const FILE_EXT_TEST = + /\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/i; const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai|Users|home)\//g; function scrubString(str: string): string { @@ -15,7 +16,7 @@ function scrubString(str: string): string { } export async function initAnalytics(config: AnalyticsConfig): Promise { - if (initialized || !config.enabled || !consentGranted) return; + if (initialized || !config.enabled) return; try { const posthogJs = (await import("posthog-js")).default; @@ -25,29 +26,33 @@ export async function initAnalytics(config: AnalyticsConfig): Promise { autocapture: false, capture_pageview: true, disable_session_recording: true, - session_recording: { - captureCanvas: { recordCanvas: false }, - maskAllInputs: true, - maskTextSelector: ".file-name, .file-path, [data-file-name]", - blockSelector: "[data-user-content]", - }, ip: false, persistence: "localStorage", + person_profiles: "always", }) ?? null; initialized = true; } catch (err) { console.warn("[analytics] PostHog init failed:", err); } + if (posthog) { + posthog.register({ + instance_id: config.instanceId, + app_version: (await import("@snapotter/shared")).APP_VERSION, + }); + } + try { if (config.sentryDsn) { const Sentry = await import("@sentry/react"); Sentry.init({ dsn: config.sentryDsn, + release: (await import("@snapotter/shared")).APP_VERSION, + environment: "production", + tracesSampleRate: config.sampleRate, sendDefaultPii: false, + integrations: [Sentry.browserTracingIntegration()], beforeSend(event) { - if (!consentGranted) return null; - startErrorReplay(); if (event.user) { delete event.user.email; delete event.user.username; @@ -66,10 +71,9 @@ export async function initAnalytics(config: AnalyticsConfig): Promise { return event; }, beforeBreadcrumb(breadcrumb) { - if (!consentGranted) return null; if (breadcrumb.category === "ui.click") return null; if (breadcrumb.category === "fetch" && breadcrumb.data?.url) { - if (FILE_EXT_PATTERN.test(breadcrumb.data.url as string)) return null; + if (FILE_EXT_TEST.test(breadcrumb.data.url as string)) return null; } if (breadcrumb.message) { breadcrumb.message = scrubString(breadcrumb.message); @@ -83,42 +87,8 @@ export async function initAnalytics(config: AnalyticsConfig): Promise { } } -export function shutdownAnalytics(): void { - if (posthog) { - try { - posthog.opt_out_capturing(); - posthog.reset(); - } catch { - // never throw - } - } - posthog = null; - initialized = false; - consentGranted = false; -} - -export function setAnalyticsConsent(enabled: boolean): void { - consentGranted = enabled; - if (!enabled) { - shutdownAnalytics(); - } -} - -export function identify( - instanceId: string, - properties: Record, - propertiesSetOnce?: Record, -): void { - if (!posthog || !consentGranted) return; - try { - posthog.identify(instanceId, properties, propertiesSetOnce); - } catch { - // never throw - } -} - export function track(event: string, properties?: Record): void { - if (!posthog || !consentGranted) return; + if (!posthog) return; try { posthog.capture(event, properties); } catch { @@ -126,11 +96,11 @@ export function track(event: string, properties?: Record): void } } -export function startErrorReplay(): void { - if (!posthog || !consentGranted) return; +export function getDistinctId(): string | null { + if (!posthog) return null; try { - posthog.startSessionRecording(); + return posthog.get_distinct_id(); } catch { - // never throw + return null; } } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 2e2cd15f..3631d319 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,3 +1,4 @@ +import { getDistinctId } from "@/lib/analytics"; import { useConnectionStore } from "@/stores/connection-store"; const API_BASE = "/api"; @@ -60,15 +61,9 @@ export function formatHeaders(init?: HeadersInit): Headers { if (token) { headers.set("Authorization", `Bearer ${token}`); } - if (!token) { - try { - const consent = localStorage.getItem("snapotter-analytics-consent"); - if (consent === "true" || consent === "false") { - headers.set("X-Analytics-Consent", consent); - } - } catch { - // localStorage unavailable - } + const distinctId = getDistinctId(); + if (distinctId) { + headers.set("X-PostHog-Distinct-Id", distinctId); } return headers; } diff --git a/apps/web/src/pages/analytics-consent-page.tsx b/apps/web/src/pages/analytics-consent-page.tsx deleted file mode 100644 index 4187f811..00000000 --- a/apps/web/src/pages/analytics-consent-page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { Shield } from "lucide-react"; -import { useEffect } from "react"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "@/contexts/i18n-context"; -import { useAnalyticsStore } from "@/stores/analytics-store"; - -export function AnalyticsConsentPage() { - const { t } = useTranslation(); - const navigate = useNavigate(); - const { config, configLoaded, fetchConfig, acceptAnalytics, declineAnalytics, remindLater } = - useAnalyticsStore(); - - useEffect(() => { - fetchConfig(); - }, [fetchConfig]); - - useEffect(() => { - if (configLoaded && !config?.enabled) { - declineAnalytics().then(() => navigate("/", { replace: true })); - } - }, [configLoaded, config, navigate, declineAnalytics]); - - if (!configLoaded) { - return ( -
-
-
- ); - } - - const handleAccept = async () => { - await acceptAnalytics(); - window.location.href = "/"; - }; - - const handleDecline = async () => { - await remindLater(); - window.location.href = "/"; - }; - - return ( -
-
-
-
- -
-
- -
-

{t.analytics.consentTitle}

-

- {t.analytics.consentDescription} -

-
- -

{t.analytics.consentChangeable}

- -
- - -
-
-
- ); -} diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 8819e865..2fb50aad 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -1,5 +1,5 @@ import type { Tool } from "@snapotter/shared"; -import { CATEGORIES, SECTIONS, TOOLS, toolSection } from "@snapotter/shared"; +import { ANALYTICS_EVENTS, CATEGORIES, SECTIONS, TOOLS, toolSection } from "@snapotter/shared"; import { ChevronDown, Search, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; @@ -85,6 +85,16 @@ export function HomePage() { const searchResults = useFuseSearch(visibleTools, search); + useEffect(() => { + if (!search || search.length < 2) return; + const timer = setTimeout(() => { + import("@/lib/analytics").then(({ track }) => { + track(ANALYTICS_EVENTS.SEARCH, { query: search, results_count: searchResults.length }); + }); + }, 1000); + return () => clearTimeout(timer); + }, [search, searchResults.length]); + const tabTools = useMemo(() => { if (activeTab === "all") return visibleTools; return visibleTools.filter((tool) => toolSection(tool) === activeTab); diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index 1a6fa599..1b0fa16f 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -1,4 +1,5 @@ import { + ANALYTICS_EVENTS, getRequiredBundlesForTool, PYTHON_SIDECAR_TOOLS, SECTIONS, @@ -263,6 +264,13 @@ export function ToolPage() { useEffect(() => { if (tool) { recordRecentTool(tool.id); + import("@/lib/analytics").then(({ track }) => { + track(ANALYTICS_EVENTS.TOOL_OPENED, { + tool_id: tool.id, + modality: tool.modality, + category: tool.category, + }); + }); } }, [tool]); diff --git a/apps/web/src/stores/analytics-store.ts b/apps/web/src/stores/analytics-store.ts index 37db8f1f..c6bf4b70 100644 --- a/apps/web/src/stores/analytics-store.ts +++ b/apps/web/src/stores/analytics-store.ts @@ -1,29 +1,15 @@ -import type { AnalyticsConfig, ConsentState } from "@snapotter/shared"; +import type { AnalyticsConfig } from "@snapotter/shared"; import { create } from "zustand"; -import { setAnalyticsConsent } from "@/lib/analytics"; -import { apiPut } from "@/lib/api"; interface AnalyticsState { config: AnalyticsConfig | null; - consent: ConsentState; configLoaded: boolean; fetchConfig: () => Promise; - setConsent: (consent: ConsentState) => void; - acceptAnalytics: () => Promise; - declineAnalytics: () => Promise; - remindLater: () => Promise; - toggleAnalytics: (enabled: boolean) => Promise; } export const useAnalyticsStore = create((set, get) => ({ config: null, - consent: { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, - }, configLoaded: false, - fetchConfig: async () => { if (get().configLoaded) return; try { @@ -34,69 +20,4 @@ export const useAnalyticsStore = create((set, get) => ({ set({ configLoaded: true }); } }, - - setConsent: (consent: ConsentState) => { - set({ consent }); - setAnalyticsConsent(consent.analyticsEnabled === true); - }, - - acceptAnalytics: async () => { - try { - await apiPut("/v1/user/analytics", { enabled: true }); - } catch { - localStorage.setItem("snapotter-analytics-consent", "true"); - } - const now = Date.now(); - const consent: ConsentState = { - analyticsEnabled: true, - analyticsConsentShownAt: now, - analyticsConsentRemindAt: null, - }; - set({ consent }); - setAnalyticsConsent(true); - }, - - declineAnalytics: async () => { - try { - await apiPut("/v1/user/analytics", { enabled: false }); - } catch { - localStorage.setItem("snapotter-analytics-consent", "false"); - } - const now = Date.now(); - const consent: ConsentState = { - analyticsEnabled: false, - analyticsConsentShownAt: now, - analyticsConsentRemindAt: null, - }; - set({ consent }); - setAnalyticsConsent(false); - }, - - remindLater: async () => { - try { - await apiPut("/v1/user/analytics", { remindLater: true }); - } catch { - localStorage.setItem("snapotter-analytics-consent", "remind"); - } - const now = Date.now(); - const consent: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: now, - analyticsConsentRemindAt: now + 7 * 24 * 60 * 60 * 1000, - }; - set({ consent }); - setAnalyticsConsent(false); - }, - - toggleAnalytics: async (enabled: boolean) => { - try { - await apiPut("/v1/user/analytics", { enabled }); - } catch { - localStorage.setItem("snapotter-analytics-consent", enabled ? "true" : "false"); - } - set((state) => ({ - consent: { ...state.consent, analyticsEnabled: enabled }, - })); - setAnalyticsConsent(enabled); - }, })); diff --git a/apps/web/src/stores/file-store.ts b/apps/web/src/stores/file-store.ts index 1c4fdc19..628b8919 100644 --- a/apps/web/src/stores/file-store.ts +++ b/apps/web/src/stores/file-store.ts @@ -1,4 +1,4 @@ -import { detectModalityFromMime, type Modality } from "@snapotter/shared"; +import { ANALYTICS_EVENTS, detectModalityFromMime, type Modality } from "@snapotter/shared"; import { create } from "zustand"; import { fetchDecodedPreview, needsServerPreview } from "@/lib/image-preview"; @@ -166,6 +166,9 @@ export const useFileStore = create((set, get) => ({ // -- Actions -------------------------------------------------------------- setFiles: (files) => { + import("@/lib/analytics").then(({ track }) => { + track(ANALYTICS_EVENTS.FILE_ADDED, { file_count: files.length }); + }); revokeEntries(get().entries); const entries = files.map(createEntry); set({ @@ -203,6 +206,9 @@ export const useFileStore = create((set, get) => ({ }, addFiles: (files) => { + import("@/lib/analytics").then(({ track }) => { + track(ANALYTICS_EVENTS.FILE_ADDED, { file_count: files.length }); + }); const oldLen = get().entries.length; const newEntries = files.map(createEntry); const entries = [...get().entries, ...newEntries]; diff --git a/docker/Dockerfile b/docker/Dockerfile index 63acc44d..ed59338f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -59,6 +59,13 @@ COPY packages/shared/src ./packages/shared/src COPY apps/web/src ./apps/web/src COPY apps/web/public ./apps/web/public +# Bake analytics config into the shared package before building the frontend. +# The published image ships with analytics ON; self-builders can override: +# docker compose build --build-arg SNAPOTTER_ANALYTICS=off +ARG SNAPOTTER_ANALYTICS=on +COPY scripts/bake-analytics.mjs ./scripts/ +RUN node scripts/bake-analytics.mjs ${SNAPOTTER_ANALYTICS} + # Build only the web frontend (API runs from TS source via tsx) RUN --mount=type=cache,id=turbo-cache,target=/app/.turbo \ pnpm --filter @snapotter/web build diff --git a/docker/docker-compose-gpu.yml b/docker/docker-compose-gpu.yml index 3a6947a9..26db3b3f 100644 --- a/docker/docker-compose-gpu.yml +++ b/docker/docker-compose-gpu.yml @@ -10,6 +10,8 @@ services: build: context: .. dockerfile: docker/Dockerfile + args: + SNAPOTTER_ANALYTICS: "on" image: snapotter/snapotter:latest container_name: SnapOtter ports: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8b92d987..a83aeba3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,6 +9,8 @@ services: build: context: .. dockerfile: docker/Dockerfile + args: + SNAPOTTER_ANALYTICS: "on" image: snapotter/snapotter:latest container_name: SnapOtter ports: diff --git a/packages/shared/src/analytics/baked.ts b/packages/shared/src/analytics/baked.ts new file mode 100644 index 00000000..72e0099f --- /dev/null +++ b/packages/shared/src/analytics/baked.ts @@ -0,0 +1,8 @@ +// AUTO-GENERATED by scripts/bake-analytics.mjs -- do not edit manually +export const ANALYTICS_BAKED = { + enabled: false, + posthogApiKey: "", + posthogHost: "", + sentryDsn: "", + sampleRate: 0, +} as const; diff --git a/packages/shared/src/analytics/consent.ts b/packages/shared/src/analytics/consent.ts deleted file mode 100644 index 82c58dda..00000000 --- a/packages/shared/src/analytics/consent.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ConsentState } from "./types.js"; - -export function shouldShowConsent(consent: ConsentState, serverEnabled: boolean): boolean { - if (!serverEnabled) return false; - if (consent.analyticsEnabled !== null) return false; - if (consent.analyticsConsentShownAt === null) return true; - if (consent.analyticsConsentRemindAt === null) return false; - return Date.now() >= consent.analyticsConsentRemindAt; -} - -export function isConsentEnabled(consent: ConsentState, serverEnabled: boolean): boolean { - if (!serverEnabled) return false; - return consent.analyticsEnabled === true; -} diff --git a/packages/shared/src/analytics/events.ts b/packages/shared/src/analytics/events.ts index 89f87248..465239b0 100644 --- a/packages/shared/src/analytics/events.ts +++ b/packages/shared/src/analytics/events.ts @@ -1,8 +1,16 @@ export const ANALYTICS_EVENTS = { TOOL_USED: "tool_used", + TOOL_OPENED: "tool_opened", + FILE_ADDED: "file_added", + TOOL_STARTED: "tool_started", + TOOL_CLIENT_ERROR: "tool_client_error", + RESULT_DOWNLOADED: "result_downloaded", + RESULT_SAVED: "result_saved", SEARCH: "search", PIPELINE_EXECUTED: "pipeline_executed", AI_BUNDLE_ACTION: "ai_bundle_action", + AI_BUNDLE_PROMPTED: "ai_bundle_prompted", + BATCH_PROCESSED: "batch_processed", } as const; export type AnalyticsEvent = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS]; diff --git a/packages/shared/src/analytics/types.ts b/packages/shared/src/analytics/types.ts index fcd4fec0..27d296c6 100644 --- a/packages/shared/src/analytics/types.ts +++ b/packages/shared/src/analytics/types.ts @@ -6,9 +6,3 @@ export interface AnalyticsConfig { sampleRate: number; instanceId: string; } - -export interface ConsentState { - analyticsEnabled: boolean | null; - analyticsConsentShownAt: number | null; - analyticsConsentRemindAt: number | null; -} diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index 32d648c1..c4cd411c 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -2679,7 +2679,6 @@ export const ar: TranslationKeys = { apiKeys: "مفاتيح API", aiFeatures: "ميزات AI", tools: "الأدوات", - productAnalytics: "تحليلات المنتج", about: "حول", }, general: { @@ -3004,15 +3003,6 @@ export const ar: TranslationKeys = { auditRetentionDaysDesc: "مدة الاحتفاظ بإدخالات سجل التدقيق. 0 = الاحتفاظ للأبد. الافتراضي: للأبد.", }, - analytics: { - heading: "تحليلات المنتج", - description: "مشاركة بيانات الاستخدام المجهولة للمساعدة في تحسين SnapOtter.", - privacyNote: "ملفاتك لا تغادر شبكتك أبدًا.", - disabledByAdmin: "تم تعطيل تحليلات المنتج بواسطة مسؤول الخادم.", - enabledLabel: "التحليلات مفعّلة", - disabledLabel: "التحليلات معطّلة", - learnMore: "معرفة المزيد", - }, aiFeatures: { title: "ميزات AI", description: "إدارة حزم نماذج AI لمعالجة الملفات المتقدمة.", @@ -3358,19 +3348,6 @@ export const ar: TranslationKeys = { invalidCredentials: "اسم المستخدم أو كلمة المرور غير صحيحة", connectionError: "خطأ في الاتصال", }, - analytics: { - consentTitle: "ساعد في تحسين SnapOtter", - consentDescription: - "إحصائيات الاستخدام المجهولة تساعدنا في إصلاح الأخطاء وبناء الأدوات التي تحتاجها فعلًا. ملفاتك تبقى على شبكتك.", - consentChangeable: "يمكنك إيقاف هذا في أي وقت من الإعدادات.", - acceptButton: "بالتأكيد، موافق", - declineButton: "ليس الآن", - settingsTitle: "تحليلات المنتج", - settingsDescription: "مشاركة بيانات الاستخدام المجهولة للمساعدة في تحسين SnapOtter.", - settingsPrivacy: "ملفاتك لا تغادر شبكتك أبدًا.", - settingsDisabledByAdmin: "تم تعطيل تحليلات المنتج بواسطة مسؤول الخادم.", - learnMore: "معرفة المزيد", - }, sidebar: { tools: "الأدوات", grid: "شبكة", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index cfb10bb2..1dab9786 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -2698,7 +2698,6 @@ export const de: TranslationKeys = { apiKeys: "API-Schlüssel", aiFeatures: "AI-Funktionen", tools: "Werkzeuge", - productAnalytics: "Produktanalytik", about: "Info", }, general: { @@ -3037,15 +3036,6 @@ export const de: TranslationKeys = { auditRetentionDaysDesc: "Wie lange Audit-Protokolleinträge aufbewahrt werden. 0 = unbegrenzt. Standard: unbegrenzt.", }, - analytics: { - heading: "Produktanalytik", - description: "Anonyme Nutzungsdaten teilen, um SnapOtter zu verbessern.", - privacyNote: "Ihre Dateien verlassen niemals Ihr Netzwerk.", - disabledByAdmin: "Produktanalytik wurde vom Serveradministrator deaktiviert.", - enabledLabel: "Analytik aktiviert", - disabledLabel: "Analytik deaktiviert", - learnMore: "Mehr erfahren", - }, aiFeatures: { title: "AI-Funktionen", description: "AI-Modellpakete für erweiterte Dateiverarbeitung verwalten.", @@ -3400,19 +3390,6 @@ export const de: TranslationKeys = { invalidCredentials: "Ungültiger Benutzername oder Passwort", connectionError: "Verbindungsfehler", }, - analytics: { - consentTitle: "Helfen Sie, SnapOtter zu verbessern", - consentDescription: - "Anonyme Nutzungsstatistiken helfen uns, Fehler zu beheben und die Werkzeuge zu entwickeln, die Sie wirklich brauchen. Ihre Dateien bleiben auf Ihrem Netzwerk.", - consentChangeable: "Sie können dies jederzeit in den Einstellungen deaktivieren.", - acceptButton: "Klar, einverstanden", - declineButton: "Jetzt nicht", - settingsTitle: "Produktanalytik", - settingsDescription: "Anonyme Nutzungsdaten teilen, um SnapOtter zu verbessern.", - settingsPrivacy: "Ihre Dateien verlassen niemals Ihr Netzwerk.", - settingsDisabledByAdmin: "Produktanalytik wurde vom Serveradministrator deaktiviert.", - learnMore: "Mehr erfahren", - }, sidebar: { tools: "Werkzeuge", grid: "Raster", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index e8f8720b..7db0d678 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -2644,7 +2644,6 @@ export const en = { apiKeys: "API Keys", aiFeatures: "AI Features", tools: "Tools", - productAnalytics: "Product Analytics", about: "About", }, general: { @@ -2989,15 +2988,6 @@ export const en = { auditRetentionDaysDesc: "How long to keep audit log entries. 0 = keep forever. Default: forever.", }, - analytics: { - heading: "Product Analytics", - description: "Share anonymous usage data to help improve SnapOtter.", - privacyNote: "Your files never leave your network.", - disabledByAdmin: "Product analytics has been disabled by the server administrator.", - enabledLabel: "Analytics enabled", - disabledLabel: "Analytics disabled", - learnMore: "Learn more", - }, about: { heading: "About", appName: "SnapOtter", @@ -3328,19 +3318,6 @@ export const en = { invalidCredentials: "Invalid username or password", connectionError: "Connection error", }, - analytics: { - consentTitle: "Help improve SnapOtter", - consentDescription: - "Anonymous usage stats help us fix bugs and build the tools you actually need. Your files stay on your network.", - consentChangeable: "You can turn this off anytime in Settings.", - acceptButton: "Sure, sounds good", - declineButton: "Not right now", - settingsTitle: "Product Analytics", - settingsDescription: "Share anonymous usage data to help improve SnapOtter.", - settingsPrivacy: "Your files never leave your network.", - settingsDisabledByAdmin: "Product analytics has been disabled by the server administrator.", - learnMore: "Learn more", - }, sidebar: { tools: "Tools", grid: "Grid", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index d389cbea..ad3e21ca 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -2681,7 +2681,6 @@ export const es: TranslationKeys = { apiKeys: "Claves API", aiFeatures: "Funciones de AI", tools: "Herramientas", - productAnalytics: "Analítica del producto", about: "Acerca de", }, general: { @@ -3019,16 +3018,6 @@ export const es: TranslationKeys = { auditRetentionDaysDesc: "Tiempo de conservación de entradas del registro de auditoría. 0 = conservar siempre. Predeterminado: siempre.", }, - analytics: { - heading: "Analítica del producto", - description: "Comparte datos de uso anónimos para ayudar a mejorar SnapOtter.", - privacyNote: "Tus archivos nunca salen de tu red.", - disabledByAdmin: - "La analítica del producto ha sido desactivada por el administrador del servidor.", - enabledLabel: "Analítica activada", - disabledLabel: "Analítica desactivada", - learnMore: "Más información", - }, aiFeatures: { title: "Funciones de AI", description: "Gestiona paquetes de modelos AI para procesamiento avanzado de archivos.", @@ -3382,20 +3371,6 @@ export const es: TranslationKeys = { invalidCredentials: "Nombre de usuario o contraseña inválidos", connectionError: "Error de conexión", }, - analytics: { - consentTitle: "Ayuda a mejorar SnapOtter", - consentDescription: - "Las estadísticas de uso anónimas nos ayudan a corregir errores y crear las herramientas que realmente necesitas. Tus archivos se quedan en tu red.", - consentChangeable: "Puedes desactivar esto en cualquier momento en Configuración.", - acceptButton: "Claro, me parece bien", - declineButton: "Ahora no", - settingsTitle: "Analítica del producto", - settingsDescription: "Comparte datos de uso anónimos para ayudar a mejorar SnapOtter.", - settingsPrivacy: "Tus archivos nunca salen de tu red.", - settingsDisabledByAdmin: - "La analítica del producto ha sido desactivada por el administrador del servidor.", - learnMore: "Más información", - }, sidebar: { tools: "Herramientas", grid: "Cuadrícula", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index 6a7a473b..40bc03a9 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -2704,7 +2704,6 @@ export const fr: TranslationKeys = { apiKeys: "Clés API", aiFeatures: "Fonctionnalités AI", tools: "Outils", - productAnalytics: "Analytique produit", about: "À propos", }, general: { @@ -3042,15 +3041,6 @@ export const fr: TranslationKeys = { auditRetentionDaysDesc: "Durée de conservation des entrées du journal d'audit. 0 = conserver indéfiniment. Par défaut : indéfiniment.", }, - analytics: { - heading: "Analytique produit", - description: "Partagez des données d'utilisation anonymes pour aider à améliorer SnapOtter.", - privacyNote: "Vos fichiers ne quittent jamais votre réseau.", - disabledByAdmin: "L'analytique produit a été désactivée par l'administrateur du serveur.", - enabledLabel: "Analytique activée", - disabledLabel: "Analytique désactivée", - learnMore: "En savoir plus", - }, aiFeatures: { title: "Fonctionnalités AI", description: "Gérez les paquets de modèles AI pour le traitement avancé de fichiers.", @@ -3404,21 +3394,6 @@ export const fr: TranslationKeys = { invalidCredentials: "Nom d'utilisateur ou mot de passe invalide", connectionError: "Erreur de connexion", }, - analytics: { - consentTitle: "Aidez à améliorer SnapOtter", - consentDescription: - "Les statistiques d'utilisation anonymes nous aident à corriger les bugs et à créer les outils dont vous avez vraiment besoin. Vos fichiers restent sur votre réseau.", - consentChangeable: "Vous pouvez désactiver cela à tout moment dans les Paramètres.", - acceptButton: "Bien sûr, ça me va", - declineButton: "Pas maintenant", - settingsTitle: "Analytique produit", - settingsDescription: - "Partagez des données d'utilisation anonymes pour aider à améliorer SnapOtter.", - settingsPrivacy: "Vos fichiers ne quittent jamais votre réseau.", - settingsDisabledByAdmin: - "L'analytique produit a été désactivée par l'administrateur du serveur.", - learnMore: "En savoir plus", - }, sidebar: { tools: "Outils", grid: "Grille", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index f365f6f5..656686c6 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -2676,7 +2676,6 @@ export const hi: TranslationKeys = { apiKeys: "API कुंजियां", aiFeatures: "AI फीचर्स", tools: "टूल्स", - productAnalytics: "प्रोडक्ट एनालिटिक्स", about: "जानकारी", }, general: { @@ -2997,15 +2996,6 @@ export const hi: TranslationKeys = { auditRetentionDays: "ऑडिट लॉग अवधारण (दिन)", auditRetentionDaysDesc: "ऑडिट लॉग प्रविष्टियां कितने समय तक रखें। 0 = हमेशा रखें। डिफ़ॉल्ट: हमेशा।", }, - analytics: { - heading: "प्रोडक्ट एनालिटिक्स", - description: "SnapOtter को बेहतर बनाने में मदद के लिए गुमनाम उपयोग डेटा शेयर करें।", - privacyNote: "आपकी फ़ाइलें कभी आपके नेटवर्क से बाहर नहीं जातीं।", - disabledByAdmin: "सर्वर एडमिनिस्ट्रेटर ने प्रोडक्ट एनालिटिक्स अक्षम कर दिया है।", - enabledLabel: "एनालिटिक्स सक्रिय", - disabledLabel: "एनालिटिक्स निष्क्रिय", - learnMore: "और जानें", - }, aiFeatures: { title: "AI सुविधाएं", description: "उन्नत फ़ाइल प्रोसेसिंग के लिए AI मॉडल बंडल प्रबंधित करें।", @@ -3353,19 +3343,6 @@ export const hi: TranslationKeys = { invalidCredentials: "गलत यूज़रनेम या पासवर्ड", connectionError: "कनेक्शन त्रुटि", }, - analytics: { - consentTitle: "SnapOtter को बेहतर बनाने में मदद करें", - consentDescription: - "गुमनाम उपयोग आंकड़े हमें बग ठीक करने और आपके काम के टूल्स बनाने में मदद करते हैं। आपकी फ़ाइलें आपके नेटवर्क पर ही रहती हैं।", - consentChangeable: "आप इसे कभी भी सेटिंग्स से बंद कर सकते हैं।", - acceptButton: "ज़रूर, ठीक लगता है", - declineButton: "अभी नहीं", - settingsTitle: "प्रोडक्ट एनालिटिक्स", - settingsDescription: "SnapOtter को बेहतर बनाने में मदद के लिए गुमनाम उपयोग डेटा शेयर करें।", - settingsPrivacy: "आपकी फ़ाइलें कभी आपके नेटवर्क से बाहर नहीं जातीं।", - settingsDisabledByAdmin: "सर्वर एडमिनिस्ट्रेटर ने प्रोडक्ट एनालिटिक्स अक्षम कर दिया है।", - learnMore: "और जानें", - }, sidebar: { tools: "टूल्स", grid: "ग्रिड", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index cb2b4609..6571270a 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -2690,7 +2690,6 @@ export const id: TranslationKeys = { apiKeys: "Kunci API", aiFeatures: "Fitur AI", tools: "Alat", - productAnalytics: "Analitik Produk", about: "Tentang", }, general: { @@ -3021,15 +3020,6 @@ export const id: TranslationKeys = { auditRetentionDaysDesc: "Berapa lama menyimpan entri log audit. 0 = simpan selamanya. Default: selamanya.", }, - analytics: { - heading: "Analitik Produk", - description: "Bagikan data penggunaan anonim untuk membantu meningkatkan SnapOtter.", - privacyNote: "File Anda tidak pernah meninggalkan jaringan Anda.", - disabledByAdmin: "Analitik produk telah dinonaktifkan oleh administrator server.", - enabledLabel: "Analitik aktif", - disabledLabel: "Analitik nonaktif", - learnMore: "Pelajari lebih lanjut", - }, aiFeatures: { title: "Fitur AI", description: "Kelola bundel model AI untuk pemrosesan file tingkat lanjut.", @@ -3381,19 +3371,6 @@ export const id: TranslationKeys = { invalidCredentials: "Nama pengguna atau kata sandi salah", connectionError: "Kesalahan koneksi", }, - analytics: { - consentTitle: "Bantu tingkatkan SnapOtter", - consentDescription: - "Statistik penggunaan anonim membantu kami memperbaiki bug dan membangun alat yang benar-benar Anda butuhkan. File Anda tetap di jaringan Anda.", - consentChangeable: "Anda dapat menonaktifkan ini kapan saja di Pengaturan.", - acceptButton: "Tentu, kedengarannya bagus", - declineButton: "Nanti saja", - settingsTitle: "Analitik Produk", - settingsDescription: "Bagikan data penggunaan anonim untuk membantu meningkatkan SnapOtter.", - settingsPrivacy: "File Anda tidak pernah meninggalkan jaringan Anda.", - settingsDisabledByAdmin: "Analitik produk telah dinonaktifkan oleh administrator server.", - learnMore: "Pelajari lebih lanjut", - }, sidebar: { tools: "Alat", grid: "Kisi", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index f0053cb4..65942bc4 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -2695,7 +2695,6 @@ export const it: TranslationKeys = { apiKeys: "Chiavi API", aiFeatures: "Funzionalità AI", tools: "Strumenti", - productAnalytics: "Analisi del prodotto", about: "Informazioni", }, general: { @@ -3032,15 +3031,6 @@ export const it: TranslationKeys = { auditRetentionDaysDesc: "Per quanto tempo conservare le voci del log di audit. 0 = conserva per sempre. Predefinito: per sempre.", }, - analytics: { - heading: "Analisi del prodotto", - description: "Condividi dati di utilizzo anonimi per contribuire a migliorare SnapOtter.", - privacyNote: "I tuoi file non lasciano mai la tua rete.", - disabledByAdmin: "L'analisi del prodotto è stata disattivata dall'amministratore del server.", - enabledLabel: "Analisi attivata", - disabledLabel: "Analisi disattivata", - learnMore: "Scopri di più", - }, aiFeatures: { title: "Funzionalità IA", description: "Gestisci pacchetti di modelli di IA per l'elaborazione avanzata dei file.", @@ -3393,21 +3383,6 @@ export const it: TranslationKeys = { invalidCredentials: "Nome utente o password non validi", connectionError: "Errore di connessione", }, - analytics: { - consentTitle: "Aiuta a migliorare SnapOtter", - consentDescription: - "Le statistiche d'uso anonime ci aiutano a correggere bug e creare gli strumenti di cui hai davvero bisogno. I tuoi file restano sulla tua rete.", - consentChangeable: "Puoi disattivare questa opzione in qualsiasi momento nelle Impostazioni.", - acceptButton: "Certo, va bene", - declineButton: "Non ora", - settingsTitle: "Analisi del prodotto", - settingsDescription: - "Condividi dati di utilizzo anonimi per contribuire a migliorare SnapOtter.", - settingsPrivacy: "I tuoi file non lasciano mai la tua rete.", - settingsDisabledByAdmin: - "L'analisi del prodotto è stata disattivata dall'amministratore del server.", - learnMore: "Scopri di più", - }, sidebar: { tools: "Strumenti", grid: "Griglia", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 89b95c1f..e32f4d18 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -2648,7 +2648,6 @@ export const ja: TranslationKeys = { apiKeys: "APIキー", aiFeatures: "AI機能", tools: "ツール", - productAnalytics: "プロダクト分析", about: "SnapOtterについて", }, general: { @@ -2973,15 +2972,6 @@ export const ja: TranslationKeys = { auditRetentionDays: "監査ログの保持期間(日)", auditRetentionDaysDesc: "監査ログエントリの保持期間。0 = 永久保持。デフォルト: 永久。", }, - analytics: { - heading: "プロダクト分析", - description: "匿名の使用データを共有してSnapOtterの改善にご協力ください。", - privacyNote: "ファイルがネットワーク外に出ることはありません。", - disabledByAdmin: "プロダクト分析はサーバー管理者によって無効化されています。", - enabledLabel: "分析が有効", - disabledLabel: "分析が無効", - learnMore: "詳しく見る", - }, aiFeatures: { title: "AI 機能", description: "高度なファイル処理のためのAIモデルバンドルを管理します。", @@ -3333,19 +3323,6 @@ export const ja: TranslationKeys = { invalidCredentials: "ユーザー名またはパスワードが無効です", connectionError: "接続エラー", }, - analytics: { - consentTitle: "SnapOtterの改善にご協力ください", - consentDescription: - "匿名の使用データはバグ修正や本当に必要なツールの開発に役立ちます。ファイルがネットワーク外に出ることはありません。", - consentChangeable: "設定からいつでもオフにできます。", - acceptButton: "はい、協力します", - declineButton: "今はしない", - settingsTitle: "プロダクト分析", - settingsDescription: "匿名の使用データを共有してSnapOtterの改善にご協力ください。", - settingsPrivacy: "ファイルがネットワーク外に出ることはありません。", - settingsDisabledByAdmin: "プロダクト分析はサーバー管理者によって無効化されています。", - learnMore: "詳しく見る", - }, sidebar: { tools: "ツール", grid: "グリッド", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index 79d8b640..f9053b94 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -2632,7 +2632,6 @@ export const ko: TranslationKeys = { apiKeys: "API 키", aiFeatures: "AI 기능", tools: "도구", - productAnalytics: "제품 분석", about: "정보", }, general: { @@ -2958,15 +2957,6 @@ export const ko: TranslationKeys = { auditRetentionDays: "감사 로그 보관 기간(일)", auditRetentionDaysDesc: "감사 로그 항목 보관 기간입니다. 0 = 영구 보관. 기본값: 영구 보관.", }, - analytics: { - heading: "제품 분석", - description: "익명 사용 데이터를 공유하여 SnapOtter 개선에 도움을 주세요.", - privacyNote: "파일은 절대 네트워크 밖으로 나가지 않습니다.", - disabledByAdmin: "서버 관리자가 제품 분석을 비활성화했습니다.", - enabledLabel: "분석 활성화됨", - disabledLabel: "분석 비활성화됨", - learnMore: "자세히 알아보기", - }, aiFeatures: { title: "AI 기능", description: "고급 파일 처리를 위한 AI 모델 번들을 관리합니다.", @@ -3315,19 +3305,6 @@ export const ko: TranslationKeys = { invalidCredentials: "사용자명 또는 비밀번호가 올바르지 않습니다", connectionError: "연결 오류", }, - analytics: { - consentTitle: "SnapOtter 개선에 도움을 주세요", - consentDescription: - "익명 사용 데이터는 버그 수정과 정말 필요한 도구 개발에 도움이 됩니다. 파일은 항상 네트워크 안에 머물러요.", - consentChangeable: "설정에서 언제든 끌 수 있어요.", - acceptButton: "네, 좋아요", - declineButton: "지금은 괜찮아요", - settingsTitle: "제품 분석", - settingsDescription: "익명 사용 데이터를 공유하여 SnapOtter 개선에 도움을 주세요.", - settingsPrivacy: "파일은 절대 네트워크 밖으로 나가지 않습니다.", - settingsDisabledByAdmin: "서버 관리자가 제품 분석을 비활성화했습니다.", - learnMore: "자세히 알아보기", - }, sidebar: { tools: "도구", grid: "그리드", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index ce61f713..39b74373 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -2696,7 +2696,6 @@ export const nl: TranslationKeys = { apiKeys: "API-sleutels", aiFeatures: "AI-functies", tools: "Gereedschap", - productAnalytics: "Productanalyse", about: "Over", }, general: { @@ -3028,15 +3027,6 @@ export const nl: TranslationKeys = { auditRetentionDaysDesc: "Hoe lang auditlogvermeldingen bewaard blijven. 0 = voor altijd bewaren. Standaard: voor altijd.", }, - analytics: { - heading: "Productanalyse", - description: "Deel anonieme gebruiksgegevens om SnapOtter te verbeteren.", - privacyNote: "Je bestanden verlaten nooit je netwerk.", - disabledByAdmin: "Productanalyse is uitgeschakeld door de serverbeheerder.", - enabledLabel: "Analyse ingeschakeld", - disabledLabel: "Analyse uitgeschakeld", - learnMore: "Meer informatie", - }, aiFeatures: { title: "AI-functies", description: "Beheer AI-modelbundels voor geavanceerde bestandsverwerking.", @@ -3389,19 +3379,6 @@ export const nl: TranslationKeys = { invalidCredentials: "Ongeldige gebruikersnaam of wachtwoord", connectionError: "Verbindingsfout", }, - analytics: { - consentTitle: "Help SnapOtter verbeteren", - consentDescription: - "Anonieme gebruiksstatistieken helpen ons bugs te fixen en tools te bouwen die je echt nodig hebt. Je bestanden blijven op je netwerk.", - consentChangeable: "Je kunt dit altijd uitschakelen bij Instellingen.", - acceptButton: "Ja, klinkt goed", - declineButton: "Nu even niet", - settingsTitle: "Productanalyse", - settingsDescription: "Deel anonieme gebruiksgegevens om SnapOtter te verbeteren.", - settingsPrivacy: "Je bestanden verlaten nooit je netwerk.", - settingsDisabledByAdmin: "Productanalyse is uitgeschakeld door de serverbeheerder.", - learnMore: "Meer informatie", - }, sidebar: { tools: "Tools", grid: "Raster", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index d7a30e80..4e7e4f64 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -2695,7 +2695,6 @@ export const pl: TranslationKeys = { apiKeys: "Klucze API", aiFeatures: "Funkcje AI", tools: "Narzędzia", - productAnalytics: "Analityka", about: "Informacje", }, general: { @@ -3034,15 +3033,6 @@ export const pl: TranslationKeys = { auditRetentionDaysDesc: "Jak długo przechowywać wpisy dziennika audytu. 0 = zachowaj na zawsze. Domyślnie: na zawsze.", }, - analytics: { - heading: "Analityka", - description: "Udostępnianie anonimowych danych o użytkowaniu w celu ulepszenia SnapOtter.", - privacyNote: "Państwa pliki nigdy nie opuszczają komputera.", - disabledByAdmin: "Analityka została wyłączona przez administratora serwera.", - enabledLabel: "Analityka włączona", - disabledLabel: "Analityka wyłączona", - learnMore: "Dowiedz się więcej", - }, aiFeatures: { title: "Funkcje AI", description: "Zarządzanie pakietami modeli AI do zaawansowanego przetwarzania plików.", @@ -3396,20 +3386,6 @@ export const pl: TranslationKeys = { invalidCredentials: "Nieprawidłowa nazwa użytkownika lub hasło", connectionError: "Błąd połączenia", }, - analytics: { - consentTitle: "Pomóż ulepszyć SnapOtter", - consentDescription: - "Anonimowe statystyki użytkowania pomagają nam naprawiać błędy i tworzyć narzędzia, których naprawdę potrzebujesz. Twoje pliki pozostają na Twoim komputerze.", - consentChangeable: "Możesz to wyłączyć w każdej chwili w Ustawieniach.", - acceptButton: "Jasne, brzmi dobrze", - declineButton: "Nie teraz", - settingsTitle: "Analityka", - settingsDescription: - "Udostępnianie anonimowych danych o użytkowaniu w celu ulepszenia SnapOtter.", - settingsPrivacy: "Państwa pliki nigdy nie opuszczają komputera.", - settingsDisabledByAdmin: "Analityka została wyłączona przez administratora serwera.", - learnMore: "Dowiedz się więcej", - }, sidebar: { tools: "Narzędzia", grid: "Siatka", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 04c2da51..76694f26 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -2692,7 +2692,6 @@ export const ptBR: TranslationKeys = { apiKeys: "Chaves API", aiFeatures: "Recursos de AI", tools: "Ferramentas", - productAnalytics: "Analítica do produto", about: "Sobre", }, general: { @@ -3027,15 +3026,6 @@ export const ptBR: TranslationKeys = { auditRetentionDaysDesc: "Por quanto tempo manter registros de auditoria. 0 = manter para sempre. Padrão: para sempre.", }, - analytics: { - heading: "Analítica do produto", - description: "Compartilhe dados de uso anônimos para ajudar a melhorar o SnapOtter.", - privacyNote: "Seus arquivos nunca saem do seu computador.", - disabledByAdmin: "A analítica do produto foi desativada pelo administrador do servidor.", - enabledLabel: "Analítica ativada", - disabledLabel: "Analítica desativada", - learnMore: "Saiba mais", - }, aiFeatures: { title: "Recursos de AI", description: "Gerencie pacotes de modelos de IA para processamento avançado de arquivos.", @@ -3388,20 +3378,6 @@ export const ptBR: TranslationKeys = { invalidCredentials: "Nome de usuário ou senha inválidos", connectionError: "Erro de conexão", }, - analytics: { - consentTitle: "Ajude a melhorar o SnapOtter", - consentDescription: - "Estatísticas de uso anônimas nos ajudam a corrigir bugs e criar as ferramentas que você realmente precisa. Seus arquivos ficam no seu computador.", - consentChangeable: "Você pode desativar isso a qualquer momento nas Configurações.", - acceptButton: "Claro, parece bom", - declineButton: "Agora não", - settingsTitle: "Analítica do produto", - settingsDescription: "Compartilhe dados de uso anônimos para ajudar a melhorar o SnapOtter.", - settingsPrivacy: "Seus arquivos nunca saem do seu computador.", - settingsDisabledByAdmin: - "A analítica do produto foi desativada pelo administrador do servidor.", - learnMore: "Saiba mais", - }, sidebar: { tools: "Ferramentas", grid: "Grade", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index b95d1c1d..0d85e476 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -2691,7 +2691,6 @@ export const ru: TranslationKeys = { apiKeys: "API-ключи", aiFeatures: "AI-функции", tools: "Инструменты", - productAnalytics: "Аналитика", about: "О программе", }, general: { @@ -3024,15 +3023,6 @@ export const ru: TranslationKeys = { auditRetentionDaysDesc: "Как долго хранить записи журнала аудита. 0 = хранить бессрочно. По умолчанию: бессрочно.", }, - analytics: { - heading: "Аналитика", - description: "Отправка анонимных данных об использовании для улучшения SnapOtter.", - privacyNote: "Ваши файлы никогда не покидают Ваш компьютер.", - disabledByAdmin: "Аналитика отключена администратором сервера.", - enabledLabel: "Аналитика включена", - disabledLabel: "Аналитика отключена", - learnMore: "Подробнее", - }, aiFeatures: { title: "AI-функции", description: "Управление пакетами AI-моделей для расширенной обработки файлов.", @@ -3384,19 +3374,6 @@ export const ru: TranslationKeys = { invalidCredentials: "Неверное имя пользователя или пароль", connectionError: "Ошибка подключения", }, - analytics: { - consentTitle: "Помогите улучшить SnapOtter", - consentDescription: - "Анонимная статистика использования помогает нам исправлять ошибки и создавать нужные Вам инструменты. Ваши файлы остаются на Вашем компьютере.", - consentChangeable: "Вы можете отключить это в любое время в Настройках.", - acceptButton: "Конечно, звучит хорошо", - declineButton: "Не сейчас", - settingsTitle: "Аналитика", - settingsDescription: "Отправка анонимных данных об использовании для улучшения SnapOtter.", - settingsPrivacy: "Ваши файлы никогда не покидают Ваш компьютер.", - settingsDisabledByAdmin: "Аналитика отключена администратором сервера.", - learnMore: "Подробнее", - }, sidebar: { tools: "Инструменты", grid: "Сетка", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index 0029b6a0..5cc871ae 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -2688,7 +2688,6 @@ export const sv: TranslationKeys = { apiKeys: "API-nycklar", aiFeatures: "AI-funktioner", tools: "Verktyg", - productAnalytics: "Produktanalys", about: "Om", }, general: { @@ -3018,15 +3017,6 @@ export const sv: TranslationKeys = { auditRetentionDaysDesc: "Hur länge granskningsloggposter behålls. 0 = behåll för alltid. Standard: för alltid.", }, - analytics: { - heading: "Produktanalys", - description: "Dela anonym användningsdata för att förbättra SnapOtter.", - privacyNote: "Dina filer lämnar aldrig din dator.", - disabledByAdmin: "Produktanalys har inaktiverats av serveradministratören.", - enabledLabel: "Analys aktiverad", - disabledLabel: "Analys inaktiverad", - learnMore: "Läs mer", - }, aiFeatures: { title: "AI-funktioner", description: "Hantera AI-modellpaket för avancerad filbehandling.", @@ -3376,19 +3366,6 @@ export const sv: TranslationKeys = { invalidCredentials: "Ogiltigt användarnamn eller lösenord", connectionError: "Anslutningsfel", }, - analytics: { - consentTitle: "Hjälp till att förbättra SnapOtter", - consentDescription: - "Anonym användningsstatistik hjälper oss fixa buggar och bygga verktygen du faktiskt behöver. Dina filer stannar på din dator.", - consentChangeable: "Du kan stänga av detta när som helst i Inställningar.", - acceptButton: "Visst, låter bra", - declineButton: "Inte just nu", - settingsTitle: "Produktanalys", - settingsDescription: "Dela anonym användningsdata för att förbättra SnapOtter.", - settingsPrivacy: "Dina filer lämnar aldrig din dator.", - settingsDisabledByAdmin: "Produktanalys har inaktiverats av serveradministratören.", - learnMore: "Läs mer", - }, sidebar: { tools: "Verktyg", grid: "Rutnät", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index 55d2ad5e..8e8b31a4 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -2664,7 +2664,6 @@ export const th: TranslationKeys = { apiKeys: "คีย์ API", aiFeatures: "ฟีเจอร์ AI", tools: "เครื่องมือ", - productAnalytics: "การวิเคราะห์ผลิตภัณฑ์", about: "เกี่ยวกับ", }, general: { @@ -2985,15 +2984,6 @@ export const th: TranslationKeys = { auditRetentionDays: "ระยะเวลาเก็บบันทึกตรวจสอบ (วัน)", auditRetentionDaysDesc: "ระยะเวลาเก็บรักษารายการบันทึกตรวจสอบ 0 = เก็บตลอด ค่าเริ่มต้น: เก็บตลอด", }, - analytics: { - heading: "การวิเคราะห์ผลิตภัณฑ์", - description: "แบ่งปันข้อมูลการใช้งานแบบไม่ระบุตัวตนเพื่อช่วยปรับปรุง SnapOtter", - privacyNote: "ไฟล์ของคุณไม่ออกจากเครื่องเด็ดขาด", - disabledByAdmin: "ผู้ดูแลเซิร์ฟเวอร์ปิดการวิเคราะห์ผลิตภัณฑ์แล้ว", - enabledLabel: "เปิดการวิเคราะห์", - disabledLabel: "ปิดการวิเคราะห์", - learnMore: "เรียนรู้เพิ่มเติม", - }, aiFeatures: { title: "ฟีเจอร์ AI", description: "จัดการชุดโมเดล AI สำหรับการประมวลผลไฟล์ขั้นสูง", @@ -3338,19 +3328,6 @@ export const th: TranslationKeys = { invalidCredentials: "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง", connectionError: "ข้อผิดพลาดในการเชื่อมต่อ", }, - analytics: { - consentTitle: "ช่วยปรับปรุง SnapOtter", - consentDescription: - "สถิติการใช้งานแบบไม่ระบุตัวตนช่วยให้เราแก้ไขข้อผิดพลาดและสร้างเครื่องมือที่คุณต้องการจริงๆ ไฟล์ของคุณอยู่บนเครื่องของคุณ", - consentChangeable: "คุณสามารถปิดได้ทุกเมื่อในการตั้งค่า", - acceptButton: "ได้เลย ฟังดูดี", - declineButton: "ไว้ทีหลัง", - settingsTitle: "การวิเคราะห์ผลิตภัณฑ์", - settingsDescription: "แบ่งปันข้อมูลการใช้งานแบบไม่ระบุตัวตนเพื่อช่วยปรับปรุง SnapOtter", - settingsPrivacy: "ไฟล์ของคุณไม่ออกจากเครื่องเด็ดขาด", - settingsDisabledByAdmin: "ผู้ดูแลเซิร์ฟเวอร์ปิดการวิเคราะห์ผลิตภัณฑ์แล้ว", - learnMore: "เรียนรู้เพิ่มเติม", - }, sidebar: { tools: "เครื่องมือ", grid: "กริด", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index 2b0ddcce..0dea0dcb 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -2694,7 +2694,6 @@ export const tr: TranslationKeys = { apiKeys: "API Anahtarları", aiFeatures: "AI Özellikleri", tools: "Araçlar", - productAnalytics: "Ürün Analitiği", about: "Hakkında", }, general: { @@ -3028,16 +3027,6 @@ export const tr: TranslationKeys = { auditRetentionDaysDesc: "Denetim günlüğü kayıtlarının saklanma süresi. 0 = süresiz. Varsayılan: süresiz.", }, - analytics: { - heading: "Ürün Analitiği", - description: - "SnapOtter'ı geliştirmeye yardımcı olmak için anonim kullanım verilerini paylaşın.", - privacyNote: "Dosyalarınız asla ağınızdan ayrılmaz.", - disabledByAdmin: "Ürün analitiği sunucu yöneticisi tarafından devre dışı bırakıldı.", - enabledLabel: "Analitik etkin", - disabledLabel: "Analitik devre dışı", - learnMore: "Daha fazla bilgi", - }, aiFeatures: { title: "AI Özellikleri", description: "Gelişmiş dosya işleme için AI model paketlerini yönetin.", @@ -3390,20 +3379,6 @@ export const tr: TranslationKeys = { invalidCredentials: "Geçersiz kullanıcı adı veya parola", connectionError: "Bağlantı hatası", }, - analytics: { - consentTitle: "SnapOtter'ı geliştirmeye yardım edin", - consentDescription: - "Anonim kullanım istatistikleri hataları düzeltmemize ve gerçekten ihtiyaç duyduğunuz araçları oluşturmamıza yardımcı olur. Dosyalarınız ağınızda kalır.", - consentChangeable: "Bunu istediğiniz zaman Ayarlar'dan kapatabilirsiniz.", - acceptButton: "Elbette, kulağa iyi geliyor", - declineButton: "Şimdi değil", - settingsTitle: "Ürün Analitiği", - settingsDescription: - "SnapOtter'ı geliştirmeye yardımcı olmak için anonim kullanım verilerini paylaşın.", - settingsPrivacy: "Dosyalarınız asla ağınızdan ayrılmaz.", - settingsDisabledByAdmin: "Ürün analitiği sunucu yöneticisi tarafından devre dışı bırakıldı.", - learnMore: "Daha fazla bilgi", - }, sidebar: { tools: "Araçlar", grid: "Izgara", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index a332d4d4..4fa9f417 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -2694,7 +2694,6 @@ export const uk: TranslationKeys = { apiKeys: "API-ключі", aiFeatures: "AI-функції", tools: "Інструменти", - productAnalytics: "Аналітика", about: "Про програму", }, general: { @@ -3025,15 +3024,6 @@ export const uk: TranslationKeys = { auditRetentionDaysDesc: "Як довго зберігати записи журналу аудиту. 0 = назавжди. Типово: назавжди.", }, - analytics: { - heading: "Аналітика", - description: "Надсилання анонімних даних про використання для поліпшення SnapOtter.", - privacyNote: "Ваші файли ніколи не залишають Вашу мережу.", - disabledByAdmin: "Аналітику вимкнено адміністратором сервера.", - enabledLabel: "Аналітику увімкнено", - disabledLabel: "Аналітику вимкнено", - learnMore: "Дізнатися більше", - }, aiFeatures: { title: "AI-функції", description: "Керування пакетами AI-моделей для розширеної обробки файлів.", @@ -3386,19 +3376,6 @@ export const uk: TranslationKeys = { invalidCredentials: "Невірне ім'я користувача або пароль", connectionError: "Помилка з'єднання", }, - analytics: { - consentTitle: "Допоможіть поліпшити SnapOtter", - consentDescription: - "Анонімна статистика використання допомагає нам виправляти помилки та створювати потрібні Вам інструменти. Ваші файли залишаються у Вашій мережі.", - consentChangeable: "Ви можете вимкнути це будь-коли у Налаштуваннях.", - acceptButton: "Звісно, добре", - declineButton: "Не зараз", - settingsTitle: "Аналітика", - settingsDescription: "Надсилання анонімних даних про використання для поліпшення SnapOtter.", - settingsPrivacy: "Ваші файли ніколи не залишають Вашу мережу.", - settingsDisabledByAdmin: "Аналітику вимкнено адміністратором сервера.", - learnMore: "Дізнатися більше", - }, sidebar: { tools: "Інструменти", grid: "Сітка", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index 128fea23..07224d66 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -2690,7 +2690,6 @@ export const vi: TranslationKeys = { apiKeys: "Khóa API", aiFeatures: "Tính năng AI", tools: "Công cụ", - productAnalytics: "Phân tích sản phẩm", about: "Giới thiệu", }, general: { @@ -3019,15 +3018,6 @@ export const vi: TranslationKeys = { auditRetentionDaysDesc: "Thời gian giữ các mục nhật ký kiểm tra. 0 = giữ mãi. Mặc định: vĩnh viễn.", }, - analytics: { - heading: "Phân tích sản phẩm", - description: "Chia sẻ dữ liệu sử dụng ẩn danh để giúp cải thiện SnapOtter.", - privacyNote: "Tệp của bạn không bao giờ rời khỏi mạng của bạn.", - disabledByAdmin: "Phân tích sản phẩm đã bị vô hiệu hóa bởi quản trị viên máy chủ.", - enabledLabel: "Đã bật phân tích", - disabledLabel: "Đã tắt phân tích", - learnMore: "Tìm hiểu thêm", - }, aiFeatures: { title: "Tính năng AI", description: "Quản lý các gói mô hình AI cho xử lý tệp nâng cao.", @@ -3377,19 +3367,6 @@ export const vi: TranslationKeys = { invalidCredentials: "Tên đăng nhập hoặc mật khẩu không đúng", connectionError: "Lỗi kết nối", }, - analytics: { - consentTitle: "Giúp cải thiện SnapOtter", - consentDescription: - "Thống kê sử dụng ẩn danh giúp chúng tôi sửa lỗi và xây dựng các công cụ bạn thực sự cần. Tệp của bạn luôn ở trên mạng của bạn.", - consentChangeable: "Bạn có thể tắt bất cứ lúc nào trong Cài đặt.", - acceptButton: "Được thôi, nghe hay đấy", - declineButton: "Để sau", - settingsTitle: "Phân tích sản phẩm", - settingsDescription: "Chia sẻ dữ liệu sử dụng ẩn danh để giúp cải thiện SnapOtter.", - settingsPrivacy: "Tệp của bạn không bao giờ rời khỏi mạng của bạn.", - settingsDisabledByAdmin: "Phân tích sản phẩm đã bị vô hiệu hóa bởi quản trị viên máy chủ.", - learnMore: "Tìm hiểu thêm", - }, sidebar: { tools: "Công cụ", grid: "Lưới", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index aa9e6587..8bd17c57 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -2617,7 +2617,6 @@ export const zhCN: TranslationKeys = { apiKeys: "API 密钥", aiFeatures: "AI 功能", tools: "工具", - productAnalytics: "产品分析", about: "关于", }, general: { @@ -2937,15 +2936,6 @@ export const zhCN: TranslationKeys = { auditRetentionDays: "审计日志保留时间(天)", auditRetentionDaysDesc: "审计日志条目的保留时长。0 = 永久保留。默认:永久。", }, - analytics: { - heading: "产品分析", - description: "分享匿名使用数据,帮助改进 SnapOtter。", - privacyNote: "您的文件永远不会离开您的网络。", - disabledByAdmin: "产品分析已被服务器管理员禁用。", - enabledLabel: "分析已启用", - disabledLabel: "分析已禁用", - learnMore: "了解更多", - }, aiFeatures: { title: "AI 功能", description: "管理用于高级文件处理的 AI 模型包。", @@ -3287,19 +3277,6 @@ export const zhCN: TranslationKeys = { invalidCredentials: "用户名或密码错误", connectionError: "连接错误", }, - analytics: { - consentTitle: "帮助改进 SnapOtter", - consentDescription: - "匿名使用统计帮助我们修复问题并构建您真正需要的工具。您的文件始终留在您的网络中。", - consentChangeable: "您可以随时在设置中关闭。", - acceptButton: "好的,没问题", - declineButton: "暂时不了", - settingsTitle: "产品分析", - settingsDescription: "分享匿名使用数据,帮助改进 SnapOtter。", - settingsPrivacy: "您的文件永远不会离开您的网络。", - settingsDisabledByAdmin: "产品分析已被服务器管理员禁用。", - learnMore: "了解更多", - }, sidebar: { tools: "工具", grid: "网格", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index 001b09a6..fc0e4036 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -2615,7 +2615,6 @@ export const zhTW: TranslationKeys = { apiKeys: "API金鑰", aiFeatures: "AI功能", tools: "工具", - productAnalytics: "產品分析", about: "關於", }, general: { @@ -2935,15 +2934,6 @@ export const zhTW: TranslationKeys = { auditRetentionDays: "稽核記錄保留時間(天)", auditRetentionDaysDesc: "稽核記錄項目的保留時間。0 = 永久保留。預設:永久保留。", }, - analytics: { - heading: "產品分析", - description: "分享匿名使用資料以協助改進SnapOtter。", - privacyNote: "您的檔案永遠不會離開您的網路。", - disabledByAdmin: "產品分析已被伺服器管理員停用。", - enabledLabel: "分析已啟用", - disabledLabel: "分析已停用", - learnMore: "了解更多", - }, aiFeatures: { title: "AI 功能", description: "管理用於進階檔案處理的AI模型套件。", @@ -3287,19 +3277,6 @@ export const zhTW: TranslationKeys = { invalidCredentials: "使用者名稱或密碼無效", connectionError: "連線錯誤", }, - analytics: { - consentTitle: "協助改進SnapOtter", - consentDescription: - "匿名使用資料協助我們修復問題並開發你真正需要的工具。你的檔案始終留在你的網路中。", - consentChangeable: "你可以隨時在設定中關閉。", - acceptButton: "好的,沒問題", - declineButton: "暫時不用", - settingsTitle: "產品分析", - settingsDescription: "分享匿名使用資料以協助改進SnapOtter。", - settingsPrivacy: "您的檔案永遠不會離開您的網路。", - settingsDisabledByAdmin: "產品分析已被伺服器管理員停用。", - learnMore: "了解更多", - }, sidebar: { tools: "工具", grid: "格線", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 36c15f37..247aa023 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,4 @@ -export * from "./analytics/consent.js"; +export * from "./analytics/baked.js"; export * from "./analytics/events.js"; export * from "./analytics/types.js"; export * from "./audit-events.js"; diff --git a/scripts/bake-analytics.mjs b/scripts/bake-analytics.mjs new file mode 100644 index 00000000..42e368d6 --- /dev/null +++ b/scripts/bake-analytics.mjs @@ -0,0 +1,29 @@ +import { writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outPath = resolve(__dirname, "../packages/shared/src/analytics/baked.ts"); + +const mode = process.argv[2] || "on"; + +const enabled = mode === "on"; +const posthogApiKey = enabled ? "phc_REPLACE_WITH_REAL_KEY" : ""; +const posthogHost = enabled ? "https://us.i.posthog.com" : ""; +const sentryDsn = enabled + ? "https://REPLACE_WITH_REAL_DSN@o0.ingest.us.sentry.io/0" + : ""; +const sampleRate = enabled ? 1 : 0; + +const content = `// AUTO-GENERATED by scripts/bake-analytics.mjs -- do not edit manually +export const ANALYTICS_BAKED = { + enabled: ${enabled}, + posthogApiKey: "${posthogApiKey}", + posthogHost: "${posthogHost}", + sentryDsn: "${sentryDsn}", + sampleRate: ${sampleRate}, +} as const; +`; + +writeFileSync(outPath, content, "utf-8"); +console.log(`bake-analytics: wrote ${outPath} (mode=${mode}, enabled=${enabled})`); diff --git a/tests/e2e-analytics/auth.setup.ts b/tests/e2e-analytics/auth.setup.ts index b178d940..9bad6960 100644 --- a/tests/e2e-analytics/auth.setup.ts +++ b/tests/e2e-analytics/auth.setup.ts @@ -4,7 +4,7 @@ import { expect, test as setup } from "@playwright/test"; const authFile = path.join(process.cwd(), "test-results", ".auth", "analytics-local-user.json"); -setup("authenticate and accept analytics consent", async ({ page }) => { +setup("authenticate for analytics tests", async ({ page }) => { const dir = path.dirname(authFile); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); @@ -13,18 +13,7 @@ setup("authenticate and accept analytics consent", async ({ page }) => { await page.getByLabel("Password").fill("admin"); await page.getByRole("button", { name: /login/i }).click(); - // With ANALYTICS_ENABLED=true, the AuthGuard will redirect to /analytics-consent - // for a fresh user. Accept consent so authenticated tests can proceed. - try { - const acceptBtn = page.getByRole("button", { name: /sure, sounds good/i }); - await acceptBtn.waitFor({ state: "visible", timeout: 10_000 }); - await acceptBtn.click(); - await page.waitForURL("/", { timeout: 30_000 }); - } catch { - // Already on home page (consent previously accepted) - await page.waitForURL("/", { timeout: 15_000 }); - } - + await page.waitForURL("/", { timeout: 30_000 }); await expect(page).toHaveURL("/"); await page.context().storageState({ path: authFile }); }); diff --git a/tests/e2e-analytics/consent-page.spec.ts b/tests/e2e-analytics/consent-page.spec.ts deleted file mode 100644 index 5deb4ab9..00000000 --- a/tests/e2e-analytics/consent-page.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { login } from "./helpers"; - -// These tests use a FRESH browser context (no stored auth) so the user -// hits the consent page naturally after logging in. - -test.describe("Analytics Consent Page", () => { - test.use({ storageState: { cookies: [], origins: [] } }); - test.describe.configure({ mode: "serial" }); - - test("fresh login redirects to /analytics-consent", async ({ page }) => { - // Reset consent state so user is fresh - await login(page); - - // Wait for either consent page or home - await page.waitForURL(/analytics-consent|\//, { timeout: 15_000 }); - - // First login should show consent page - const url = page.url(); - if (url.includes("analytics-consent")) { - await expect(page.getByText(/help improve snapotter/i)).toBeVisible({ timeout: 5_000 }); - } - // If it went to home, consent was already handled in a prior run -- still valid - }); - - test("consent page renders all required elements", async ({ page }) => { - await page.goto("/analytics-consent"); - - // Shield icon container - await expect(page.locator("svg.lucide-shield")).toBeVisible({ timeout: 5_000 }); - - // Title - await expect(page.getByText(/help improve snapotter/i)).toBeVisible(); - - // Description text about anonymous usage data - await expect(page.getByText(/anonymous usage data/i)).toBeVisible(); - - // "You can change this" reassurance text - await expect(page.getByText(/change this anytime/i)).toBeVisible(); - - // Accept button - await expect(page.getByRole("button", { name: /sure, sounds good/i })).toBeVisible(); - - // Decline button - await expect(page.getByRole("button", { name: /not right now/i })).toBeVisible(); - }); - - test("accept button navigates to home and sets analyticsEnabled=true", async ({ page }) => { - await login(page); - await page.waitForURL(/analytics-consent|\//, { timeout: 15_000 }); - - if (page.url().includes("analytics-consent")) { - await page.getByRole("button", { name: /sure, sounds good/i }).click(); - await page.waitForURL("/", { timeout: 30_000 }); - } - - await expect(page).toHaveURL("/"); - - // Verify via session API - const token = await page.evaluate(() => localStorage.getItem("snapotter-token") ?? ""); - const sessionRes = await page.request.get("/api/auth/session", { - headers: { Authorization: `Bearer ${token}` }, - }); - const session = await sessionRes.json(); - expect(session.user.analyticsEnabled).toBe(true); - }); - - test("after accept, navigating around never shows consent page again", async ({ page }) => { - await login(page); - await page.waitForURL(/analytics-consent|\//, { timeout: 15_000 }); - - if (page.url().includes("analytics-consent")) { - await page.getByRole("button", { name: /sure, sounds good/i }).click(); - await page.waitForURL("/", { timeout: 30_000 }); - } - - // Navigate to different pages - for (const path of ["/resize", "/fullscreen", "/automate", "/"]) { - await page.goto(path); - await page.waitForTimeout(500); - expect(page.url()).not.toContain("analytics-consent"); - } - }); -}); - -test.describe("Analytics Consent - Decline (Remind Later)", () => { - test.use({ storageState: { cookies: [], origins: [] } }); - test.describe.configure({ mode: "serial" }); - - test("decline button navigates to home", async ({ page }) => { - // Reset consent to force the prompt: accept first, then we can't re-trigger - // because the DB user already has consent set. Use API to reset. - await login(page); - await page.waitForURL(/analytics-consent|\//, { timeout: 15_000 }); - - if (page.url().includes("analytics-consent")) { - await page.getByRole("button", { name: /not right now/i }).click(); - await page.waitForURL("/", { timeout: 30_000 }); - await expect(page).toHaveURL("/"); - } - }); - - test("after decline, session shows remindAt in the future", async ({ page }) => { - await login(page); - await page.waitForURL(/analytics-consent|\//, { timeout: 15_000 }); - - // If consent page shows, decline - if (page.url().includes("analytics-consent")) { - await page.getByRole("button", { name: /not right now/i }).click(); - await page.waitForURL("/", { timeout: 30_000 }); - } - - const token = await page.evaluate(() => localStorage.getItem("snapotter-token") ?? ""); - const sessionRes = await page.request.get("/api/auth/session", { - headers: { Authorization: `Bearer ${token}` }, - }); - const session = await sessionRes.json(); - - // After decline (remind later), analyticsEnabled is null and remindAt is set - if (session.user.analyticsConsentRemindAt !== null) { - expect(session.user.analyticsConsentRemindAt).toBeGreaterThan(Date.now()); - } - }); -}); diff --git a/tests/e2e-analytics/helpers.ts b/tests/e2e-analytics/helpers.ts index 547e7b3a..7daf2645 100644 --- a/tests/e2e-analytics/helpers.ts +++ b/tests/e2e-analytics/helpers.ts @@ -7,26 +7,6 @@ export async function login(page: Page, username = "admin", password = "admin") await page.getByRole("button", { name: /login/i }).click(); } -export async function getSessionViaApi(page: Page) { - const token = await page.evaluate(() => localStorage.getItem("snapotter-token") ?? ""); - const res = await page.request.get("/api/auth/session", { - headers: { Authorization: `Bearer ${token}` }, - }); - return res.json(); -} - -export async function setConsentViaApi( - page: Page, - data: { enabled?: boolean; remindLater?: boolean }, -) { - const token = await page.evaluate(() => localStorage.getItem("snapotter-token") ?? ""); - const apiBase = process.env.API_URL || "http://localhost:13491"; - await page.request.put(`${apiBase}/api/v1/user/analytics`, { - headers: { Authorization: `Bearer ${token}` }, - data, - }); -} - export const test = base.extend<{ loggedInPage: Page }>({ loggedInPage: async ({ page }, use) => { await page.goto("/"); diff --git a/tests/e2e-analytics/seven-day-reminder.spec.ts b/tests/e2e-analytics/seven-day-reminder.spec.ts deleted file mode 100644 index a495ecd2..00000000 --- a/tests/e2e-analytics/seven-day-reminder.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { login } from "./helpers"; - -// Tests the 7-day reminder lifecycle. Since we can't wait 7 real days, -// we verify the DB state transitions via the API and confirm the UI -// behavior is consistent with shouldShowConsent() logic. - -test.describe("7-day Reminder Lifecycle", () => { - test.use({ storageState: { cookies: [], origins: [] } }); - test.describe.configure({ mode: "serial" }); - - let token: string; - - test("login and obtain auth token", async ({ page }) => { - await login(page); - await page.waitForURL(/analytics-consent|\//, { timeout: 15_000 }); - - // Accept consent if shown so we can use the API - if (page.url().includes("analytics-consent")) { - await page.getByRole("button", { name: /sure, sounds good/i }).click(); - await page.waitForURL("/", { timeout: 30_000 }); - } - - token = await page.evaluate(() => localStorage.getItem("snapotter-token") ?? ""); - expect(token).toBeTruthy(); - }); - - test("remindLater via API sets remindAt 7 days in the future", async ({ page }) => { - await login(page); - await page.waitForURL(/analytics-consent|\//, { timeout: 15_000 }); - if (page.url().includes("analytics-consent")) { - await page.getByRole("button", { name: /sure, sounds good/i }).click(); - await page.waitForURL("/", { timeout: 30_000 }); - } - - token = await page.evaluate(() => localStorage.getItem("snapotter-token") ?? ""); - - const before = Date.now(); - const apiBase = process.env.API_URL || "http://localhost:13491"; - await page.request.put(`${apiBase}/api/v1/user/analytics`, { - headers: { Authorization: `Bearer ${token}` }, - data: { remindLater: true }, - }); - - const sessionRes = await page.request.get(`${apiBase}/api/auth/session`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const session = await sessionRes.json(); - - expect(session.user.analyticsEnabled).toBeNull(); - expect(session.user.analyticsConsentRemindAt).toBeGreaterThanOrEqual(before); - - const sevenDays = 7 * 24 * 60 * 60 * 1000; - expect(session.user.analyticsConsentRemindAt).toBeGreaterThanOrEqual(before + sevenDays - 2000); - expect(session.user.analyticsConsentRemindAt).toBeLessThanOrEqual( - Date.now() + sevenDays + 2000, - ); - }); - - test("after remindLater, user can still accept via API", async ({ page }) => { - await login(page); - await page.waitForURL(/analytics-consent|\//, { timeout: 15_000 }); - if (page.url().includes("analytics-consent")) { - await page.getByRole("button", { name: /sure, sounds good/i }).click(); - await page.waitForURL("/", { timeout: 30_000 }); - } - - token = await page.evaluate(() => localStorage.getItem("snapotter-token") ?? ""); - const apiBase = process.env.API_URL || "http://localhost:13491"; - - // Set remind later - await page.request.put(`${apiBase}/api/v1/user/analytics`, { - headers: { Authorization: `Bearer ${token}` }, - data: { remindLater: true }, - }); - - // Now accept - await page.request.put(`${apiBase}/api/v1/user/analytics`, { - headers: { Authorization: `Bearer ${token}` }, - data: { enabled: true }, - }); - - const sessionRes = await page.request.get(`${apiBase}/api/auth/session`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const session = await sessionRes.json(); - - expect(session.user.analyticsEnabled).toBe(true); - expect(session.user.analyticsConsentRemindAt).toBeNull(); - }); - - test("after remindLater, user can explicitly decline via API", async ({ page }) => { - await login(page); - await page.waitForURL(/analytics-consent|\//, { timeout: 15_000 }); - if (page.url().includes("analytics-consent")) { - await page.getByRole("button", { name: /sure, sounds good/i }).click(); - await page.waitForURL("/", { timeout: 30_000 }); - } - - token = await page.evaluate(() => localStorage.getItem("snapotter-token") ?? ""); - const apiBase = process.env.API_URL || "http://localhost:13491"; - - await page.request.put(`${apiBase}/api/v1/user/analytics`, { - headers: { Authorization: `Bearer ${token}` }, - data: { remindLater: true }, - }); - - await page.request.put(`${apiBase}/api/v1/user/analytics`, { - headers: { Authorization: `Bearer ${token}` }, - data: { enabled: false }, - }); - - const sessionRes = await page.request.get(`${apiBase}/api/auth/session`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const session = await sessionRes.json(); - - expect(session.user.analyticsEnabled).toBe(false); - expect(session.user.analyticsConsentRemindAt).toBeNull(); - }); -}); diff --git a/tests/e2e-docker/analytics-api.spec.ts b/tests/e2e-docker/analytics-api.spec.ts index 35344497..1399d7cd 100644 --- a/tests/e2e-docker/analytics-api.spec.ts +++ b/tests/e2e-docker/analytics-api.spec.ts @@ -1,23 +1,11 @@ import { expect, test } from "@playwright/test"; -// ─── Analytics API Endpoints ──────────────────────────────────────── -// Tests for the analytics config and user consent API endpoints. +// Tests for the analytics config endpoint. // These run against the Docker container at localhost:1349. const BASE_URL = "http://localhost:1349"; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -/** Login and return a Bearer token for authenticated requests. */ -async function getAuthToken(): Promise { - const res = await fetch(`${BASE_URL}/api/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username: "admin", password: "admin" }), - }); - const data = await res.json(); - return data.token; -} - test.describe("GET /api/v1/config/analytics (public)", () => { test("returns 200 without auth token", async () => { const res = await fetch(`${BASE_URL}/api/v1/config/analytics`); @@ -65,127 +53,3 @@ test.describe("GET /api/v1/config/analytics (public)", () => { expect(config1.instanceId).toBe(config2.instanceId); }); }); - -test.describe("PUT /api/v1/user/analytics (auth required)", () => { - test("returns 401 without auth token", async () => { - const res = await fetch(`${BASE_URL}/api/v1/user/analytics`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled: true }), - }); - expect(res.status).toBe(401); - }); - - test("accepts consent with enabled: true", async () => { - const token = await getAuthToken(); - - const res = await fetch(`${BASE_URL}/api/v1/user/analytics`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ enabled: true }), - }); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(body).toEqual({ ok: true, analyticsEnabled: true }); - }); - - test("declines consent with enabled: false", async () => { - const token = await getAuthToken(); - - const res = await fetch(`${BASE_URL}/api/v1/user/analytics`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ enabled: false }), - }); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(body).toEqual({ ok: true, analyticsEnabled: false }); - }); - - test("remind later sets analyticsEnabled to null", async () => { - const token = await getAuthToken(); - - const res = await fetch(`${BASE_URL}/api/v1/user/analytics`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ remindLater: true }), - }); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(body).toEqual({ ok: true, analyticsEnabled: null }); - }); -}); - -test.describe("GET /api/auth/session includes analytics fields", () => { - test("session response contains analytics consent fields", async () => { - const token = await getAuthToken(); - - // First set a consent preference so the fields are populated - await fetch(`${BASE_URL}/api/v1/user/analytics`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ enabled: true }), - }); - - // Fetch session - const sessionRes = await fetch(`${BASE_URL}/api/auth/session`, { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(sessionRes.status).toBe(200); - - const session = await sessionRes.json(); - - // The user object should include analytics fields - expect(session.user).toHaveProperty("analyticsEnabled"); - expect(session.user).toHaveProperty("analyticsConsentShownAt"); - expect(session.user).toHaveProperty("analyticsConsentRemindAt"); - - // After accepting, analyticsEnabled should be true - expect(session.user.analyticsEnabled).toBe(true); - // analyticsConsentShownAt should be a timestamp (number) - expect(typeof session.user.analyticsConsentShownAt).toBe("number"); - // analyticsConsentRemindAt should be null after explicit accept - expect(session.user.analyticsConsentRemindAt).toBeNull(); - }); - - test("session reflects remind-later state", async () => { - const token = await getAuthToken(); - - // Set remind later - await fetch(`${BASE_URL}/api/v1/user/analytics`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ remindLater: true }), - }); - - // Fetch session - const sessionRes = await fetch(`${BASE_URL}/api/auth/session`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const session = await sessionRes.json(); - - expect(session.user.analyticsEnabled).toBeNull(); - expect(typeof session.user.analyticsConsentShownAt).toBe("number"); - // analyticsConsentRemindAt should be a future timestamp - expect(typeof session.user.analyticsConsentRemindAt).toBe("number"); - expect(session.user.analyticsConsentRemindAt).toBeGreaterThan(Date.now()); - }); -}); diff --git a/tests/e2e-docker/analytics-consent.spec.ts b/tests/e2e-docker/analytics-consent.spec.ts deleted file mode 100644 index fc8a15d2..00000000 --- a/tests/e2e-docker/analytics-consent.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { expect, test } from "@playwright/test"; - -// ─── Analytics Consent Page Flow ──────────────────────────────────── -// These tests run against a Docker container at localhost:1349. -// The container must be started with SKIP_MUST_CHANGE_PASSWORD=true. -// -// IMPORTANT: There is only one admin user in the DB. Once consent is -// given/declined, the user's state changes. Tests run serially and -// each builds on the state left by the previous test. - -async function loginAndGetToHome(page: import("@playwright/test").Page) { - await page.goto("/login"); - await page.getByLabel("Username").fill("admin"); - await page.getByLabel("Password").fill("admin"); - await page.getByRole("button", { name: /login/i }).click(); - // May hit consent page or go straight to home - try { - const acceptBtn = page.getByRole("button", { name: /sure, sounds good/i }); - await acceptBtn.waitFor({ state: "visible", timeout: 5_000 }); - await acceptBtn.click(); - await page.waitForURL("/", { timeout: 30_000 }); - } catch { - await page.waitForURL("/", { timeout: 30_000 }); - } -} - -test.describe("Analytics consent page", () => { - test.use({ storageState: { cookies: [], origins: [] } }); - test.describe.configure({ mode: "serial" }); - - test("consent already accepted by auth setup — home loads without consent redirect", async ({ - page, - }) => { - // The auth setup project already accepted analytics consent for the admin - // user, so a fresh browser session logging in as admin should go straight - // to the home page without being redirected to /analytics-consent. - await loginAndGetToHome(page); - await expect(page).toHaveURL("/"); - - // Navigate away and back — consent page should NOT reappear - await page.goto("/resize"); - await page.waitForTimeout(1_000); - await page.goto("/"); - await page.waitForTimeout(1_000); - await expect(page).not.toHaveURL(/analytics-consent/); - - // Verify session has analyticsEnabled=true via API (using the in-browser token) - const sessionData = await page.evaluate(async () => { - const token = localStorage.getItem("snapotter-token") ?? ""; - const res = await fetch("/api/auth/session", { - headers: { Authorization: `Bearer ${token}` }, - }); - return res.json(); - }); - expect(sessionData.user?.analyticsEnabled ?? true).toBe(true); - }); - - test("settings toggle works after accepting analytics", async ({ page }) => { - // User already accepted in previous test — login should go straight to home - await loginAndGetToHome(page); - await expect(page).toHaveURL("/"); - - // Open Settings dialog — look for the gear icon or settings button - const settingsButton = page - .locator("[data-testid='settings-button']") - .or(page.locator("button").filter({ has: page.locator("svg.lucide-settings") })); - await expect(settingsButton.first()).toBeVisible({ timeout: 10_000 }); - await settingsButton.first().click(); - - // Navigate to Product Analytics section in the settings nav - const analyticsNav = page.getByText("Product Analytics"); - await expect(analyticsNav).toBeVisible({ timeout: 5_000 }); - await analyticsNav.click(); - - // Verify toggle shows enabled state - await expect(page.getByText("Analytics enabled")).toBeVisible({ timeout: 5_000 }); - - // Click the toggle button to disable - const toggleButton = page.locator("button.rounded-full"); - await toggleButton.click(); - await expect(page.getByText("Analytics disabled")).toBeVisible({ timeout: 5_000 }); - - // Toggle back on - await toggleButton.click(); - await expect(page.getByText("Analytics enabled")).toBeVisible({ timeout: 5_000 }); - }); -}); diff --git a/tests/e2e-docker/analytics-disabled.spec.ts b/tests/e2e-docker/analytics-disabled.spec.ts index a5c4dee0..81086526 100644 --- a/tests/e2e-docker/analytics-disabled.spec.ts +++ b/tests/e2e-docker/analytics-disabled.spec.ts @@ -1,12 +1,9 @@ import { expect, test } from "@playwright/test"; -// ─── Analytics Disabled (ANALYTICS_ENABLED=false) ─────────────────── -// These tests verify behavior when the server has ANALYTICS_ENABLED=false. -// They need a Docker container started with ANALYTICS_ENABLED=false. -// -// If the container has analytics enabled, these tests will be skipped -// automatically by checking the config endpoint first. +// Tests for behavior when analytics is disabled at build time. +// Skip automatically if the container was built with analytics enabled. +// biome-ignore lint/suspicious/noUndeclaredEnvVars: e2e test env var const BASE_URL = process.env.API_URL ?? "http://localhost:1349"; async function loginFresh(page: import("@playwright/test").Page) { @@ -16,11 +13,10 @@ async function loginFresh(page: import("@playwright/test").Page) { await page.getByRole("button", { name: /login/i }).click(); } -test.describe("Analytics disabled by server", () => { +test.describe("Analytics disabled by build-time config", () => { test.use({ storageState: { cookies: [], origins: [] } }); test.beforeEach(async ({ request: _request }, testInfo) => { - // Skip this suite if the container has analytics enabled const res = await fetch(`${BASE_URL}/api/v1/config/analytics`); const config = await res.json(); if (config.enabled) { @@ -43,10 +39,8 @@ test.describe("Analytics disabled by server", () => { }); }); - test("no consent screen when analytics disabled — goes directly to home", async ({ page }) => { + test("login goes directly to home (no consent screen)", async ({ page }) => { await loginFresh(page); - - // Should go directly to home, NOT to /analytics-consent await page.waitForURL("/", { timeout: 30_000 }); await expect(page).toHaveURL("/"); }); @@ -54,17 +48,13 @@ test.describe("Analytics disabled by server", () => { test("no outbound network requests to PostHog or Sentry", async ({ page }) => { const analyticsRequests: string[] = []; - // Intercept ALL network requests and log any that hit analytics domains await page.route("**/*", (route) => { const url = route.request().url(); - // Match on the URL host, not a substring, so an unrelated host that merely - // contains "posthog"/"sentry" can't false-trigger (CodeQL - // js/incomplete-url-substring-sanitization). let host = ""; try { host = new URL(url).hostname.toLowerCase(); } catch { - // non-URL scheme (data:/blob:) -- not an analytics host + // non-URL scheme } if ( host === "posthog.com" || @@ -77,11 +67,9 @@ test.describe("Analytics disabled by server", () => { return route.continue(); }); - // Login await loginFresh(page); await page.waitForURL("/", { timeout: 30_000 }); - // Navigate around await page.goto("/resize"); await page.waitForTimeout(2_000); await page.goto("/compress"); @@ -89,7 +77,6 @@ test.describe("Analytics disabled by server", () => { await page.goto("/"); await page.waitForTimeout(2_000); - // Assert ZERO analytics requests expect(analyticsRequests).toEqual([]); }); }); diff --git a/tests/e2e-docker/analytics-no-data-leak.spec.ts b/tests/e2e-docker/analytics-no-data-leak.spec.ts index 593ca9d6..23c6d476 100644 --- a/tests/e2e-docker/analytics-no-data-leak.spec.ts +++ b/tests/e2e-docker/analytics-no-data-leak.spec.ts @@ -1,22 +1,13 @@ -import fs from "node:fs"; import path from "node:path"; import { expect, test } from "@playwright/test"; -// ─── Analytics Privacy / No Data Leak ─────────────────────────────── -// CRITICAL privacy tests: verify that disabling analytics means -// absolutely zero data is sent to PostHog, Sentry, or any external -// analytics domain. Also verifies that tool functionality is not -// degraded when analytics are disabled. -// -// NOTE: The auth setup project already accepted analytics consent for -// the admin user, so the consent page won't appear on login. Instead, -// these tests login, then explicitly disable analytics via the API, -// then reload the page so the store picks up the new state. +// Verifies that when analytics is disabled at build time, zero data +// is sent to PostHog or Sentry, and tool functionality is unaffected. -const SAMPLES_DIR = path.join(process.env.HOME ?? "/Users/sidd", "Downloads", "sample"); const FIXTURES_DIR = path.join(process.cwd(), "tests", "fixtures"); +// biome-ignore lint/suspicious/noUndeclaredEnvVars: e2e test env var +const BASE_URL = process.env.API_URL ?? "http://localhost:1349"; -/** Analytics-related domains to watch for in network traffic. */ const ANALYTICS_DOMAINS = [ "posthog.com", "us.i.posthog.com", @@ -35,60 +26,7 @@ async function loginFresh(page: import("@playwright/test").Page) { await page.getByLabel("Username").fill("admin"); await page.getByLabel("Password").fill("admin"); await page.getByRole("button", { name: /login/i }).click(); - // May land on "/" or "/analytics-consent" depending on user state - try { - const acceptBtn = page.getByRole("button", { name: /sure, sounds good/i }); - await acceptBtn.waitFor({ state: "visible", timeout: 5_000 }); - await acceptBtn.click(); - await page.waitForURL("/", { timeout: 30_000 }); - } catch { - await page.waitForURL("/", { timeout: 30_000 }); - } -} - -/** - * Disable analytics for the admin user via the browser's existing auth token, - * then reload so the frontend store picks up analyticsEnabled=false. - * This avoids an extra /api/auth/login call (which counts toward rate limits). - */ -async function disableAnalytics(page: import("@playwright/test").Page): Promise { - const ok = await page.evaluate(async () => { - const token = localStorage.getItem("snapotter-token") ?? ""; - const res = await fetch("/api/v1/user/analytics", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ enabled: false }), - }); - return res.ok; - }); - expect(ok, "Failed to disable analytics via in-browser API call").toBe(true); - await page.reload(); - await page.waitForLoadState("networkidle"); -} - -/** - * Re-enable analytics for the admin user via the browser's existing auth token. - * Called in afterEach so subsequent tests/runs start with analytics enabled. - */ -async function enableAnalytics(page: import("@playwright/test").Page): Promise { - try { - await page.evaluate(async () => { - const token = localStorage.getItem("snapotter-token") ?? ""; - await fetch("/api/v1/user/analytics", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ enabled: true }), - }); - }); - } catch { - // Best-effort cleanup — page may already be closed - } + await page.waitForURL("/", { timeout: 30_000 }); } function getFixture(name: string): string { @@ -117,30 +55,26 @@ async function waitForProcessingDone( await spinner.waitFor({ state: "hidden", timeout: timeoutMs }); } } catch { - // No spinner — processing may have been instant + // No spinner } await page.waitForTimeout(500); } -test.describe("No data leak after disabling analytics", () => { - // Use fresh browser context — no saved auth state +test.describe("No data leak when analytics disabled", () => { test.use({ storageState: { cookies: [], origins: [] } }); - // Re-enable analytics after each test so subsequent tests/runs start clean - test.afterEach(async ({ page }) => { - await enableAnalytics(page); + test.beforeEach(async ({ request: _request }, testInfo) => { + // Skip if this build has analytics enabled + const res = await fetch(`${BASE_URL}/api/v1/config/analytics`); + const config = await res.json(); + if (config.enabled) { + testInfo.skip(); + } }); - test("zero PostHog/Sentry traffic after explicitly disabling analytics", async ({ page }) => { + test("zero PostHog/Sentry traffic when analytics disabled", async ({ page }) => { const analyticsRequests: string[] = []; - // Login first (consent was already accepted by auth setup) - await loginFresh(page); - - // Disable analytics via API and reload - await disableAnalytics(page); - - // Set up network interception AFTER disabling analytics await page.route("**/*", (route) => { const url = route.request().url(); if (isAnalyticsRequest(url)) { @@ -149,7 +83,8 @@ test.describe("No data leak after disabling analytics", () => { return route.continue(); }); - // Navigate to several pages + await loginFresh(page); + await page.goto("/resize"); await page.waitForTimeout(2_000); await page.goto("/compress"); @@ -159,115 +94,32 @@ test.describe("No data leak after disabling analytics", () => { await page.goto("/"); await page.waitForTimeout(2_000); - // Assert ZERO analytics requests were made expect( analyticsRequests, `Expected zero analytics requests, but found: ${analyticsRequests.join(", ")}`, ).toEqual([]); }); - test("zero PostHog/Sentry traffic after toggling analytics off", async ({ page }) => { - const analyticsRequests: string[] = []; - - // Login (consent was already accepted by auth setup) + test("tool processing works when analytics disabled", async ({ page }) => { await loginFresh(page); - // Disable analytics via API and reload - await disableAnalytics(page); - - // Set up network interception - await page.route("**/*", (route) => { - const url = route.request().url(); - if (isAnalyticsRequest(url)) { - analyticsRequests.push(url); - } - return route.continue(); - }); - - // Browse around - await page.goto("/crop"); - await page.waitForTimeout(2_000); - await page.goto("/convert"); - await page.waitForTimeout(2_000); - await page.goto("/"); - await page.waitForTimeout(2_000); - - expect( - analyticsRequests, - `Expected zero analytics requests, but found: ${analyticsRequests.join(", ")}`, - ).toEqual([]); - }); - - test("tool processing works normally after disabling analytics", async ({ page }) => { - // Login and disable analytics - await loginFresh(page); - await disableAnalytics(page); - - // Navigate to resize tool await page.goto("/resize"); await page.waitForTimeout(2_000); - // Upload a test image const testImage = getFixture("test-200x150.png"); await uploadFiles(page, [testImage]); - // Set resize parameters const widthInput = page.getByLabel("Width (px)"); await widthInput.fill("100"); - // Process the image const processBtn = page.getByTestId("resize-submit"); await expect(processBtn).toBeEnabled({ timeout: 15_000 }); await processBtn.click(); await waitForProcessingDone(page); - // Verify no errors const error = page.locator(".text-red-500"); expect(await error.isVisible({ timeout: 2_000 }).catch(() => false)).toBe(false); - // Verify a download link or result appeared - const downloadLink = page.locator( - "a[download], a[href*='download'], button:has-text('Download')", - ); - await expect(downloadLink.first()).toBeVisible({ timeout: 15_000 }); - }); - - test("tool processing works with sample portrait after disabling analytics", async ({ page }) => { - const portraitPath = path.join( - SAMPLES_DIR, - "portrait-of-a-smiling-man-with-glasses-and-a-beard-isolated.png", - ); - if (!fs.existsSync(portraitPath)) { - test.skip(); - return; - } - - // Login and disable analytics - await loginFresh(page); - await disableAnalytics(page); - - // Navigate to resize tool - await page.goto("/resize"); - await page.waitForTimeout(2_000); - - // Upload sample portrait - await uploadFiles(page, [portraitPath]); - - // Set resize width - const widthInput = page.getByLabel("Width (px)"); - await widthInput.fill("200"); - - // Process - const processBtn = page.getByTestId("resize-submit"); - await expect(processBtn).toBeEnabled({ timeout: 15_000 }); - await processBtn.click(); - await waitForProcessingDone(page); - - // Verify no errors - const error = page.locator(".text-red-500"); - expect(await error.isVisible({ timeout: 2_000 }).catch(() => false)).toBe(false); - - // Verify download appeared — proves functionality is not degraded const downloadLink = page.locator( "a[download], a[href*='download'], button:has-text('Download')", ); diff --git a/tests/integration/platform/analytics.test.ts b/tests/integration/platform/analytics.test.ts index 9f516d7b..c1b0477d 100644 --- a/tests/integration/platform/analytics.test.ts +++ b/tests/integration/platform/analytics.test.ts @@ -1,12 +1,10 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js"; +import { buildTestApp, type TestApp } from "../test-server.js"; let testApp: TestApp; -let token: string; beforeAll(async () => { testApp = await buildTestApp(); - token = await loginAsAdmin(testApp.app); }); afterAll(async () => { @@ -51,267 +49,26 @@ describe("GET /api/v1/config/analytics", () => { const c2 = JSON.parse(res2.body); expect(c1.instanceId).toBe(c2.instanceId); }); -}); -describe("PUT /api/v1/user/analytics", () => { - it("returns 401 without auth token", async () => { - const res = await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - payload: { enabled: true }, - }); - expect(res.statusCode).toBe(401); - }); - - it("accepts consent with enabled: true", async () => { - const res = await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { enabled: true }, - }); - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body).toEqual({ ok: true, analyticsEnabled: true }); - }); - - it("declines consent with enabled: false", async () => { - const res = await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { enabled: false }, - }); - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body).toEqual({ ok: true, analyticsEnabled: false }); - }); - - it("remindLater sets analyticsEnabled to null", async () => { - const res = await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { remindLater: true }, - }); - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body).toEqual({ ok: true, analyticsEnabled: null }); - }); - - it("rejects non-boolean enabled value", async () => { - const res = await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { enabled: "yes" }, - }); - expect(res.statusCode).toBe(400); - }); - - it("rejects non-boolean remindLater value", async () => { - const res = await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { remindLater: 1 }, - }); - expect(res.statusCode).toBe(400); - }); - - it("accepts empty body without error", async () => { - const res = await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: {}, - }); - expect(res.statusCode).toBe(200); + it("config values come from build-time bake (dev defaults to disabled)", async () => { + const res = await testApp.app.inject({ method: "GET", url: "/api/v1/config/analytics" }); + const config = JSON.parse(res.body); + // In dev/test the committed baked.ts has enabled: false + expect(config.enabled).toBe(false); + expect(config.posthogApiKey).toBe(""); + expect(config.posthogHost).toBe(""); + expect(config.sentryDsn).toBe(""); + expect(config.sampleRate).toBe(0); }); }); -describe("Session includes analytics fields", () => { - it("after accept, session shows analyticsEnabled=true", async () => { - await testApp.app.inject({ +describe("PUT /api/v1/user/analytics (removed)", () => { + it("returns 404 (endpoint no longer exists)", async () => { + const res = await testApp.app.inject({ method: "PUT", url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, payload: { enabled: true }, }); - - const sessionRes = await testApp.app.inject({ - method: "GET", - url: "/api/auth/session", - headers: { authorization: `Bearer ${token}` }, - }); - const session = JSON.parse(sessionRes.body); - expect(session.user.analyticsEnabled).toBe(true); - expect(typeof session.user.analyticsConsentShownAt).toBe("number"); - expect(session.user.analyticsConsentRemindAt).toBeNull(); - }); - - it("after decline, session shows analyticsEnabled=false", async () => { - await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { enabled: false }, - }); - - const sessionRes = await testApp.app.inject({ - method: "GET", - url: "/api/auth/session", - headers: { authorization: `Bearer ${token}` }, - }); - const session = JSON.parse(sessionRes.body); - expect(session.user.analyticsEnabled).toBe(false); - expect(session.user.analyticsConsentRemindAt).toBeNull(); - }); - - it("after remindLater, session shows null + future remindAt", async () => { - const before = Date.now(); - await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { remindLater: true }, - }); - - const sessionRes = await testApp.app.inject({ - method: "GET", - url: "/api/auth/session", - headers: { authorization: `Bearer ${token}` }, - }); - const session = JSON.parse(sessionRes.body); - expect(session.user.analyticsEnabled).toBeNull(); - expect(typeof session.user.analyticsConsentShownAt).toBe("number"); - expect(typeof session.user.analyticsConsentRemindAt).toBe("number"); - - const sevenDays = 7 * 24 * 60 * 60 * 1000; - expect(session.user.analyticsConsentRemindAt).toBeGreaterThanOrEqual(before + sevenDays - 1000); - expect(session.user.analyticsConsentRemindAt).toBeLessThanOrEqual( - Date.now() + sevenDays + 1000, - ); + expect(res.statusCode).toBe(404); }); }); - -describe("7-day reminder lifecycle", () => { - it("fresh -> remindLater -> accept clears remindAt", async () => { - // Step 1: Set remind later - await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { remindLater: true }, - }); - - let session = await getSession(); - expect(session.user.analyticsEnabled).toBeNull(); - expect(session.user.analyticsConsentRemindAt).toBeTypeOf("number"); - expect(session.user.analyticsConsentRemindAt).toBeGreaterThan(Date.now()); - - // Step 2: Accept consent (user comes back and says yes) - await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { enabled: true }, - }); - - session = await getSession(); - expect(session.user.analyticsEnabled).toBe(true); - expect(session.user.analyticsConsentRemindAt).toBeNull(); - }); - - it("fresh -> remindLater -> decline clears remindAt", async () => { - await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { remindLater: true }, - }); - - let session = await getSession(); - expect(session.user.analyticsEnabled).toBeNull(); - expect(session.user.analyticsConsentRemindAt).not.toBeNull(); - - await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { enabled: false }, - }); - - session = await getSession(); - expect(session.user.analyticsEnabled).toBe(false); - expect(session.user.analyticsConsentRemindAt).toBeNull(); - }); - - it("accept -> toggle off -> toggle on preserves consent history", async () => { - await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { enabled: true }, - }); - - let session = await getSession(); - expect(session.user.analyticsEnabled).toBe(true); - const firstShownAt = session.user.analyticsConsentShownAt; - - await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { enabled: false }, - }); - - session = await getSession(); - expect(session.user.analyticsEnabled).toBe(false); - - await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { enabled: true }, - }); - - session = await getSession(); - expect(session.user.analyticsEnabled).toBe(true); - expect(session.user.analyticsConsentShownAt).toBeGreaterThanOrEqual(firstShownAt); - }); - - it("multiple remindLater calls update the remindAt timestamp", async () => { - await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { remindLater: true }, - }); - - const session1 = await getSession(); - const firstRemindAt = session1.user.analyticsConsentRemindAt; - - // Small delay to ensure timestamps differ - await new Promise((r) => setTimeout(r, 50)); - - await testApp.app.inject({ - method: "PUT", - url: "/api/v1/user/analytics", - headers: { authorization: `Bearer ${token}` }, - payload: { remindLater: true }, - }); - - const session2 = await getSession(); - expect(session2.user.analyticsConsentRemindAt).toBeGreaterThanOrEqual(firstRemindAt); - }); -}); - -async function getSession() { - const res = await testApp.app.inject({ - method: "GET", - url: "/api/auth/session", - headers: { authorization: `Bearer ${token}` }, - }); - return JSON.parse(res.body); -} diff --git a/tests/unit/api/analytics-env.test.ts b/tests/unit/api/analytics-env.test.ts deleted file mode 100644 index 1b74e7ef..00000000 --- a/tests/unit/api/analytics-env.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { afterAll, beforeEach, describe, expect, it } from "vitest"; - -describe("analytics env var validation", () => { - const originalEnv = { ...process.env }; - - // Keys that the Zod schema cares about -- we clear them before each test - // so defaults kick in unless explicitly set. - const analyticsKeys = [ - "ANALYTICS_ENABLED", - "ANALYTICS_SAMPLE_RATE", - "POSTHOG_API_KEY", - "POSTHOG_HOST", - "SENTRY_DSN", - ]; - - beforeEach(() => { - for (const key of analyticsKeys) { - delete process.env[key]; - } - }); - - afterAll(() => { - for (const key of Object.keys(process.env)) { - if (!(key in originalEnv)) delete process.env[key]; - } - Object.assign(process.env, originalEnv); - }); - - // ── ANALYTICS_ENABLED ─────────────────────────────────────────────────── - - it("ANALYTICS_ENABLED defaults to false (no-phone-home)", async () => { - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - const env = loadEnv(); - expect(env.ANALYTICS_ENABLED).toBe(false); - }); - - it("ANALYTICS_ENABLED='false' transforms to boolean false", async () => { - process.env.ANALYTICS_ENABLED = "false"; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(loadEnv().ANALYTICS_ENABLED).toBe(false); - }); - - it("ANALYTICS_ENABLED='true' transforms to boolean true", async () => { - process.env.ANALYTICS_ENABLED = "true"; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(loadEnv().ANALYTICS_ENABLED).toBe(true); - }); - - it("ANALYTICS_ENABLED rejects non-enum values", async () => { - process.env.ANALYTICS_ENABLED = "yes"; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(() => loadEnv()).toThrow(); - }); - - // ── ANALYTICS_SAMPLE_RATE ────────────────────────────────────────────── - - it("ANALYTICS_SAMPLE_RATE defaults to 1.0", async () => { - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - const env = loadEnv(); - expect(env.ANALYTICS_SAMPLE_RATE).toBe(1.0); - }); - - it("ANALYTICS_SAMPLE_RATE=0.5 parses correctly", async () => { - process.env.ANALYTICS_SAMPLE_RATE = "0.5"; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(loadEnv().ANALYTICS_SAMPLE_RATE).toBe(0.5); - }); - - it("ANALYTICS_SAMPLE_RATE=0 is valid (no sampling)", async () => { - process.env.ANALYTICS_SAMPLE_RATE = "0"; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(loadEnv().ANALYTICS_SAMPLE_RATE).toBe(0); - }); - - it("ANALYTICS_SAMPLE_RATE=1 is valid (full sampling)", async () => { - process.env.ANALYTICS_SAMPLE_RATE = "1"; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(loadEnv().ANALYTICS_SAMPLE_RATE).toBe(1); - }); - - it("ANALYTICS_SAMPLE_RATE > 1 fails validation", async () => { - process.env.ANALYTICS_SAMPLE_RATE = "1.5"; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(() => loadEnv()).toThrow(); - }); - - it("ANALYTICS_SAMPLE_RATE < 0 fails validation", async () => { - process.env.ANALYTICS_SAMPLE_RATE = "-0.1"; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(() => loadEnv()).toThrow(); - }); - - it("ANALYTICS_SAMPLE_RATE=2 fails validation", async () => { - process.env.ANALYTICS_SAMPLE_RATE = "2"; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(() => loadEnv()).toThrow(); - }); - - // ── POSTHOG_API_KEY ──────────────────────────────────────────────────── - - it("POSTHOG_API_KEY defaults to empty string (no baked key)", async () => { - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - const env = loadEnv(); - expect(env.POSTHOG_API_KEY).toBe(""); - }); - - it("POSTHOG_API_KEY can be overridden with a custom value", async () => { - process.env.POSTHOG_API_KEY = "phc_custom_key_123"; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(loadEnv().POSTHOG_API_KEY).toBe("phc_custom_key_123"); - }); - - it("POSTHOG_API_KEY can be set to empty string", async () => { - process.env.POSTHOG_API_KEY = ""; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(loadEnv().POSTHOG_API_KEY).toBe(""); - }); - - // ── POSTHOG_HOST ─────────────────────────────────────────────────────── - - it("POSTHOG_HOST defaults to the PostHog US endpoint", async () => { - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - const env = loadEnv(); - expect(env.POSTHOG_HOST).toBe("https://us.i.posthog.com"); - }); - - it("POSTHOG_HOST can be overridden", async () => { - process.env.POSTHOG_HOST = "https://eu.posthog.com"; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(loadEnv().POSTHOG_HOST).toBe("https://eu.posthog.com"); - }); - - // ── SENTRY_DSN ───────────────────────────────────────────────────────── - - it("SENTRY_DSN defaults to empty string (no baked DSN)", async () => { - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - const env = loadEnv(); - expect(env.SENTRY_DSN).toBe(""); - }); - - it("SENTRY_DSN can be overridden with a custom value", async () => { - process.env.SENTRY_DSN = "https://custom@sentry.io/999"; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(loadEnv().SENTRY_DSN).toBe("https://custom@sentry.io/999"); - }); - - it("SENTRY_DSN can be set to empty string to disable", async () => { - process.env.SENTRY_DSN = ""; - const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); - expect(loadEnv().SENTRY_DSN).toBe(""); - }); -}); diff --git a/tests/unit/api/analytics-no-leak.test.ts b/tests/unit/api/analytics-no-leak.test.ts index 425ea0a8..081305f4 100644 --- a/tests/unit/api/analytics-no-leak.test.ts +++ b/tests/unit/api/analytics-no-leak.test.ts @@ -1,86 +1,49 @@ // Proves the server-side invariant: captureException and trackEvent -// never send data to Sentry/PostHog when analytics is disabled or -// the request user has not opted in. +// never send data to Sentry/PostHog when analytics is disabled (baked). // -// Tests the FIXED captureException that accepts an optional request -// parameter and checks consent before forwarding to Sentry. -// Also tests the FIXED PII scrubbing regex that now correctly -// matches .heic and .heif extensions. +// Also tests the PII scrubbing regex that correctly matches .heic and .heif. import { describe, expect, it } from "vitest"; -// The corrected regex from both apps/api and apps/web analytics modules. -// The fix changed `he[ic]f?` to `hei[cf]?` so .heic and .heif are matched. const FILE_EXT_PATTERN = /\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi; const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai)\//g; describe("Server-side Analytics No-Leak Invariant", () => { - describe("captureException consent gating (code review)", () => { - it("captureException checks isRequestOptedIn before sending to Sentry", async () => { - // Read the actual source to verify the consent check exists. - // The function signature is: captureException(error, request?) - // When request is provided, it checks isRequestOptedIn(request) - // and returns early if the user has not opted in. + describe("captureException gating (code review)", () => { + it("captureException takes only error, no request parameter", async () => { const fs = await import("node:fs"); const source = fs.readFileSync("apps/api/src/lib/analytics.ts", "utf8"); - expect(source).toContain( - "export async function captureException(error: unknown, request?: FastifyRequest)", + "export async function captureException(error: unknown): Promise", ); - expect(source).toContain("if (request && !(await isRequestOptedIn(request))) return;"); }); - it("error handler passes request to captureException", async () => { + it("captureException checks ANALYTICS_BAKED.enabled before sending", async () => { const fs = await import("node:fs"); - const source = fs.readFileSync("apps/api/src/index.ts", "utf8"); - expect(source).toContain("captureException(error, request)"); - expect(source).not.toMatch(/captureException\(error\)[^,]/); + const source = fs.readFileSync("apps/api/src/lib/analytics.ts", "utf8"); + expect(source).toContain("if (!ANALYTICS_BAKED.enabled) return;"); }); }); - describe("isUserOptedIn logic (code review)", () => { - it("returns false for anonymous user", async () => { + describe("initAnalytics gating (code review)", () => { + it("initAnalytics bails when ANALYTICS_BAKED.enabled is false", async () => { const fs = await import("node:fs"); const source = fs.readFileSync("apps/api/src/lib/analytics.ts", "utf8"); - expect(source).toContain('if (userId === "anonymous") return false;'); + expect(source).toContain("if (!ANALYTICS_BAKED.enabled) return;"); }); - it("checks ANALYTICS_ENABLED before user DB lookup", async () => { + it("shutdownAnalytics nulls posthogClient", async () => { const fs = await import("node:fs"); const source = fs.readFileSync("apps/api/src/lib/analytics.ts", "utf8"); - expect(source).toContain("if (!env.ANALYTICS_ENABLED) return false;"); - }); - }); - - describe("shouldSample logic", () => { - it("rate 0.0 always rejects (Math.random() < 0.0 is always false)", () => { - for (let i = 0; i < 100; i++) { - expect(Math.random() < 0.0).toBe(false); - } - }); - - it("rate 1.0 always accepts (checked before Math.random call)", () => { - const rate = 1.0; - expect(rate >= 1.0).toBe(true); - }); - - it("rate between 0 and 1 produces a mix", () => { - let trueCount = 0; - for (let i = 0; i < 1000; i++) { - if (Math.random() < 0.5) trueCount++; - } - expect(trueCount).toBeGreaterThan(0); - expect(trueCount).toBeLessThan(1000); + expect(source).toContain("posthogClient = null;"); }); }); describe("trackEvent gating (code review)", () => { - it("trackEvent checks posthogClient, consent, and sampling", async () => { + it("trackEvent checks ANALYTICS_BAKED.enabled and posthogClient", async () => { const fs = await import("node:fs"); const source = fs.readFileSync("apps/api/src/lib/analytics.ts", "utf8"); - expect(source).toContain( - "if (!posthogClient || !(await isRequestOptedIn(request)) || !shouldSample()) return;", - ); + expect(source).toContain("if (!ANALYTICS_BAKED.enabled || !posthogClient) return;"); }); it("trackEvent wraps capture in try-catch (never throws)", async () => { @@ -90,6 +53,15 @@ describe("Server-side Analytics No-Leak Invariant", () => { expect(trackEventBlock).toContain("try {"); expect(trackEventBlock).toContain("catch {"); }); + + it("trackEvent signature: (event, properties, distinctId?)", async () => { + const fs = await import("node:fs"); + const source = fs.readFileSync("apps/api/src/lib/analytics.ts", "utf8"); + expect(source).toContain("export async function trackEvent("); + expect(source).toContain("event: string,"); + expect(source).toContain("properties: Record,"); + expect(source).toContain("distinctId?: string,"); + }); }); describe("PII scrubbing regex - FILE_EXT_PATTERN", () => { @@ -171,19 +143,4 @@ describe("Server-side Analytics No-Leak Invariant", () => { expect(result).toContain("/[REDACTED]/"); }); }); - - describe("initAnalytics gating (code review)", () => { - it("initAnalytics bails when ANALYTICS_ENABLED is false", async () => { - const fs = await import("node:fs"); - const source = fs.readFileSync("apps/api/src/lib/analytics.ts", "utf8"); - expect(source).toContain("if (!env.ANALYTICS_ENABLED || !env.POSTHOG_API_KEY) return;"); - }); - - it("shutdownAnalytics nulls both clients", async () => { - const fs = await import("node:fs"); - const source = fs.readFileSync("apps/api/src/lib/analytics.ts", "utf8"); - expect(source).toContain("posthogClient = null;"); - expect(source).toContain("sentryModule = null;"); - }); - }); }); diff --git a/tests/unit/api/analytics-route.test.ts b/tests/unit/api/analytics-route.test.ts deleted file mode 100644 index e406720f..00000000 --- a/tests/unit/api/analytics-route.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Unit tests for the analytics route Zod schema validation - * and consent body parsing logic. - * - * The route itself requires Fastify + DB, but the schema validation - * and consent logic can be tested in isolation. - */ -import { describe, expect, it } from "vitest"; - -// Inline a minimal Zod-like validator to avoid the zod package resolution issue. -// The real route uses Zod; we test the same schema shape with manual validation -// to avoid needing api-workspace dependencies. - -function validateConsentBody(input: unknown): { - success: boolean; - data?: { enabled?: boolean; remindLater?: boolean }; - error?: string; -} { - if (input === null || typeof input !== "object") { - return { success: false, error: "Expected object" }; - } - const obj = input as Record; - const data: { enabled?: boolean; remindLater?: boolean } = {}; - - if ("enabled" in obj) { - if (typeof obj.enabled !== "boolean") - return { success: false, error: "enabled must be boolean" }; - data.enabled = obj.enabled; - } - if ("remindLater" in obj) { - if (typeof obj.remindLater !== "boolean") - return { success: false, error: "remindLater must be boolean" }; - data.remindLater = obj.remindLater; - } - return { success: true, data }; -} - -const analyticsConsentSchema = { - safeParse: validateConsentBody, -}; - -describe("analytics consent schema", () => { - it("accepts empty object", () => { - const result = analyticsConsentSchema.safeParse({}); - expect(result.success).toBe(true); - }); - - it("accepts enabled: true", () => { - const result = analyticsConsentSchema.safeParse({ enabled: true }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.enabled).toBe(true); - } - }); - - it("accepts enabled: false", () => { - const result = analyticsConsentSchema.safeParse({ enabled: false }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.enabled).toBe(false); - } - }); - - it("accepts remindLater: true", () => { - const result = analyticsConsentSchema.safeParse({ remindLater: true }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.remindLater).toBe(true); - } - }); - - it("rejects enabled as string", () => { - const result = analyticsConsentSchema.safeParse({ enabled: "true" }); - expect(result.success).toBe(false); - }); - - it("rejects enabled as number", () => { - const result = analyticsConsentSchema.safeParse({ enabled: 1 }); - expect(result.success).toBe(false); - }); - - it("rejects remindLater as string", () => { - const result = analyticsConsentSchema.safeParse({ remindLater: "yes" }); - expect(result.success).toBe(false); - }); - - it("accepts both enabled and remindLater together", () => { - const result = analyticsConsentSchema.safeParse({ enabled: true, remindLater: false }); - expect(result.success).toBe(true); - }); - - it("strips unknown properties", () => { - const result = analyticsConsentSchema.safeParse({ enabled: true, extra: "field" }); - expect(result.success).toBe(true); - if (result.success) { - expect((result.data as Record).extra).toBeUndefined(); - } - }); -}); - -/** - * Test the consent logic branches (without the DB calls). - * The route has two branches: remindLater and enabled. - */ -describe("analytics consent logic", () => { - it("remindLater branch sets analyticsEnabled to null", () => { - const body = { remindLater: true }; - // Simulating the route logic - if (body.remindLater) { - const analyticsEnabled = null; - expect(analyticsEnabled).toBeNull(); - } - }); - - it("enabled=true sets analyticsEnabled to true", () => { - const body = { enabled: true }; - const enabled = body.enabled === true; - expect(enabled).toBe(true); - }); - - it("enabled=false sets analyticsEnabled to false", () => { - const body = { enabled: false }; - const enabled = body.enabled === true; - expect(enabled).toBe(false); - }); - - it("missing enabled defaults to false", () => { - const body = {}; - const enabled = (body as { enabled?: boolean }).enabled === true; - expect(enabled).toBe(false); - }); - - it("remindLater sets remind-at 7 days in the future", () => { - const now = Date.now(); - const remindAt = new Date(now + 7 * 24 * 60 * 60 * 1000); - const expectedMs = 7 * 24 * 60 * 60 * 1000; - expect(remindAt.getTime() - now).toBe(expectedMs); - }); -}); diff --git a/tests/unit/api/analytics.test.ts b/tests/unit/api/analytics.test.ts index a0d39014..dad6fb0b 100644 --- a/tests/unit/api/analytics.test.ts +++ b/tests/unit/api/analytics.test.ts @@ -1,16 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const config = vi.hoisted(() => ({ - ANALYTICS_ENABLED: false, - POSTHOG_API_KEY: "", - POSTHOG_HOST: "", - SENTRY_DSN: "", - ANALYTICS_SAMPLE_RATE: 1.0, -})); - -const dbGetResult = vi.hoisted(() => ({ value: null as unknown })); -const mockAuthUser = vi.hoisted(() => ({ - value: null as { id: string; analyticsEnabled?: boolean } | null, +const bakedConfig = vi.hoisted(() => ({ + enabled: false, + posthogApiKey: "", + posthogHost: "", + sentryDsn: "", + sampleRate: 1.0, })); const mockCapture = vi.hoisted(() => vi.fn()); @@ -26,16 +21,19 @@ const mockSentryCapture = vi.hoisted(() => vi.fn()); const mockSentryClose = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const mockSentryInit = vi.hoisted(() => vi.fn()); -vi.mock("../../../apps/api/src/config.js", () => ({ env: config })); +vi.mock("@snapotter/shared", async (importOriginal) => { + const actual: Record = await importOriginal(); + return { + ...actual, + ANALYTICS_BAKED: bakedConfig, + }; +}); vi.mock("../../../apps/api/src/db/index.js", () => ({ db: { select: () => ({ from: () => ({ - where: () => { - const val = dbGetResult.value; - return Promise.resolve(val ? [val] : []); - }, + where: () => Promise.resolve([]), }), }), }, @@ -43,14 +41,9 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ closeDb: async () => {}, schema: { settings: { key: "key" }, - users: { id: "id", analyticsEnabled: "analyticsEnabled" }, }, })); -vi.mock("../../../apps/api/src/plugins/auth.js", () => ({ - getAuthUser: () => mockAuthUser.value, -})); - vi.mock("drizzle-orm", () => ({ eq: () => "mocked-eq", })); @@ -69,14 +62,11 @@ type AnalyticsModule = typeof import("../../../apps/api/src/lib/analytics.js"); let mod: AnalyticsModule; beforeEach(async () => { - config.ANALYTICS_ENABLED = false; - config.POSTHOG_API_KEY = ""; - config.POSTHOG_HOST = ""; - config.SENTRY_DSN = ""; - config.ANALYTICS_SAMPLE_RATE = 1.0; - - dbGetResult.value = null; - mockAuthUser.value = null; + bakedConfig.enabled = false; + bakedConfig.posthogApiKey = ""; + bakedConfig.posthogHost = ""; + bakedConfig.sentryDsn = ""; + bakedConfig.sampleRate = 1.0; mockCapture.mockClear(); mockShutdown.mockClear(); @@ -90,23 +80,23 @@ beforeEach(async () => { }); describe("initAnalytics", () => { - it("does nothing when ANALYTICS_ENABLED is false", async () => { - config.ANALYTICS_ENABLED = false; + it("does nothing when ANALYTICS_BAKED.enabled is false", async () => { + bakedConfig.enabled = false; await expect(mod.initAnalytics()).resolves.toBeUndefined(); expect(MockPostHog).not.toHaveBeenCalled(); }); - it("does nothing when ANALYTICS_ENABLED is true but POSTHOG_API_KEY is empty", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = ""; + it("does nothing when enabled is true but posthogApiKey is empty", async () => { + bakedConfig.enabled = true; + bakedConfig.posthogApiKey = ""; await expect(mod.initAnalytics()).resolves.toBeUndefined(); expect(MockPostHog).not.toHaveBeenCalled(); }); it("initializes posthog when enabled with API key", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; - config.POSTHOG_HOST = "https://test.posthog.com"; + bakedConfig.enabled = true; + bakedConfig.posthogApiKey = "phc_test_key"; + bakedConfig.posthogHost = "https://test.posthog.com"; await mod.initAnalytics(); expect(MockPostHog).toHaveBeenCalledWith("phc_test_key", { @@ -116,17 +106,12 @@ describe("initAnalytics", () => { }); }); - it("initializes sentry when SENTRY_DSN is provided", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; - config.SENTRY_DSN = "https://test@sentry.io/123"; + it("does not initialize sentry (moved to preload)", async () => { + bakedConfig.enabled = true; + bakedConfig.posthogApiKey = "phc_test_key"; + bakedConfig.sentryDsn = "https://test@sentry.io/123"; await mod.initAnalytics(); - expect(mockSentryInit).toHaveBeenCalledWith( - expect.objectContaining({ - dsn: "https://test@sentry.io/123", - sendDefaultPii: false, - }), - ); + expect(mockSentryInit).not.toHaveBeenCalled(); }); }); @@ -135,42 +120,16 @@ describe("captureException", () => { await expect(mod.captureException(new Error("test"))).resolves.toBeUndefined(); }); - it("does nothing when request user is not opted in", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; - config.SENTRY_DSN = "https://test@sentry.io/123"; - await mod.initAnalytics(); - - mockAuthUser.value = null; - const fakeRequest = { headers: {} } as Parameters[1]; - await mod.captureException(new Error("test"), fakeRequest); - expect(mockSentryCapture).not.toHaveBeenCalled(); - }); - - it("captures when sentry is initialized and no request provided", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; - config.SENTRY_DSN = "https://test@sentry.io/123"; + it("captures when sentry is initialized", async () => { + bakedConfig.enabled = true; + bakedConfig.posthogApiKey = "phc_test_key"; + bakedConfig.sentryDsn = "https://test@sentry.io/123"; await mod.initAnalytics(); const err = new Error("test error"); await mod.captureException(err); expect(mockSentryCapture).toHaveBeenCalledWith(err); }); - - it("captures when sentry is initialized and request user is opted in", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; - config.SENTRY_DSN = "https://test@sentry.io/123"; - await mod.initAnalytics(); - - mockAuthUser.value = { id: "user-1", analyticsEnabled: true }; - dbGetResult.value = { analyticsEnabled: true }; - const fakeRequest = { headers: {} } as Parameters[1]; - const err = new Error("opted in error"); - await mod.captureException(err, fakeRequest); - expect(mockSentryCapture).toHaveBeenCalledWith(err); - }); }); describe("shutdownAnalytics", () => { @@ -179,73 +138,50 @@ describe("shutdownAnalytics", () => { }); it("shuts down posthog when initialized", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; + bakedConfig.enabled = true; + bakedConfig.posthogApiKey = "phc_test_key"; await mod.initAnalytics(); await mod.shutdownAnalytics(); expect(mockShutdown).toHaveBeenCalled(); }); - it("closes sentry when initialized", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; - config.SENTRY_DSN = "https://test@sentry.io/123"; + it("does not close sentry (moved to preload)", async () => { + bakedConfig.enabled = true; + bakedConfig.posthogApiKey = "phc_test_key"; + bakedConfig.sentryDsn = "https://test@sentry.io/123"; await mod.initAnalytics(); await mod.shutdownAnalytics(); - expect(mockSentryClose).toHaveBeenCalledWith(2000); + expect(mockSentryClose).not.toHaveBeenCalled(); }); }); describe("trackEvent", () => { it("does nothing when posthogClient is null", async () => { - const fakeRequest = {} as Parameters[0]; - await expect( - mod.trackEvent(fakeRequest, "test_event", { key: "value" }), - ).resolves.toBeUndefined(); + await expect(mod.trackEvent("test_event", { key: "value" })).resolves.toBeUndefined(); }); - it("does nothing when ANALYTICS_ENABLED is false", async () => { - config.ANALYTICS_ENABLED = false; - const fakeRequest = {} as Parameters[0]; - await expect( - mod.trackEvent(fakeRequest, "test_event", { key: "value" }), - ).resolves.toBeUndefined(); + it("does nothing when ANALYTICS_BAKED.enabled is false", async () => { + bakedConfig.enabled = false; + await expect(mod.trackEvent("test_event", { key: "value" })).resolves.toBeUndefined(); }); - it("does nothing when request user is not opted in", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; + it("does nothing when sampleRate is 0", async () => { + bakedConfig.enabled = true; + bakedConfig.posthogApiKey = "phc_test_key"; + bakedConfig.sampleRate = 0; await mod.initAnalytics(); - mockAuthUser.value = null; - const fakeRequest = { headers: {} } as Parameters[0]; - await mod.trackEvent(fakeRequest, "test_event", { key: "value" }); + await mod.trackEvent("test_event", { key: "value" }); expect(mockCapture).not.toHaveBeenCalled(); }); - it("does nothing when ANALYTICS_SAMPLE_RATE is 0", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; - config.ANALYTICS_SAMPLE_RATE = 0; + it("captures event when enabled and initialized", async () => { + bakedConfig.enabled = true; + bakedConfig.posthogApiKey = "phc_test_key"; + bakedConfig.sampleRate = 1.0; await mod.initAnalytics(); - mockAuthUser.value = { id: "user-1", analyticsEnabled: true }; - dbGetResult.value = { analyticsEnabled: true }; - const fakeRequest = { headers: {} } as Parameters[0]; - await mod.trackEvent(fakeRequest, "test_event", { key: "value" }); - expect(mockCapture).not.toHaveBeenCalled(); - }); - - it("captures event when all conditions are met", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; - config.ANALYTICS_SAMPLE_RATE = 1.0; - await mod.initAnalytics(); - - mockAuthUser.value = { id: "user-1", analyticsEnabled: true }; - dbGetResult.value = { analyticsEnabled: true }; - const fakeRequest = { headers: {} } as Parameters[0]; - await mod.trackEvent(fakeRequest, "tool_used", { tool: "resize" }); + await mod.trackEvent("tool_used", { tool: "resize" }); expect(mockCapture).toHaveBeenCalledWith({ distinctId: "unknown", event: "tool_used", @@ -253,64 +189,30 @@ describe("trackEvent", () => { }); }); - it("uses instance ID from DB when available", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; - config.ANALYTICS_SAMPLE_RATE = 1.0; + it("uses provided distinctId when given", async () => { + bakedConfig.enabled = true; + bakedConfig.posthogApiKey = "phc_test_key"; + bakedConfig.sampleRate = 1.0; await mod.initAnalytics(); - dbGetResult.value = { value: "inst-abc-123", analyticsEnabled: true }; - mockAuthUser.value = { id: "user-1", analyticsEnabled: true }; - const fakeRequest = { headers: {} } as Parameters[0]; - await mod.trackEvent(fakeRequest, "tool_used", { tool: "crop" }); + await mod.trackEvent("tool_used", { tool: "crop" }, "custom-id-123"); expect(mockCapture).toHaveBeenCalledWith( expect.objectContaining({ - distinctId: "inst-abc-123", + distinctId: "custom-id-123", }), ); }); - it("allows anonymous users with x-analytics-consent header", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; - config.ANALYTICS_SAMPLE_RATE = 1.0; - await mod.initAnalytics(); - - mockAuthUser.value = { id: "anonymous" }; - const fakeRequest = { - headers: { "x-analytics-consent": "true" }, - } as unknown as Parameters[0]; - await mod.trackEvent(fakeRequest, "test_event", { key: "value" }); - expect(mockCapture).toHaveBeenCalled(); - }); - - it("rejects anonymous users without consent header", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; - config.ANALYTICS_SAMPLE_RATE = 1.0; - await mod.initAnalytics(); - - mockAuthUser.value = { id: "anonymous" }; - const fakeRequest = { - headers: {}, - } as unknown as Parameters[0]; - mod.trackEvent(fakeRequest, "test_event", { key: "value" }); - expect(mockCapture).not.toHaveBeenCalled(); - }); - it("does not throw when capture throws internally", async () => { - config.ANALYTICS_ENABLED = true; - config.POSTHOG_API_KEY = "phc_test_key"; - config.ANALYTICS_SAMPLE_RATE = 1.0; + bakedConfig.enabled = true; + bakedConfig.posthogApiKey = "phc_test_key"; + bakedConfig.sampleRate = 1.0; await mod.initAnalytics(); mockCapture.mockImplementationOnce(() => { throw new Error("capture failed"); }); - mockAuthUser.value = { id: "user-1", analyticsEnabled: true }; - dbGetResult.value = { analyticsEnabled: true }; - const fakeRequest = { headers: {} } as Parameters[0]; - expect(() => mod.trackEvent(fakeRequest, "test_event", { key: "value" })).not.toThrow(); + await expect(mod.trackEvent("test_event", { key: "value" })).resolves.toBeUndefined(); }); }); diff --git a/tests/unit/api/tool-factory-route.test.ts b/tests/unit/api/tool-factory-route.test.ts index 89c880fe..4b48facd 100644 --- a/tests/unit/api/tool-factory-route.test.ts +++ b/tests/unit/api/tool-factory-route.test.ts @@ -261,6 +261,7 @@ function createMockRequest(opts: { for (const p of parts) yield p; }, }), + headers: {}, log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, }; } @@ -599,6 +600,7 @@ describe("createToolRoute", () => { }; }, }), + headers: {}, log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, }; @@ -644,6 +646,7 @@ describe("createToolRoute", () => { }; }, }), + headers: {}, log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, }; @@ -677,6 +680,7 @@ describe("createToolRoute", () => { }; }, }), + headers: {}, log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, }; @@ -709,6 +713,7 @@ describe("createToolRoute", () => { }; }, }), + headers: {}, log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, }; diff --git a/tests/unit/shared/analytics-consent-extended.test.ts b/tests/unit/shared/analytics-consent-extended.test.ts deleted file mode 100644 index 53f91145..00000000 --- a/tests/unit/shared/analytics-consent-extended.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { ConsentState } from "@snapotter/shared"; -import { isConsentEnabled, shouldShowConsent } from "@snapotter/shared"; -import { describe, expect, it } from "vitest"; - -const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; - -describe("shouldShowConsent edge cases", () => { - it("returns true when remindAt is exactly equal to Date.now()", () => { - const now = Date.now(); - const state: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: now - SEVEN_DAYS_MS, - analyticsConsentRemindAt: now, - }; - // Date.now() >= remindAt should be true when they are equal - expect(shouldShowConsent(state, true)).toBe(true); - }); - - it("returns true when remindAt is set but consentShownAt is null (defensive)", () => { - const state: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: Date.now() - 1000, - }; - // consentShownAt is null -> returns true (fresh user path) - expect(shouldShowConsent(state, true)).toBe(true); - }); - - it("returns false when remindAt is 1ms in the future", () => { - const now = Date.now(); - const state: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: now - SEVEN_DAYS_MS, - analyticsConsentRemindAt: now + 100000, // safely in the future - }; - expect(shouldShowConsent(state, true)).toBe(false); - }); - - it("returns true after 'Maybe later' and 7 days have passed", () => { - const shownAt = Date.now() - SEVEN_DAYS_MS - 1000; - const remindAt = Date.now() - 1000; // remind time has passed - const state: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: shownAt, - analyticsConsentRemindAt: remindAt, - }; - expect(shouldShowConsent(state, true)).toBe(true); - }); - - it("returns false when server is disabled regardless of remindAt", () => { - const state: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: Date.now() - SEVEN_DAYS_MS, - analyticsConsentRemindAt: Date.now() - 1000, - }; - expect(shouldShowConsent(state, false)).toBe(false); - }); -}); - -describe("isConsentEnabled edge cases", () => { - it("returns false when analyticsEnabled is false (explicitly declined)", () => { - const state: ConsentState = { - analyticsEnabled: false, - analyticsConsentShownAt: Date.now(), - analyticsConsentRemindAt: null, - }; - expect(isConsentEnabled(state, true)).toBe(false); - }); - - it("returns false when analyticsEnabled is null (never decided)", () => { - const state: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, - }; - expect(isConsentEnabled(state, true)).toBe(false); - }); - - it("returns false when server disabled even if user opted in", () => { - const state: ConsentState = { - analyticsEnabled: true, - analyticsConsentShownAt: Date.now(), - analyticsConsentRemindAt: null, - }; - expect(isConsentEnabled(state, false)).toBe(false); - }); -}); - -describe("consent lifecycle simulations", () => { - it("fresh -> maybe later -> remind time passes -> show again -> accept", () => { - // Step 1: Fresh user -- never been asked - const fresh: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, - }; - expect(shouldShowConsent(fresh, true)).toBe(true); - expect(isConsentEnabled(fresh, true)).toBe(false); - - // Step 2: User clicks "Maybe later" -- shown timestamp set, remind in 7 days - const shownAt = Date.now() - SEVEN_DAYS_MS - 1000; - const maybeLater: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: shownAt, - analyticsConsentRemindAt: shownAt + SEVEN_DAYS_MS, - }; - // Remind time has now passed (shownAt + 7days < now) - expect(shouldShowConsent(maybeLater, true)).toBe(true); - expect(isConsentEnabled(maybeLater, true)).toBe(false); - - // Step 3: User accepts on second prompt - const accepted: ConsentState = { - analyticsEnabled: true, - analyticsConsentShownAt: Date.now(), - analyticsConsentRemindAt: null, - }; - expect(shouldShowConsent(accepted, true)).toBe(false); - expect(isConsentEnabled(accepted, true)).toBe(true); - }); - - it("fresh -> accept immediately -> never show again", () => { - // Step 1: Fresh user - const fresh: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, - }; - expect(shouldShowConsent(fresh, true)).toBe(true); - - // Step 2: User accepts immediately - const accepted: ConsentState = { - analyticsEnabled: true, - analyticsConsentShownAt: Date.now(), - analyticsConsentRemindAt: null, - }; - expect(shouldShowConsent(accepted, true)).toBe(false); - expect(isConsentEnabled(accepted, true)).toBe(true); - - // Verify it stays hidden even far in the future - expect(shouldShowConsent(accepted, true)).toBe(false); - }); - - it("fresh -> decline immediately -> never show again", () => { - // Step 1: Fresh user - const fresh: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, - }; - expect(shouldShowConsent(fresh, true)).toBe(true); - - // Step 2: User declines immediately - const declined: ConsentState = { - analyticsEnabled: false, - analyticsConsentShownAt: Date.now(), - analyticsConsentRemindAt: null, - }; - expect(shouldShowConsent(declined, true)).toBe(false); - expect(isConsentEnabled(declined, true)).toBe(false); - - // Verify it stays hidden and analytics stays disabled - expect(shouldShowConsent(declined, true)).toBe(false); - expect(isConsentEnabled(declined, true)).toBe(false); - }); - - it("fresh -> maybe later -> remind time NOT yet passed -> stay hidden", () => { - const fresh: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, - }; - expect(shouldShowConsent(fresh, true)).toBe(true); - - // User clicks maybe later, only 1 day ago - const maybeLater: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: Date.now() - 86400000, - analyticsConsentRemindAt: Date.now() + SEVEN_DAYS_MS - 86400000, - }; - expect(shouldShowConsent(maybeLater, true)).toBe(false); - expect(isConsentEnabled(maybeLater, true)).toBe(false); - }); -}); diff --git a/tests/unit/shared/analytics-consent.test.ts b/tests/unit/shared/analytics-consent.test.ts deleted file mode 100644 index 03f5d651..00000000 --- a/tests/unit/shared/analytics-consent.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { isConsentEnabled, shouldShowConsent } from "@snapotter/shared"; -import { describe, expect, it } from "vitest"; - -describe("shouldShowConsent", () => { - it("returns false when server has analytics disabled", () => { - expect( - shouldShowConsent( - { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, - }, - false, - ), - ).toBe(false); - }); - - it("returns true for a fresh user who has never been asked", () => { - expect( - shouldShowConsent( - { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, - }, - true, - ), - ).toBe(true); - }); - - it("returns false when user already opted in", () => { - expect( - shouldShowConsent( - { - analyticsEnabled: true, - analyticsConsentShownAt: Date.now(), - analyticsConsentRemindAt: null, - }, - true, - ), - ).toBe(false); - }); - - it("returns false when user explicitly declined", () => { - expect( - shouldShowConsent( - { - analyticsEnabled: false, - analyticsConsentShownAt: Date.now(), - analyticsConsentRemindAt: null, - }, - true, - ), - ).toBe(false); - }); - - it("returns false when remind-at is in the future", () => { - expect( - shouldShowConsent( - { - analyticsEnabled: null, - analyticsConsentShownAt: Date.now() - 86400000, - analyticsConsentRemindAt: Date.now() + 86400000, - }, - true, - ), - ).toBe(false); - }); - - it("returns true when remind-at has passed", () => { - expect( - shouldShowConsent( - { - analyticsEnabled: null, - analyticsConsentShownAt: Date.now() - 86400000 * 8, - analyticsConsentRemindAt: Date.now() - 86400000, - }, - true, - ), - ).toBe(true); - }); -}); - -describe("isConsentEnabled", () => { - it("returns false when server disabled", () => { - expect( - isConsentEnabled( - { - analyticsEnabled: true, - analyticsConsentShownAt: Date.now(), - analyticsConsentRemindAt: null, - }, - false, - ), - ).toBe(false); - }); - - it("returns false when user has not consented", () => { - expect( - isConsentEnabled( - { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, - }, - true, - ), - ).toBe(false); - }); - - it("returns true when user opted in and server enabled", () => { - expect( - isConsentEnabled( - { - analyticsEnabled: true, - analyticsConsentShownAt: Date.now(), - analyticsConsentRemindAt: null, - }, - true, - ), - ).toBe(true); - }); -}); diff --git a/tests/unit/shared/analytics-events.test.ts b/tests/unit/shared/analytics-events.test.ts index 2ff66d0d..123d7716 100644 --- a/tests/unit/shared/analytics-events.test.ts +++ b/tests/unit/shared/analytics-events.test.ts @@ -2,15 +2,23 @@ import { ANALYTICS_EVENTS } from "@snapotter/shared"; import { describe, expect, it } from "vitest"; describe("ANALYTICS_EVENTS", () => { - it("has exactly 4 event keys", () => { - expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(4); + it("has exactly 12 event keys", () => { + expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(12); }); it("contains the expected keys", () => { expect(ANALYTICS_EVENTS).toHaveProperty("TOOL_USED"); + expect(ANALYTICS_EVENTS).toHaveProperty("TOOL_OPENED"); + expect(ANALYTICS_EVENTS).toHaveProperty("FILE_ADDED"); + expect(ANALYTICS_EVENTS).toHaveProperty("TOOL_STARTED"); + expect(ANALYTICS_EVENTS).toHaveProperty("TOOL_CLIENT_ERROR"); + expect(ANALYTICS_EVENTS).toHaveProperty("RESULT_DOWNLOADED"); + expect(ANALYTICS_EVENTS).toHaveProperty("RESULT_SAVED"); expect(ANALYTICS_EVENTS).toHaveProperty("SEARCH"); expect(ANALYTICS_EVENTS).toHaveProperty("PIPELINE_EXECUTED"); expect(ANALYTICS_EVENTS).toHaveProperty("AI_BUNDLE_ACTION"); + expect(ANALYTICS_EVENTS).toHaveProperty("AI_BUNDLE_PROMPTED"); + expect(ANALYTICS_EVENTS).toHaveProperty("BATCH_PROCESSED"); }); it("all event values are strings", () => { @@ -47,10 +55,11 @@ describe("ANALYTICS_EVENTS", () => { // defined with "as const" is a plain object unless explicitly frozen. // We verify the values are stable by checking they haven't changed. const snapshot = { ...ANALYTICS_EVENTS }; - expect(ANALYTICS_EVENTS.TOOL_USED).toBe(snapshot.TOOL_USED); - expect(ANALYTICS_EVENTS.SEARCH).toBe(snapshot.SEARCH); - expect(ANALYTICS_EVENTS.PIPELINE_EXECUTED).toBe(snapshot.PIPELINE_EXECUTED); - expect(ANALYTICS_EVENTS.AI_BUNDLE_ACTION).toBe(snapshot.AI_BUNDLE_ACTION); + for (const key of Object.keys(snapshot)) { + expect(ANALYTICS_EVENTS[key as keyof typeof ANALYTICS_EVENTS]).toBe( + snapshot[key as keyof typeof snapshot], + ); + } }); it("all values are unique (no duplicate event names)", () => { diff --git a/tests/unit/shared/analytics-types.test.ts b/tests/unit/shared/analytics-types.test.ts index 72e15f02..278a9dcf 100644 --- a/tests/unit/shared/analytics-types.test.ts +++ b/tests/unit/shared/analytics-types.test.ts @@ -1,4 +1,4 @@ -import type { AnalyticsConfig, ConsentState } from "@snapotter/shared"; +import type { AnalyticsConfig } from "@snapotter/shared"; import { describe, expect, it } from "vitest"; describe("AnalyticsConfig type", () => { @@ -59,71 +59,3 @@ describe("AnalyticsConfig type", () => { ); }); }); - -describe("ConsentState type", () => { - it("accepts all-null state (fresh user)", () => { - const state: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, - }; - expect(state.analyticsEnabled).toBeNull(); - expect(state.analyticsConsentShownAt).toBeNull(); - expect(state.analyticsConsentRemindAt).toBeNull(); - }); - - it("accepts opted-in state (analyticsEnabled = true)", () => { - const state: ConsentState = { - analyticsEnabled: true, - analyticsConsentShownAt: 1713800000000, - analyticsConsentRemindAt: null, - }; - expect(state.analyticsEnabled).toBe(true); - expect(state.analyticsConsentShownAt).toBe(1713800000000); - expect(state.analyticsConsentRemindAt).toBeNull(); - }); - - it("accepts declined state (analyticsEnabled = false)", () => { - const state: ConsentState = { - analyticsEnabled: false, - analyticsConsentShownAt: 1713800000000, - analyticsConsentRemindAt: null, - }; - expect(state.analyticsEnabled).toBe(false); - }); - - it("accepts deferred state (maybe later with remindAt set)", () => { - const shownAt = Date.now() - 86400000; - const remindAt = Date.now() + 86400000 * 6; - const state: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: shownAt, - analyticsConsentRemindAt: remindAt, - }; - expect(state.analyticsEnabled).toBeNull(); - expect(state.analyticsConsentShownAt).toBe(shownAt); - expect(state.analyticsConsentRemindAt).toBe(remindAt); - }); - - it("accepts mixed state with analyticsEnabled true and remindAt set", () => { - const state: ConsentState = { - analyticsEnabled: true, - analyticsConsentShownAt: 1713800000000, - analyticsConsentRemindAt: 1714400000000, - }; - expect(state.analyticsEnabled).toBe(true); - expect(state.analyticsConsentRemindAt).toBe(1714400000000); - }); - - it("has exactly the expected keys", () => { - const state: ConsentState = { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, - }; - const keys = Object.keys(state).sort(); - expect(keys).toEqual( - ["analyticsConsentRemindAt", "analyticsConsentShownAt", "analyticsEnabled"].sort(), - ); - }); -}); diff --git a/tests/unit/web/analytics-no-leak.test.ts b/tests/unit/web/analytics-no-leak.test.ts index ed7f3ef2..bcf8ad6e 100644 --- a/tests/unit/web/analytics-no-leak.test.ts +++ b/tests/unit/web/analytics-no-leak.test.ts @@ -1,23 +1,20 @@ // @vitest-environment node // // Proves the invariant: PostHog and Sentry are NEVER called when -// analytics is disabled or the user has not granted consent. +// analytics is disabled in the baked config. import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const mockPosthogInit = vi.fn(() => ({ capture: mockCapture, - identify: mockIdentify, - startSessionRecording: mockStartSessionRecording, + startSessionRecording: vi.fn(), opt_in_capturing: vi.fn(), - opt_out_capturing: mockOptOut, - reset: mockReset, + opt_out_capturing: vi.fn(), + reset: vi.fn(), + register: vi.fn(), + get_distinct_id: vi.fn(() => "test-id"), persistence: { disabled: false }, })); const mockCapture = vi.fn(); -const mockIdentify = vi.fn(); -const mockStartSessionRecording = vi.fn(); -const mockOptOut = vi.fn(); -const mockReset = vi.fn(); vi.mock("posthog-js", () => ({ __esModule: true, @@ -25,8 +22,10 @@ vi.mock("posthog-js", () => ({ })); const mockSentryInit = vi.fn(); +const mockBrowserTracingIntegration = vi.fn(() => ({ name: "BrowserTracing" })); vi.mock("@sentry/react", () => ({ init: mockSentryInit, + browserTracingIntegration: mockBrowserTracingIntegration, })); const noop = () => {}; @@ -43,15 +42,6 @@ afterAll(() => { vi.restoreAllMocks(); }); -import { - identify, - initAnalytics, - setAnalyticsConsent, - shutdownAnalytics, - startErrorReplay, - track, -} from "@/lib/analytics"; - const enabledConfig = { enabled: true, posthogApiKey: "phc_test", @@ -70,222 +60,66 @@ const disabledConfig = { instanceId: "", }; -function clearAllMocks() { - shutdownAnalytics(); - mockPosthogInit.mockClear(); - mockCapture.mockClear(); - mockIdentify.mockClear(); - mockStartSessionRecording.mockClear(); - mockOptOut.mockClear(); - mockReset.mockClear(); - mockSentryInit.mockClear(); -} +type AnalyticsModule = typeof import("../../../apps/web/src/lib/analytics"); +let mod: AnalyticsModule; -describe("Analytics No-Leak Invariant", () => { - beforeEach(clearAllMocks); +describe("Analytics No-Leak Invariant (baked model)", () => { + beforeEach(async () => { + mockPosthogInit.mockClear(); + mockCapture.mockClear(); + mockSentryInit.mockClear(); + vi.resetModules(); + mod = await import("../../../apps/web/src/lib/analytics"); + }); - // ── Scenario 1: Server has analytics disabled ───────────────────── - describe("when server config.enabled is false", () => { + describe("when config.enabled is false", () => { it("initAnalytics never calls posthog.init", async () => { - setAnalyticsConsent(true); - await initAnalytics(disabledConfig); + await mod.initAnalytics(disabledConfig); expect(mockPosthogInit).not.toHaveBeenCalled(); }); it("initAnalytics never calls Sentry.init", async () => { - setAnalyticsConsent(true); - await initAnalytics(disabledConfig); + await mod.initAnalytics(disabledConfig); expect(mockSentryInit).not.toHaveBeenCalled(); }); it("track() is a silent no-op", async () => { - setAnalyticsConsent(true); - await initAnalytics(disabledConfig); - track("test_event", { key: "value" }); + await mod.initAnalytics(disabledConfig); + mod.track("test_event", { key: "value" }); expect(mockCapture).not.toHaveBeenCalled(); }); - - it("identify() is a silent no-op", async () => { - setAnalyticsConsent(true); - await initAnalytics(disabledConfig); - identify("inst-1", { version: "1.0" }); - expect(mockIdentify).not.toHaveBeenCalled(); - }); - - it("startErrorReplay() is a silent no-op", async () => { - setAnalyticsConsent(true); - await initAnalytics(disabledConfig); - startErrorReplay(); - expect(mockStartSessionRecording).not.toHaveBeenCalled(); - }); }); - // ── Scenario 2: Consent never granted ───────────────────────────── - describe("when consent is never granted (fresh user)", () => { - it("initAnalytics with enabled config but no consent skips PostHog", async () => { - // Do NOT call setAnalyticsConsent(true) -- simulates fresh user - await initAnalytics(enabledConfig); - expect(mockPosthogInit).not.toHaveBeenCalled(); - }); - - it("track() never calls posthog.capture", () => { - track("should_not_fire"); - expect(mockCapture).not.toHaveBeenCalled(); - }); - - it("identify() never calls posthog.identify", () => { - identify("inst-1", {}); - expect(mockIdentify).not.toHaveBeenCalled(); - }); - - it("startErrorReplay() never calls posthog.startSessionRecording", () => { - startErrorReplay(); - expect(mockStartSessionRecording).not.toHaveBeenCalled(); - }); - - it("no PostHog or Sentry SDK is loaded at all", async () => { - await initAnalytics(enabledConfig); - expect(mockPosthogInit).not.toHaveBeenCalled(); - expect(mockSentryInit).not.toHaveBeenCalled(); - }); - }); - - // ── Scenario 3: Consent explicitly revoked ──────────────────────── - describe("when consent is revoked after being granted", () => { - it("shutdownAnalytics opts out and resets PostHog", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); + describe("when config.enabled is true", () => { + it("initAnalytics calls posthog.init", async () => { + await mod.initAnalytics(enabledConfig); expect(mockPosthogInit).toHaveBeenCalledOnce(); - - setAnalyticsConsent(false); - expect(mockOptOut).toHaveBeenCalledOnce(); - expect(mockReset).toHaveBeenCalledOnce(); }); - it("track() is silent after revocation", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - mockCapture.mockClear(); - - setAnalyticsConsent(false); - track("should_not_fire"); - expect(mockCapture).not.toHaveBeenCalled(); + it("track() forwards to posthog.capture", async () => { + await mod.initAnalytics(enabledConfig); + mod.track("tool_used", { tool: "resize" }); + expect(mockCapture).toHaveBeenCalledWith("tool_used", { tool: "resize" }); }); - it("identify() is silent after revocation", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - mockIdentify.mockClear(); - - setAnalyticsConsent(false); - identify("inst-1", { phase: 2 }); - expect(mockIdentify).not.toHaveBeenCalled(); - }); - - it("startErrorReplay() is silent after revocation", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - mockStartSessionRecording.mockClear(); - - setAnalyticsConsent(false); - startErrorReplay(); - expect(mockStartSessionRecording).not.toHaveBeenCalled(); - }); - - it("Sentry beforeSend returns null after revocation", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - const sentryCall = mockSentryInit.mock.calls.find((call: unknown[]) => call[0]?.beforeSend); - expect(sentryCall).toBeDefined(); - const beforeSend = sentryCall?.[0].beforeSend; - - setAnalyticsConsent(false); - const result = beforeSend({ exception: { values: [] } }); - expect(result).toBeNull(); - }); - - it("Sentry beforeBreadcrumb returns null after revocation", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - const sentryCall = mockSentryInit.mock.calls.find( - (call: unknown[]) => call[0]?.beforeBreadcrumb, + it("initAnalytics initializes Sentry when sentryDsn provided", async () => { + await mod.initAnalytics(enabledConfig); + expect(mockSentryInit).toHaveBeenCalledOnce(); + expect(mockSentryInit).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: "https://sentry.test/123", + sendDefaultPii: false, + }), ); - expect(sentryCall).toBeDefined(); - const beforeBreadcrumb = sentryCall?.[0].beforeBreadcrumb; - - setAnalyticsConsent(false); - const result = beforeBreadcrumb({ category: "console", message: "test" }); - expect(result).toBeNull(); }); }); - // ── Scenario 4: Remind-later state ──────────────────────────────── - describe("when user is in remind-later state (consent = null)", () => { - it("remind-later calls setAnalyticsConsent(false), not true", () => { - // This is tested in the store, but verify the invariant: - // When consent hasn't been given, setAnalyticsConsent(false) is called, - // which means PostHog/Sentry are never active during the remind period. - setAnalyticsConsent(false); - track("during_remind_period"); - expect(mockCapture).not.toHaveBeenCalled(); - }); - - it("no SDK activity after remind-later even with enabled config", async () => { - // Simulate: user hit "Not right now" -- consent was never true - setAnalyticsConsent(false); - await initAnalytics(enabledConfig); - expect(mockPosthogInit).not.toHaveBeenCalled(); - expect(mockSentryInit).not.toHaveBeenCalled(); - - track("event_during_remind"); - identify("inst-1", {}); - startErrorReplay(); - expect(mockCapture).not.toHaveBeenCalled(); - expect(mockIdentify).not.toHaveBeenCalled(); - expect(mockStartSessionRecording).not.toHaveBeenCalled(); - }); - }); - - // ── Scenario 5: Consent race condition ──────────────────────────── - describe("consent revoked during async SDK import", () => { - it("PostHog is not active if consent revoked while import resolves", async () => { - setAnalyticsConsent(true); - const initPromise = initAnalytics(enabledConfig); - setAnalyticsConsent(false); - await initPromise; - - track("after_race_condition"); - expect(mockCapture).not.toHaveBeenCalled(); - }); - }); - - // ── Scenario 6: Multiple rapid toggles ──────────────────────────── - describe("rapid consent toggles", () => { - it("ending on false means nothing is active", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - setAnalyticsConsent(false); - setAnalyticsConsent(true); - setAnalyticsConsent(false); - setAnalyticsConsent(true); - setAnalyticsConsent(false); - - mockCapture.mockClear(); - mockIdentify.mockClear(); - track("after_toggles"); - identify("inst-1", {}); - expect(mockCapture).not.toHaveBeenCalled(); - expect(mockIdentify).not.toHaveBeenCalled(); - }); - }); - - // ── Scenario 7: PII scrubbing even when consent is granted ──────── - describe("PII never leaks even with consent", () => { + describe("PII never leaks even when analytics enabled", () => { it("Sentry strips user.email and user.username from events", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); + await mod.initAnalytics(enabledConfig); const sentryCall = mockSentryInit.mock.calls.find((call: unknown[]) => call[0]?.beforeSend); const beforeSend = sentryCall?.[0].beforeSend; + if (!beforeSend) return; const event = { user: { email: "user@example.com", username: "admin", id: "123" }, @@ -298,10 +132,10 @@ describe("Analytics No-Leak Invariant", () => { }); it("Sentry redacts file paths from exception values", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); + await mod.initAnalytics(enabledConfig); const sentryCall = mockSentryInit.mock.calls.find((call: unknown[]) => call[0]?.beforeSend); const beforeSend = sentryCall?.[0].beforeSend; + if (!beforeSend) return; const event = { exception: { @@ -322,23 +156,23 @@ describe("Analytics No-Leak Invariant", () => { }); it("Sentry blocks ui.click breadcrumbs", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); + await mod.initAnalytics(enabledConfig); const sentryCall = mockSentryInit.mock.calls.find( (call: unknown[]) => call[0]?.beforeBreadcrumb, ); const beforeBreadcrumb = sentryCall?.[0].beforeBreadcrumb; + if (!beforeBreadcrumb) return; expect(beforeBreadcrumb({ category: "ui.click" })).toBeNull(); }); it("Sentry blocks fetch breadcrumbs to file URLs", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); + await mod.initAnalytics(enabledConfig); const sentryCall = mockSentryInit.mock.calls.find( (call: unknown[]) => call[0]?.beforeBreadcrumb, ); const beforeBreadcrumb = sentryCall?.[0].beforeBreadcrumb; + if (!beforeBreadcrumb) return; expect( beforeBreadcrumb({ diff --git a/tests/unit/web/analytics.test.ts b/tests/unit/web/analytics.test.ts index ca475cc2..1eb1fab0 100644 --- a/tests/unit/web/analytics.test.ts +++ b/tests/unit/web/analytics.test.ts @@ -3,19 +3,15 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites const mockInit = vi.fn(() => ({ capture: mockCapture, - identify: mockIdentify, - startSessionRecording: mockStartSessionRecording, - opt_in_capturing: mockOptIn, - opt_out_capturing: mockOptOut, - reset: mockReset, + startSessionRecording: vi.fn(), + opt_in_capturing: vi.fn(), + opt_out_capturing: vi.fn(), + reset: vi.fn(), + register: vi.fn(), + get_distinct_id: vi.fn(() => "test-distinct-id"), persistence: { disabled: false }, })); const mockCapture = vi.fn(); -const mockIdentify = vi.fn(); -const mockStartSessionRecording = vi.fn(); -const mockOptIn = vi.fn(); -const mockOptOut = vi.fn(); -const mockReset = vi.fn(); vi.mock("posthog-js", () => ({ __esModule: true, @@ -23,8 +19,10 @@ vi.mock("posthog-js", () => ({ })); const mockSentryInit = vi.fn(); +const mockBrowserTracingIntegration = vi.fn(() => ({ name: "BrowserTracing" })); vi.mock("@sentry/react", () => ({ init: mockSentryInit, + browserTracingIntegration: mockBrowserTracingIntegration, })); const noop = () => {}; @@ -41,15 +39,6 @@ afterAll(() => { vi.restoreAllMocks(); }); -import { - identify, - initAnalytics, - setAnalyticsConsent, - shutdownAnalytics, - startErrorReplay, - track, -} from "@/lib/analytics"; - const enabledConfig = { enabled: true, posthogApiKey: "phc_test", @@ -68,34 +57,29 @@ const disabledConfig = { instanceId: "inst-1", }; -describe("analytics lib", () => { - beforeEach(() => { - shutdownAnalytics(); - mockInit.mockClear(); - mockCapture.mockClear(); - mockIdentify.mockClear(); - mockStartSessionRecording.mockClear(); - mockOptIn.mockClear(); - mockOptOut.mockClear(); - mockReset.mockClear(); - mockSentryInit.mockClear(); - }); +type AnalyticsModule = typeof import("../../../apps/web/src/lib/analytics"); +// The frontend analytics module has internal state (initialized flag, posthog instance) +// that persists across calls. We reset modules before each test to get a fresh module. +let mod: AnalyticsModule; + +beforeEach(async () => { + mockInit.mockClear(); + mockCapture.mockClear(); + mockSentryInit.mockClear(); + vi.resetModules(); + mod = await import("../../../apps/web/src/lib/analytics"); +}); + +describe("analytics lib (baked model)", () => { describe("initAnalytics", () => { it("skips initialization when config.enabled is false", async () => { - setAnalyticsConsent(true); - await initAnalytics(disabledConfig); + await mod.initAnalytics(disabledConfig); expect(mockInit).not.toHaveBeenCalled(); }); - it("skips posthog.init when consent is not granted", async () => { - await initAnalytics(enabledConfig); - expect(mockInit).not.toHaveBeenCalled(); - }); - - it("calls posthog.init when config.enabled and consent are both true", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); + it("calls posthog.init when config.enabled is true", async () => { + await mod.initAnalytics(enabledConfig); expect(mockInit).toHaveBeenCalledOnce(); expect(mockInit).toHaveBeenCalledWith( "phc_test", @@ -107,9 +91,8 @@ describe("analytics lib", () => { ); }); - it("initializes Sentry when sentryDsn is provided and consent is granted", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); + it("initializes Sentry when sentryDsn is provided", async () => { + await mod.initAnalytics(enabledConfig); expect(mockSentryInit).toHaveBeenCalledOnce(); expect(mockSentryInit).toHaveBeenCalledWith( expect.objectContaining({ @@ -120,8 +103,7 @@ describe("analytics lib", () => { }); it("skips Sentry when sentryDsn is empty", async () => { - setAnalyticsConsent(true); - await initAnalytics({ ...enabledConfig, sentryDsn: "" }); + await mod.initAnalytics({ ...enabledConfig, sentryDsn: "" }); expect(mockSentryInit).not.toHaveBeenCalled(); }); @@ -130,324 +112,65 @@ describe("analytics lib", () => { mockSentryInit.mockImplementationOnce(() => { throw new Error("Sentry init boom"); }); - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); + await mod.initAnalytics(enabledConfig); expect(consoleSpy).toHaveBeenCalledWith("[analytics] Sentry init failed:", expect.any(Error)); consoleSpy.mockRestore(); }); it("does not double-initialize on repeated calls", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - await initAnalytics(enabledConfig); + await mod.initAnalytics(enabledConfig); + await mod.initAnalytics(enabledConfig); expect(mockInit).toHaveBeenCalledOnce(); }); - - it("retries initialization if first attempt throws", async () => { - setAnalyticsConsent(true); - mockInit.mockImplementationOnce(() => { - throw new Error("init failed"); - }); - await initAnalytics(enabledConfig); - expect(mockInit).toHaveBeenCalledOnce(); - - mockInit.mockClear(); - mockInit.mockReturnValueOnce({ - capture: mockCapture, - identify: mockIdentify, - startSessionRecording: mockStartSessionRecording, - opt_in_capturing: mockOptIn, - opt_out_capturing: mockOptOut, - reset: mockReset, - persistence: { disabled: false }, - }); - await initAnalytics(enabledConfig); - expect(mockInit).toHaveBeenCalledOnce(); - }); - - it("bails out if consent is revoked during async import", async () => { - setAnalyticsConsent(true); - const initPromise = initAnalytics(enabledConfig); - setAnalyticsConsent(false); - await initPromise; - // shutdownAnalytics was called by setAnalyticsConsent(false), - // and the init should have bailed after the import resolved - // because consentGranted was false at that point. - // mockInit may or may not have been called depending on timing, - // but the SDK should not be active after shutdown. - // Verify track does not forward to posthog: - track("test_event"); - expect(mockCapture).not.toHaveBeenCalled(); - }); - }); - - describe("shutdownAnalytics", () => { - it("calls opt_out_capturing and reset on the posthog instance", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - shutdownAnalytics(); - expect(mockOptOut).toHaveBeenCalledOnce(); - expect(mockReset).toHaveBeenCalledOnce(); - }); - - it("is safe to call when not initialized", () => { - expect(() => shutdownAnalytics()).not.toThrow(); - }); - - it("is safe to call multiple times", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - shutdownAnalytics(); - expect(() => shutdownAnalytics()).not.toThrow(); - // opt_out and reset only called once (first shutdown had a posthog instance) - expect(mockOptOut).toHaveBeenCalledOnce(); - expect(mockReset).toHaveBeenCalledOnce(); - }); - - it("allows re-initialization after shutdown", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - expect(mockInit).toHaveBeenCalledOnce(); - - shutdownAnalytics(); - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - expect(mockInit).toHaveBeenCalledTimes(2); - }); - - it("swallows exceptions from posthog.opt_out_capturing", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - mockOptOut.mockImplementationOnce(() => { - throw new Error("opt_out boom"); - }); - expect(() => shutdownAnalytics()).not.toThrow(); - }); - - it("swallows exceptions from posthog.reset", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - mockReset.mockImplementationOnce(() => { - throw new Error("reset boom"); - }); - expect(() => shutdownAnalytics()).not.toThrow(); - }); - }); - - describe("setAnalyticsConsent", () => { - it("does not throw when setting consent to true", () => { - expect(() => setAnalyticsConsent(true)).not.toThrow(); - }); - - it("does not throw when setting consent to false", () => { - expect(() => setAnalyticsConsent(false)).not.toThrow(); - }); - - it("can be toggled multiple times", () => { - expect(() => { - setAnalyticsConsent(true); - setAnalyticsConsent(false); - setAnalyticsConsent(true); - }).not.toThrow(); - }); - - it("triggers shutdown when set to false after init", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - setAnalyticsConsent(false); - expect(mockOptOut).toHaveBeenCalledOnce(); - expect(mockReset).toHaveBeenCalledOnce(); - }); - - it("does not trigger shutdown when set to true", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - mockOptOut.mockClear(); - mockReset.mockClear(); - setAnalyticsConsent(true); - expect(mockOptOut).not.toHaveBeenCalled(); - expect(mockReset).not.toHaveBeenCalled(); - }); }); describe("track", () => { - it("does not call capture without consent", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - mockCapture.mockClear(); - setAnalyticsConsent(false); - // Re-init to have a posthog instance for the next consent grant - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - setAnalyticsConsent(false); - track("blocked_event"); - expect(mockCapture).not.toHaveBeenCalled(); - }); - - it("calls capture with consent", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - track("tool_used", { tool: "resize" }); + it("calls capture when initialized", async () => { + await mod.initAnalytics(enabledConfig); + mod.track("tool_used", { tool: "resize" }); expect(mockCapture).toHaveBeenCalledWith("tool_used", { tool: "resize" }); }); it("works without properties", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - track("simple_event"); + await mod.initAnalytics(enabledConfig); + mod.track("simple_event"); expect(mockCapture).toHaveBeenCalledWith("simple_event", undefined); }); it("does not throw before initialization", () => { - setAnalyticsConsent(true); - expect(() => track("pre_init_event")).not.toThrow(); + expect(() => mod.track("pre_init_event")).not.toThrow(); }); it("swallows exceptions from posthog.capture", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); + await mod.initAnalytics(enabledConfig); mockCapture.mockImplementationOnce(() => { throw new Error("capture boom"); }); - expect(() => track("should_not_throw")).not.toThrow(); - }); - }); - - describe("identify", () => { - it("does not call posthog.identify without consent", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - setAnalyticsConsent(false); - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - setAnalyticsConsent(false); - mockIdentify.mockClear(); - identify("blocked-id", {}); - expect(mockIdentify).not.toHaveBeenCalled(); + expect(() => mod.track("should_not_throw")).not.toThrow(); }); - it("calls posthog.identify with consent", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - identify("inst-1", { version: "1.0" }); - expect(mockIdentify).toHaveBeenCalledWith("inst-1", { version: "1.0" }, undefined); - }); - - it("does not throw before initialization", () => { - setAnalyticsConsent(true); - expect(() => identify("inst-1", {})).not.toThrow(); - }); - - it("swallows exceptions from posthog.identify", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - mockIdentify.mockImplementationOnce(() => { - throw new Error("identify boom"); - }); - expect(() => identify("inst-x", { foo: "bar" })).not.toThrow(); - }); - }); - - describe("startErrorReplay", () => { - it("does not call startSessionRecording without consent", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - setAnalyticsConsent(false); - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - setAnalyticsConsent(false); - mockStartSessionRecording.mockClear(); - startErrorReplay(); - expect(mockStartSessionRecording).not.toHaveBeenCalled(); - }); - - it("calls startSessionRecording with consent", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - startErrorReplay(); - expect(mockStartSessionRecording).toHaveBeenCalledOnce(); - }); - - it("does not throw before initialization", () => { - setAnalyticsConsent(true); - expect(() => startErrorReplay()).not.toThrow(); - }); - - it("swallows exceptions from posthog.startSessionRecording", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - mockStartSessionRecording.mockImplementationOnce(() => { - throw new Error("replay boom"); - }); - expect(() => startErrorReplay()).not.toThrow(); - }); - }); - - describe("full consent lifecycle", () => { - it("accept -> use -> revoke -> silent -> re-accept -> use", async () => { - // Phase 1: Accept and use analytics - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - expect(mockInit).toHaveBeenCalledOnce(); - - track("phase1_event"); - expect(mockCapture).toHaveBeenCalledWith("phase1_event", undefined); - identify("inst-1", { phase: 1 }); - expect(mockIdentify).toHaveBeenCalledWith("inst-1", { phase: 1 }, undefined); - - // Phase 2: Revoke consent mid-session - setAnalyticsConsent(false); - expect(mockOptOut).toHaveBeenCalledOnce(); - expect(mockReset).toHaveBeenCalledOnce(); - - mockCapture.mockClear(); - mockIdentify.mockClear(); - track("phase2_blocked"); - identify("inst-1", { phase: 2 }); - expect(mockCapture).not.toHaveBeenCalled(); - expect(mockIdentify).not.toHaveBeenCalled(); - - // Phase 3: Re-accept consent - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); - expect(mockInit).toHaveBeenCalledTimes(2); - - track("phase3_event"); - expect(mockCapture).toHaveBeenCalledWith("phase3_event", undefined); - }); - - it("server disabled overrides user consent", async () => { - setAnalyticsConsent(true); - await initAnalytics(disabledConfig); - expect(mockInit).not.toHaveBeenCalled(); - - track("should_not_fire"); + it("is silent when config was disabled", async () => { + await mod.initAnalytics(disabledConfig); + mod.track("blocked_event"); expect(mockCapture).not.toHaveBeenCalled(); }); + }); - it("rapid consent toggles do not corrupt state", async () => { - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); + describe("getDistinctId", () => { + it("returns null before initialization", () => { + expect(mod.getDistinctId()).toBeNull(); + }); - setAnalyticsConsent(false); - setAnalyticsConsent(true); - setAnalyticsConsent(false); - setAnalyticsConsent(true); - - // After rapid toggles ending on true, SDK was shut down multiple times. - // Re-init should work cleanly. - await initAnalytics(enabledConfig); - track("after_rapid_toggle"); - expect(mockCapture).toHaveBeenCalledWith("after_rapid_toggle", undefined); + it("returns distinct ID after initialization", async () => { + await mod.initAnalytics(enabledConfig); + expect(mod.getDistinctId()).toBe("test-distinct-id"); }); }); describe("Sentry beforeSend callback", () => { async function getBeforeSend() { - shutdownAnalytics(); mockSentryInit.mockClear(); - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); + await mod.initAnalytics(enabledConfig); const sentryCall = mockSentryInit.mock.calls.find((call: unknown[]) => call[0]?.beforeSend); return sentryCall ? sentryCall[0].beforeSend : null; } @@ -483,15 +206,6 @@ describe("analytics lib", () => { expect(result.exception.values[0].stacktrace.frames[0].abs_path).toContain("[REDACTED]"); }); - it("returns null when consent is not granted", async () => { - const beforeSend = await getBeforeSend(); - if (!beforeSend) return; - - setAnalyticsConsent(false); - const result = beforeSend({ exception: { values: [] } }); - expect(result).toBeNull(); - }); - it("handles event without user or exception fields", async () => { const beforeSend = await getBeforeSend(); if (!beforeSend) return; @@ -515,10 +229,8 @@ describe("analytics lib", () => { describe("Sentry beforeBreadcrumb callback", () => { async function getBeforeBreadcrumb() { - shutdownAnalytics(); mockSentryInit.mockClear(); - setAnalyticsConsent(true); - await initAnalytics(enabledConfig); + await mod.initAnalytics(enabledConfig); const sentryCall = mockSentryInit.mock.calls.find( (call: unknown[]) => call[0]?.beforeBreadcrumb, ); @@ -557,15 +269,6 @@ describe("analytics lib", () => { expect(result.message).toContain("[REDACTED]"); }); - it("returns null when consent is not granted", async () => { - const beforeBreadcrumb = await getBeforeBreadcrumb(); - if (!beforeBreadcrumb) return; - - setAnalyticsConsent(false); - const result = beforeBreadcrumb({ category: "console", message: "test" }); - expect(result).toBeNull(); - }); - it("passes through fetch breadcrumbs without file extension URLs", async () => { const beforeBreadcrumb = await getBeforeBreadcrumb(); if (!beforeBreadcrumb) return; diff --git a/tests/unit/web/api-extended.test.ts b/tests/unit/web/api-extended.test.ts index efd9bd94..3888c158 100644 --- a/tests/unit/web/api-extended.test.ts +++ b/tests/unit/web/api-extended.test.ts @@ -136,31 +136,6 @@ describe("formatHeaders", () => { expect(headers.get("Authorization")).toBeNull(); }); - it("includes analytics consent header when no token and consent is set to true", () => { - storageMap.set("snapotter-analytics-consent", "true"); - const headers = formatHeaders(); - expect(headers.get("X-Analytics-Consent")).toBe("true"); - }); - - it("includes analytics consent header when consent is false", () => { - storageMap.set("snapotter-analytics-consent", "false"); - const headers = formatHeaders(); - expect(headers.get("X-Analytics-Consent")).toBe("false"); - }); - - it("does not include analytics consent header when token exists", () => { - storageMap.set("snapotter-token", "tok"); - storageMap.set("snapotter-analytics-consent", "true"); - const headers = formatHeaders(); - expect(headers.get("X-Analytics-Consent")).toBeNull(); - }); - - it("does not include analytics consent header when consent is not true/false", () => { - storageMap.set("snapotter-analytics-consent", "remind"); - const headers = formatHeaders(); - expect(headers.get("X-Analytics-Consent")).toBeNull(); - }); - it("merges provided HeadersInit with auth headers", () => { storageMap.set("snapotter-token", "tok"); const headers = formatHeaders({ "Content-Type": "application/json" }); diff --git a/tests/unit/web/i18n-locale.test.ts b/tests/unit/web/i18n-locale.test.ts index 15263c2e..ea794ef0 100644 --- a/tests/unit/web/i18n-locale.test.ts +++ b/tests/unit/web/i18n-locale.test.ts @@ -62,7 +62,6 @@ describe("en translation completeness", () => { "features", "help", "errors", - "analytics", "sidebar", "toolCard", "appLayout", diff --git a/tests/unit/web/zustand-stores.test.ts b/tests/unit/web/zustand-stores.test.ts index 9f44787b..c3a983ef 100644 --- a/tests/unit/web/zustand-stores.test.ts +++ b/tests/unit/web/zustand-stores.test.ts @@ -2694,35 +2694,13 @@ describe("useFeaturesStore", () => { // AnalyticsStore // ========================================================================== -// Mock the analytics lib to avoid importing Sentry/PostHog -vi.mock("@/lib/analytics", () => ({ - setAnalyticsConsent: vi.fn(), - initAnalytics: vi.fn(), - identify: vi.fn(), - trackEvent: vi.fn(), - trackPageView: vi.fn(), -})); - -import { setAnalyticsConsent } from "@/lib/analytics"; -import { apiPut } from "@/lib/api"; import { useAnalyticsStore } from "@/stores/analytics-store"; -const mockApiPut = vi.mocked(apiPut); -const mockSetAnalyticsConsent = vi.mocked(setAnalyticsConsent); - describe("useAnalyticsStore", () => { beforeEach(() => { fetchMock.mockReset(); - mockApiPut.mockReset(); - mockSetAnalyticsConsent.mockClear(); - storageMap.clear(); useAnalyticsStore.setState({ config: null, - consent: { - analyticsEnabled: null, - analyticsConsentShownAt: null, - analyticsConsentRemindAt: null, - }, configLoaded: false, }); }); @@ -2730,9 +2708,6 @@ describe("useAnalyticsStore", () => { it("has correct initial state", () => { const s = useAnalyticsStore.getState(); expect(s.config).toBeNull(); - expect(s.consent.analyticsEnabled).toBeNull(); - expect(s.consent.analyticsConsentShownAt).toBeNull(); - expect(s.consent.analyticsConsentRemindAt).toBeNull(); expect(s.configLoaded).toBe(false); }); @@ -2769,108 +2744,4 @@ describe("useAnalyticsStore", () => { expect(useAnalyticsStore.getState().configLoaded).toBe(true); expect(useAnalyticsStore.getState().config).toBeNull(); }); - - it("setConsent updates consent and calls setAnalyticsConsent", () => { - const consent = { - analyticsEnabled: true, - analyticsConsentShownAt: 1000, - analyticsConsentRemindAt: null, - }; - useAnalyticsStore.getState().setConsent(consent); - - expect(useAnalyticsStore.getState().consent).toEqual(consent); - expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(true); - }); - - it("acceptAnalytics calls API and sets consent to true", async () => { - mockApiPut.mockResolvedValueOnce({}); - - await useAnalyticsStore.getState().acceptAnalytics(); - - const s = useAnalyticsStore.getState(); - expect(s.consent.analyticsEnabled).toBe(true); - expect(s.consent.analyticsConsentShownAt).toBeTypeOf("number"); - expect(s.consent.analyticsConsentRemindAt).toBeNull(); - expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(true); - }); - - it("acceptAnalytics falls back to localStorage on API error", async () => { - mockApiPut.mockRejectedValueOnce(new Error("Server error")); - - await useAnalyticsStore.getState().acceptAnalytics(); - - expect(localStorage.setItem).toHaveBeenCalledWith("snapotter-analytics-consent", "true"); - expect(useAnalyticsStore.getState().consent.analyticsEnabled).toBe(true); - }); - - it("declineAnalytics calls API and sets consent to false", async () => { - mockApiPut.mockResolvedValueOnce({}); - - await useAnalyticsStore.getState().declineAnalytics(); - - const s = useAnalyticsStore.getState(); - expect(s.consent.analyticsEnabled).toBe(false); - expect(s.consent.analyticsConsentShownAt).toBeTypeOf("number"); - expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(false); - }); - - it("declineAnalytics falls back to localStorage on API error", async () => { - mockApiPut.mockRejectedValueOnce(new Error("Server error")); - - await useAnalyticsStore.getState().declineAnalytics(); - - expect(localStorage.setItem).toHaveBeenCalledWith("snapotter-analytics-consent", "false"); - }); - - it("remindLater calls API and sets remind-at 7 days in the future", async () => { - mockApiPut.mockResolvedValueOnce({}); - - const before = Date.now(); - await useAnalyticsStore.getState().remindLater(); - const after = Date.now(); - - const s = useAnalyticsStore.getState(); - expect(s.consent.analyticsEnabled).toBeNull(); - expect(s.consent.analyticsConsentRemindAt).toBeTypeOf("number"); - // Remind-at should be approximately 7 days from now - const sevenDays = 7 * 24 * 60 * 60 * 1000; - expect(s.consent.analyticsConsentRemindAt!).toBeGreaterThanOrEqual(before + sevenDays); - expect(s.consent.analyticsConsentRemindAt!).toBeLessThanOrEqual(after + sevenDays); - expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(false); - }); - - it("remindLater falls back to localStorage on API error", async () => { - mockApiPut.mockRejectedValueOnce(new Error("Server error")); - - await useAnalyticsStore.getState().remindLater(); - - expect(localStorage.setItem).toHaveBeenCalledWith("snapotter-analytics-consent", "remind"); - }); - - it("toggleAnalytics enables analytics", async () => { - mockApiPut.mockResolvedValueOnce({}); - - await useAnalyticsStore.getState().toggleAnalytics(true); - - expect(useAnalyticsStore.getState().consent.analyticsEnabled).toBe(true); - expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(true); - }); - - it("toggleAnalytics disables analytics", async () => { - mockApiPut.mockResolvedValueOnce({}); - - await useAnalyticsStore.getState().toggleAnalytics(false); - - expect(useAnalyticsStore.getState().consent.analyticsEnabled).toBe(false); - expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(false); - }); - - it("toggleAnalytics falls back to localStorage on API error", async () => { - mockApiPut.mockRejectedValueOnce(new Error("Server error")); - - await useAnalyticsStore.getState().toggleAnalytics(true); - - expect(localStorage.setItem).toHaveBeenCalledWith("snapotter-analytics-consent", "true"); - expect(useAnalyticsStore.getState().consent.analyticsEnabled).toBe(true); - }); });