mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(analytics): build-time bake + telemetry depth (#336)
Bake PostHog + Sentry into the published Docker image (SNAPOTTER_ANALYTICS build arg, codegen script). Delete entire consent system. Move event emission to BullMQ worker. Add cross-tier identity stitching, Sentry performance tracing on both tiers, frontend funnel events. Fix stateful regex bug. 86 files changed, 1593 insertions(+), 3747 deletions(-)
This commit is contained in:
@@ -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";
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@
|
||||
"when": 1781359444186,
|
||||
"tag": "0004_handy_warhawk",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1782266352290,
|
||||
"tag": "0005_special_exodus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export interface ToolJobData {
|
||||
parentId?: string;
|
||||
totalFiles?: number;
|
||||
fileIndex?: number;
|
||||
analyticsDistinctId?: string;
|
||||
_otel?: { traceparent: string; tracestate?: string };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ToolJobData>): Promise<ToolJobResult> {
|
||||
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<ToolJobData>): Promise<ToolJobResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<ToolJobData>): Promise<ToolJobResult> {
|
||||
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<ToolJobData>): Promise<ToolJobRe
|
||||
await recordChildOutcome(data.parentId, data.totalFiles, data.filename, errorMsg);
|
||||
}
|
||||
|
||||
// Analytics: emit pipeline_executed on failure
|
||||
if (ANALYTICS_BAKED.enabled) {
|
||||
void trackEvent(
|
||||
ANALYTICS_EVENTS.PIPELINE_EXECUTED,
|
||||
{
|
||||
step_count: totalSteps,
|
||||
tool_ids: steps.map((s) => 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<ToolJobData>): Promise<ToolJobRe
|
||||
await recordChildOutcome(data.parentId, data.totalFiles, outFilename);
|
||||
}
|
||||
|
||||
// Analytics: emit pipeline_executed on success
|
||||
if (ANALYTICS_BAKED.enabled) {
|
||||
void trackEvent(
|
||||
ANALYTICS_EVENTS.PIPELINE_EXECUTED,
|
||||
{
|
||||
step_count: totalSteps,
|
||||
tool_ids: steps.map((s) => 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);
|
||||
}
|
||||
|
||||
|
||||
+19
-105
@@ -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<void> {
|
||||
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<void> {
|
||||
export async function captureException(error: unknown): Promise<void> {
|
||||
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<void> {
|
||||
await posthogClient.shutdown();
|
||||
posthogClient = null;
|
||||
}
|
||||
|
||||
if (sentryModule) {
|
||||
await sentryModule.close(2000);
|
||||
sentryModule = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getInstanceId(): Promise<string> {
|
||||
@@ -112,39 +47,18 @@ async function getInstanceId(): Promise<string> {
|
||||
return row?.value ?? "unknown";
|
||||
}
|
||||
|
||||
async function isUserOptedIn(userId: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<string, unknown>,
|
||||
distinctId?: string,
|
||||
): Promise<void> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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(""),
|
||||
})
|
||||
|
||||
@@ -435,9 +435,6 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -350,9 +350,6 @@ export async function registerMfa(app: FastifyInstance): Promise<void> {
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,6 +304,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -165,7 +165,6 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
|
||||
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<void>
|
||||
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<void>
|
||||
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<void>
|
||||
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,
|
||||
|
||||
@@ -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<void
|
||||
|
||||
// ── Enqueue as a BullMQ flow ────────────────────────────────
|
||||
|
||||
const startTime = Date.now();
|
||||
const jobId = randomUUID();
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const originalSize = fileBuffer.length;
|
||||
@@ -457,6 +460,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
filename,
|
||||
pipelinePool,
|
||||
clientJobId: clientJobId ?? jobId,
|
||||
analyticsDistinctId: request.headers["x-posthog-distinct-id"] as string | undefined,
|
||||
});
|
||||
|
||||
// Insert all durable rows before adding the flow. enqueueToolJob
|
||||
@@ -497,13 +501,6 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
const result = await waitForJob(pipelinePool, jobId, 10 * 60_000);
|
||||
|
||||
if (!result) {
|
||||
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: "failed",
|
||||
});
|
||||
return reply.status(422).send({
|
||||
error: "Pipeline processing timed out",
|
||||
});
|
||||
@@ -511,27 +508,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
|
||||
// Check for step failure reported by the finalize handler
|
||||
if (result.resultPayload?.error) {
|
||||
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: "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<void
|
||||
steps: result.resultPayload?.steps ?? [],
|
||||
});
|
||||
} catch (err) {
|
||||
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: "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<void
|
||||
}
|
||||
|
||||
// ── Prepare files and build flow ─────────────────────────────────
|
||||
const batchStartTime = Date.now();
|
||||
const parentId = clientJobId || randomUUID();
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
|
||||
@@ -990,6 +964,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
pipelinePool: batchPipelinePool,
|
||||
parentId,
|
||||
totalFiles: files.length,
|
||||
analyticsDistinctId: request.headers["x-posthog-distinct-id"] as string | undefined,
|
||||
});
|
||||
|
||||
// Insert step + finalize rows for this file
|
||||
@@ -1028,14 +1003,6 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
|
||||
if (perFileChildren.length === 0) {
|
||||
// All files failed validation
|
||||
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: preFailures.map((f) => ({ filename: f.filename, error: f.error })),
|
||||
@@ -1056,6 +1023,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
inputRefs: [],
|
||||
filename: "",
|
||||
settings: { flowChildCount: perFileChildren.length },
|
||||
analyticsDistinctId: request.headers["x-posthog-distinct-id"] as string | undefined,
|
||||
} satisfies ToolJobData,
|
||||
opts: { jobId: parentId, attempts: 1 },
|
||||
children: perFileChildren,
|
||||
@@ -1076,14 +1044,6 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
const batchResult = await waitForJob("system", parentId, 30 * 60_000);
|
||||
|
||||
if (!batchResult) {
|
||||
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: "Pipeline batch processing timed out" });
|
||||
}
|
||||
|
||||
@@ -1150,29 +1110,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
const failedEntries = allResults.filter((r) => !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<string, string> = {};
|
||||
for (const entry of successEntries) {
|
||||
const uniqueName = getUniqueName(entry.filename);
|
||||
|
||||
@@ -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<T>(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<T>(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<T>(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");
|
||||
|
||||
Reference in New Issue
Block a user