From 07b26b530f541d9957a7ca525d5bf29e3fc8109d Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 17 Aug 2026 18:16:44 +0800 Subject: [PATCH] fix(analytics): remove TS product events table (#2306) --- .../stage-ui/src/composables/use-analytics.ts | 8 +- .../api/drizzle/0022_worthless_shriek.sql | 1 + .../apps/api/drizzle/meta/0022_snapshot.json | 3541 +++++++++++++++++ server/apps/api/drizzle/meta/_journal.json | 7 + server/apps/api/src/app.test.ts | 1 - server/apps/api/src/app.ts | 11 +- server/apps/api/src/libs/env.ts | 5 +- server/apps/api/src/otel/index.ts | 26 +- .../src/routes/audio-speech-ws/route.test.ts | 32 +- .../api/src/routes/audio-speech-ws/session.ts | 137 +- .../api/src/routes/audio-speech-ws/types.ts | 3 - server/apps/api/src/routes/internal-auth.ts | 4 +- .../v1/operations/chat-completions/index.ts | 107 - .../v1/operations/speech-generation/index.ts | 1 - .../api/src/routes/openai/v1/route.test.ts | 74 +- .../src/routes/stripe/operations/checkout.ts | 1 + .../src/routes/stripe/operations/webhook.ts | 43 +- .../apps/api/src/routes/stripe/route.test.ts | 45 +- server/apps/api/src/schemas/index.ts | 1 - server/apps/api/src/schemas/product-events.ts | 32 - .../apps/api/src/services/adapters/posthog.ts | 9 +- server/apps/api/src/services/domain/chats.ts | 14 +- .../services/domain/openai-speech/index.ts | 131 +- .../services/domain/product-events.test.ts | 394 +- .../api/src/services/domain/product-events.ts | 247 +- server/apps/api/src/utils/observability.ts | 5 - server/apps/auth/src/auth.ts | 5 - server/apps/auth/src/resource-api.ts | 4 +- 28 files changed, 3791 insertions(+), 1098 deletions(-) create mode 100644 server/apps/api/drizzle/0022_worthless_shriek.sql create mode 100644 server/apps/api/drizzle/meta/0022_snapshot.json delete mode 100644 server/apps/api/src/schemas/product-events.ts diff --git a/packages/stage-ui/src/composables/use-analytics.ts b/packages/stage-ui/src/composables/use-analytics.ts index 6f34e2d11..3927bec9c 100644 --- a/packages/stage-ui/src/composables/use-analytics.ts +++ b/packages/stage-ui/src/composables/use-analytics.ts @@ -351,11 +351,9 @@ export function useAnalytics() { } // ─── LLM round events (client-known fields only) ────────────────────── - // Source-of-truth for HTTP status / token usage / billing stage is the - // server, which records them as - // Postgres `product_events` rows — deliberately NOT forwarded to PostHog - // (per-request volume stays in DB/Grafana). These client emits supply the - // user-facing latency picture (TTFT, render time) the server cannot see. + // The server owns HTTP status, token usage, and billing state. It records + // these request-level facts in operational telemetry, not product analytics. + // These client events supply latency data the server cannot observe. function trackMessageSendStarted(properties: ChatRoundCorrelationProperties & { source: 'text' | 'voice', model?: string }) { if (!canCapture()) diff --git a/server/apps/api/drizzle/0022_worthless_shriek.sql b/server/apps/api/drizzle/0022_worthless_shriek.sql new file mode 100644 index 000000000..d22a39908 --- /dev/null +++ b/server/apps/api/drizzle/0022_worthless_shriek.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS "product_events"; diff --git a/server/apps/api/drizzle/meta/0022_snapshot.json b/server/apps/api/drizzle/meta/0022_snapshot.json new file mode 100644 index 000000000..b4c194717 --- /dev/null +++ b/server/apps/api/drizzle/meta/0022_snapshot.json @@ -0,0 +1,3541 @@ +{ + "id": "6043d3c0-26ec-45d8-ab06-33142316efbc", + "prevId": "3ce78f81-307c-4877-b9d3-caf822ae0f20", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.avatar_model": { + "name": "avatar_model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "avatar_model_character_id_characters_id_fk": { + "name": "avatar_model_character_id_characters_id_fk", + "tableFrom": "avatar_model", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_role": { + "name": "creator_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_credit": { + "name": "price_credit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bookmarks_count": { + "name": "bookmarks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "interactions_count": { + "name": "interactions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_count": { + "name": "forks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_capabilities": { + "name": "character_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_capabilities_character_id_characters_id_fk": { + "name": "character_capabilities_character_id_characters_id_fk", + "tableFrom": "character_capabilities", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_covers": { + "name": "character_covers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "foreground_url": { + "name": "foreground_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "background_url": { + "name": "background_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_covers_character_id_characters_id_fk": { + "name": "character_covers_character_id_characters_id_fk", + "tableFrom": "character_covers", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_i18n": { + "name": "character_i18n", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_i18n_character_id_characters_id_fk": { + "name": "character_i18n_character_id_characters_id_fk", + "tableFrom": "character_i18n", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_prompts": { + "name": "character_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_prompts_character_id_characters_id_fk": { + "name": "character_prompts_character_id_characters_id_fk", + "tableFrom": "character_prompts", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_members": { + "name": "chat_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_type": { + "name": "member_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_members_user_id_member_type_chat_id_idx": { + "name": "chat_members_user_id_member_type_chat_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_members_chat_id_member_type_user_id_idx": { + "name": "chat_members_chat_id_member_type_user_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_members_chat_id_chats_id_fk": { + "name": "chat_members_chat_id_chats_id_fk", + "tableFrom": "chat_members", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_ids": { + "name": "media_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "sticker_ids": { + "name": "sticker_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "reply_message_id": { + "name": "reply_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forward_from_message_id": { + "name": "forward_from_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "messages_chat_id_seq_idx": { + "name": "messages_chat_id_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_chat_id_seq_active_idx": { + "name": "messages_chat_id_seq_active_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sticker_packs": { + "name": "sticker_packs", + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stickers": { + "name": "stickers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.config_kv": { + "name": "config_kv", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flux_transaction": { + "name": "flux_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_before": { + "name": "balance_before", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flux_tx_user_id_idx": { + "name": "flux_tx_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_created_at_idx": { + "name": "flux_tx_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_user_request_uniq": { + "name": "flux_tx_user_request_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "request_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_flux": { + "name": "user_flux", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "flux": { + "name": "flux", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_request_log": { + "name": "llm_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "flux_consumed": { + "name": "flux_consumed", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.capability_alias_routes": { + "name": "capability_alias_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alias_id": { + "name": "alias_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "router_model_id": { + "name": "router_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pool": { + "name": "pool", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'primary'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "capability_alias_routes_alias_model_pool_uidx": { + "name": "capability_alias_routes_alias_model_pool_uidx", + "columns": [ + { + "expression": "alias_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "router_model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pool", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "capability_alias_routes_alias_id_capability_aliases_id_fk": { + "name": "capability_alias_routes_alias_id_capability_aliases_id_fk", + "tableFrom": "capability_alias_routes", + "tableTo": "capability_aliases", + "columnsFrom": [ + "alias_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.capability_aliases": { + "name": "capability_aliases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alias_id": { + "name": "alias_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fallback_enabled": { + "name": "fallback_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "load_balancing_enabled": { + "name": "load_balancing_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "capability_aliases_surface_alias_uidx": { + "name": "capability_aliases_surface_alias_uidx", + "columns": [ + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "alias_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_catalog_tts_models": { + "name": "provider_catalog_tts_models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "router_model_id": { + "name": "router_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_catalog_tts_models_router_model_uidx": { + "name": "provider_catalog_tts_models_router_model_uidx", + "columns": [ + { + "expression": "router_model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_catalog_tts_voices": { + "name": "provider_catalog_tts_voices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tts_model_id": { + "name": "tts_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_voice_id": { + "name": "provider_voice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "languages": { + "name": "languages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "preview_audio_url": { + "name": "preview_audio_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'provider-sync'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_catalog_tts_voices_model_voice_uidx": { + "name": "provider_catalog_tts_voices_model_voice_uidx", + "columns": [ + { + "expression": "tts_model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_voice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_catalog_tts_voices_tts_model_id_provider_catalog_tts_models_id_fk": { + "name": "provider_catalog_tts_voices_tts_model_id_provider_catalog_tts_models_id_fk", + "tableFrom": "provider_catalog_tts_voices", + "tableTo": "provider_catalog_tts_models", + "columnsFrom": [ + "tts_model_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_provider_configs": { + "name": "system_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_provider_configs": { + "name": "user_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_checkout_session": { + "name": "stripe_checkout_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_session_id": { + "name": "stripe_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_total": { + "name": "amount_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success_url": { + "name": "success_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_url": { + "name": "cancel_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_checkout_session_stripe_session_id_unique": { + "name": "stripe_checkout_session_stripe_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_customer": { + "name": "stripe_customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_customer_stripe_customer_id_unique": { + "name": "stripe_customer_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_invoice": { + "name": "stripe_invoice", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_due": { + "name": "amount_due", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amount_paid": { + "name": "amount_paid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_url": { + "name": "invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_pdf": { + "name": "invoice_pdf", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_invoice_stripe_invoice_id_unique": { + "name": "stripe_invoice_stripe_invoice_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_invoice_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_subscription": { + "name": "stripe_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_subscription_stripe_subscription_id_unique": { + "name": "stripe_subscription_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_bookmarks": { + "name": "user_character_bookmarks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_bookmarks_character_id_characters_id_fk": { + "name": "user_character_bookmarks_character_id_characters_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_bookmarks_user_id_character_id_pk": { + "name": "user_character_bookmarks_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_likes": { + "name": "user_character_likes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_likes_character_id_characters_id_fk": { + "name": "user_character_likes_character_id_characters_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_likes_user_id_character_id_pk": { + "name": "user_character_likes_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_packs": { + "name": "voice_packs", + "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": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voice_id": { + "name": "voice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upstream_voice_id": { + "name": "upstream_voice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tts_model_id": { + "name": "tts_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_account_id_provider_id_idx": { + "name": "account_account_id_provider_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_token_idx": { + "name": "oauth_refresh_token_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_id_idx": { + "name": "oauth_refresh_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_expires_at_idx": { + "name": "session_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/apps/api/drizzle/meta/_journal.json b/server/apps/api/drizzle/meta/_journal.json index d99e6648f..fc7dc513f 100644 --- a/server/apps/api/drizzle/meta/_journal.json +++ b/server/apps/api/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1786864179375, "tag": "0021_chilly_starjammers", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1786957404391, + "tag": "0022_worthless_shriek", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/apps/api/src/app.test.ts b/server/apps/api/src/app.test.ts index f12b4bdbe..c3a11c8d0 100644 --- a/server/apps/api/src/app.test.ts +++ b/server/apps/api/src/app.test.ts @@ -29,7 +29,6 @@ function createTestDeps() { productEventService: { track: vi.fn(async () => undefined), trackGeneration: vi.fn(async () => undefined), - countDistinctUsersByFeature: vi.fn(async () => []), } as never, configKV: { getOrThrow: vi.fn() } as never, redis: redis as never, diff --git a/server/apps/api/src/app.ts b/server/apps/api/src/app.ts index 93c0a4131..463eb4a11 100644 --- a/server/apps/api/src/app.ts +++ b/server/apps/api/src/app.ts @@ -175,7 +175,6 @@ export async function buildApp(deps: AppDeps) { fluxService: deps.fluxService, ttsMeter: deps.ttsMeter, requestLogService: deps.requestLogService, - productEventService: deps.productEventService, }) app.get('/api/v1/audio/speech/ws', upgradeWebSocket(async (c) => { const token = c.req.query('token') @@ -389,7 +388,7 @@ function parseTtsSource( } /** - * Normalizes the client-provided streaming TTS voice bucket for product events. + * Normalizes the client-provided streaming TTS voice bucket for request telemetry. */ function parseTtsVoiceType( value: string | undefined, @@ -507,8 +506,8 @@ export async function createApp() { }) const productEventService = injeca.provide('services:productEvents', { - dependsOn: { db, otel, posthogSink }, - build: ({ dependsOn }) => createProductEventService(dependsOn.db, dependsOn.otel?.product, dependsOn.posthogSink), + dependsOn: { posthogSink }, + build: ({ dependsOn }) => createProductEventService(dependsOn.posthogSink), }) const characterService = injeca.provide('services:characters', { @@ -522,8 +521,8 @@ export async function createApp() { }) const chatService = injeca.provide('services:chats', { - dependsOn: { db, otel, productEventService }, - build: ({ dependsOn }) => createChatService(dependsOn.db, dependsOn.otel?.engagement, dependsOn.productEventService), + dependsOn: { db, otel }, + build: ({ dependsOn }) => createChatService(dependsOn.db, dependsOn.otel?.engagement), }) const stripeService = injeca.provide('services:stripe', { diff --git a/server/apps/api/src/libs/env.ts b/server/apps/api/src/libs/env.ts index 3b63da649..55509fe1a 100644 --- a/server/apps/api/src/libs/env.ts +++ b/server/apps/api/src/libs/env.ts @@ -114,11 +114,10 @@ const EnvSchema = object({ DB_POOL_CONNECTION_TIMEOUT_MS: optionalIntegerFromString(5000, 'DB_POOL_CONNECTION_TIMEOUT_MS', 1), DB_POOL_KEEPALIVE_INITIAL_DELAY_MS: optionalIntegerFromString(10000, 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS', 1), - // PostHog product-event forwarding (signup / payment / subscription facts). + // PostHog product-event forwarding for server-confirmed funnel facts. // Defaults to the shared AIRI project key (same browser-safe phc_* key the // client surfaces embed in posthog.config.ts), so forwarding is on out of - // the box. Set to an empty string to disable; Postgres `product_events` - // stays the source of truth either way. + // the box. Set to an empty string to disable server-side product analytics. POSTHOG_PROJECT_KEY: optional(string(), 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9'), // cspell:disable-line POSTHOG_API_HOST: optional(string(), 'https://t.airi.build'), diff --git a/server/apps/api/src/otel/index.ts b/server/apps/api/src/otel/index.ts index 861ec528d..d03702db4 100644 --- a/server/apps/api/src/otel/index.ts +++ b/server/apps/api/src/otel/index.ts @@ -32,7 +32,6 @@ import { METRIC_AIRI_GEN_AI_GATEWAY_UPSTREAM_ERRORS, METRIC_AIRI_GEN_AI_STREAM_INTERRUPTED, METRIC_AIRI_OBSERVABILITY_READ_ERRORS, - METRIC_AIRI_PRODUCT_EVENTS, METRIC_AIRI_RATE_LIMIT_BLOCKED, METRIC_AIRI_STRIPE_REVENUE, METRIC_AIRI_TTS_CHARS, @@ -256,21 +255,6 @@ export interface ObservabilityMetrics { metricReadErrors: Counter } -export interface ProductMetrics { - /** - * Low-cardinality product event counter. - * - * Use when: - * - Reporting feature/event volume in Prometheus and Grafana. - * - * Expects: - * - Labels stay bounded (`feature`, `action`, `status`, optional - * `source`). Never attach `user_id`, `session_id`, request ids, models - * with unbounded aliases, or free-form error messages here. - */ - events: Counter -} - export interface OtelInstance { auth: AuthMetrics engagement: EngagementMetrics @@ -280,7 +264,6 @@ export interface OtelInstance { email: EmailMetrics rateLimit: RateLimitMetrics observability: ObservabilityMetrics - product: ProductMetrics } /** @@ -481,12 +464,6 @@ export function initOtel(env: Env): OtelInstance | null { }), } - const product: ProductMetrics = { - events: meter.createCounter(METRIC_AIRI_PRODUCT_EVENTS, { - description: 'Low-cardinality product event volume. Distinct users live in Postgres product_events, not Prometheus labels.', - }), - } - // NOTICE: // OTel SDK only emits a Counter time series after .add() runs the first time. // Without this priming step, low-traffic counters (auth_failures_total, @@ -535,11 +512,10 @@ export function initOtel(env: Env): OtelInstance | null { email.failures, rateLimit.blocked, observability.metricReadErrors, - product.events, ] for (const counter of counters) counter.add(0) - return { auth, engagement, revenue, genAi, gateway, email, rateLimit, observability, product } + return { auth, engagement, revenue, genAi, gateway, email, rateLimit, observability } } const severityMap: Record = { diff --git a/server/apps/api/src/routes/audio-speech-ws/route.test.ts b/server/apps/api/src/routes/audio-speech-ws/route.test.ts index 16bfc0429..6cff96795 100644 --- a/server/apps/api/src/routes/audio-speech-ws/route.test.ts +++ b/server/apps/api/src/routes/audio-speech-ws/route.test.ts @@ -184,11 +184,6 @@ function makeFakeDeps(overrides: { const requestLogService = { logRequest: vi.fn(async () => undefined), } - const productEventService = { - track: vi.fn(async () => undefined), - trackGeneration: vi.fn(async () => undefined), - countDistinctUsersByFeature: vi.fn(async () => []), - } const configKV = { getOptional: vi.fn(async (key: string) => { if (key === 'UNSPEECH_UPSTREAM') { @@ -212,7 +207,7 @@ function makeFakeDeps(overrides: { decryptKey: vi.fn(() => Buffer.from(overrides.decryptedKey ?? 'mock-upstream-token', 'utf8')), } - return { configKV, envelopeCrypto, fluxService, ttsMeter, requestLogService, productEventService } + return { configKV, envelopeCrypto, fluxService, ttsMeter, requestLogService } } /** Drives the WSEvents lifecycle as if a real client had connected. */ @@ -299,17 +294,6 @@ describe('audio-speech-ws route', () => { status: 200, fluxConsumed: 1, }) - expect(deps.productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ - userId: 'user-123', - feature: 'tts', - action: 'speech_succeeded', - status: 'succeeded', - model: 'volcengine/seed-tts-2.0', - metadata: expect.objectContaining({ - voice_id: 'mock', - voice_type: 'official_selected', - }), - })) }) it('refuses the session with insufficient_flux when the user is broke', async () => { @@ -335,20 +319,6 @@ describe('audio-speech-ws route', () => { }) expect(client.closed).toBe(true) expect(client.closeCode).toBe(1008) - expect(deps.productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ - userId: 'user-broke', - feature: 'tts', - action: 'speech_blocked', - status: 'blocked', - source: 'chat_auto_tts', - reason: 'insufficient_balance', - metadata: expect.objectContaining({ - trigger: 'auto', - block_reason: 'insufficient_balance', - balance_state: 'insufficient', - flux_balance_bucket: 'zero', - }), - })) }) it('refuses with streaming_tts_not_configured when UNSPEECH_UPSTREAM.streaming is empty', async () => { diff --git a/server/apps/api/src/routes/audio-speech-ws/session.ts b/server/apps/api/src/routes/audio-speech-ws/session.ts index 790b9987e..0791ec925 100644 --- a/server/apps/api/src/routes/audio-speech-ws/session.ts +++ b/server/apps/api/src/routes/audio-speech-ws/session.ts @@ -12,7 +12,6 @@ import { useLogger } from '@guiiai/logg' import { context as otelContext, SpanStatusCode, trace } from '@opentelemetry/api' import { ofetch } from 'ofetch' -import { fluxBalanceBucket } from '../../services/domain/flux-balance' import { ApiError } from '../../utils/error' import { nanoid } from '../../utils/id' import { @@ -79,11 +78,10 @@ export interface AudioSpeechSessionAnalytics { export function createSessionState( userId: string, opts: AudioSpeechWsHandlersOptions, - analyticsInput: AudioSpeechSessionAnalytics = {}, + _analyticsInput: AudioSpeechSessionAnalytics = {}, ): AudioSpeechSessionState { const requestId = nanoid() const startedAt = Date.now() - const analytics = normalizeAnalytics(analyticsInput) const span = tracer.startSpan('llm.gateway.tts.stream', { attributes: { [AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'text_to_speech_stream', @@ -99,9 +97,7 @@ export function createSessionState( let startValidationStarted = false let dialStarted = false let totalInputChars = 0 - let preflightFluxBalance: number | undefined let modelLabel = STREAM_MODEL_LABEL_FALLBACK - let voiceLabel: string | undefined /** * Frames the client sent before the upstream finished dialing. Buffered to * avoid silently dropping the `start` frame; flushed in arrival order once @@ -118,19 +114,6 @@ export function createSessionState( return dialStarted = true - void opts.productEventService.track({ - userId, - feature: 'tts', - action: 'speech_requested', - status: 'started', - source: analytics.source, - model: modelLabel, - metadata: { - trigger: analytics.trigger, - ...streamingVoiceMetadata(voiceLabel, analytics.voiceType), - }, - }) - let unspeech: Awaited> try { unspeech = await opts.configKV.getOptional('UNSPEECH_UPSTREAM') @@ -151,7 +134,6 @@ export function createSessionState( // afford the worst-case session. try { const flux = await opts.fluxService.getFlux(userId) - preflightFluxBalance = flux.flux await opts.ttsMeter.assertCanAfford(userId, STREAMING_PREFLIGHT_CHARS_ESTIMATE, flux.flux) } catch (err) { @@ -231,20 +213,6 @@ export function createSessionState( log.withError(err).withFields({ userId }).warn('upstream ws error') span.recordException(err) span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }) - void opts.productEventService.track({ - userId, - feature: 'tts', - action: 'speech_failed', - status: 'failed', - source: analytics.source, - model: modelLabel, - reason: 'upstream_error', - metadata: { - duration_ms: Date.now() - startedAt, - trigger: analytics.trigger, - ...streamingVoiceMetadata(voiceLabel, analytics.voiceType), - }, - }) try { clientWs?.send(JSON.stringify({ event: 'error', @@ -284,7 +252,6 @@ export function createSessionState( startValidationStarted = true modelLabel = startFrame.model - voiceLabel = startFrame.voice pendingClientFrames.push({ data: payload, isBinary }) void validateStartFrame(startFrame).then((accepted) => { if (!accepted || closed) @@ -410,9 +377,6 @@ export function createSessionState( const model = (parsed as Record).model if (typeof model === 'string' && model.length > 0) modelLabel = model - const voice = (parsed as Record).voice - if (typeof voice === 'string' && voice.length > 0) - voiceLabel = voice } } catch { @@ -522,22 +486,6 @@ export function createSessionState( log.withError(err).warn('failed to write request log for streaming tts') } - void opts.productEventService.track({ - userId, - feature: 'tts', - action: 'speech_succeeded', - status: 'succeeded', - source: analytics.source, - model: modelLabel, - metadata: { - input_chars: units, - duration_ms: durationMs, - flux_consumed: fluxConsumed, - trigger: analytics.trigger, - ...streamingVoiceMetadata(voiceLabel, analytics.voiceType), - }, - }) - finalize() } @@ -560,21 +508,6 @@ export function createSessionState( if (closed) return span.setStatus({ code: SpanStatusCode.ERROR, message: reason }) - void opts.productEventService.track({ - userId, - feature: 'tts', - action: 'speech_failed', - status: 'failed', - source: analytics.source, - model: modelLabel, - reason, - metadata: { - close_code: code, - duration_ms: Date.now() - startedAt, - trigger: analytics.trigger, - ...streamingVoiceMetadata(voiceLabel, analytics.voiceType), - }, - }) if (clientWs) { try { clientWs.send(JSON.stringify({ event: 'error', code: reason, message: reason })) @@ -592,25 +525,6 @@ export function createSessionState( function closeWithBlockedPreflight(code: number, reason: string) { if (closed) return - void opts.productEventService.track({ - userId, - feature: 'tts', - action: 'speech_blocked', - status: 'blocked', - source: analytics.source, - model: modelLabel, - reason: 'insufficient_balance', - metadata: { - block_reason: 'insufficient_balance', - balance_state: 'insufficient', - flux_balance_bucket: fluxBalanceBucket(preflightFluxBalance), - billing_units: STREAMING_PREFLIGHT_CHARS_ESTIMATE, - close_code: code, - duration_ms: Date.now() - startedAt, - trigger: analytics.trigger, - ...streamingVoiceMetadata(voiceLabel, analytics.voiceType), - }, - }) if (clientWs) { try { clientWs.send(JSON.stringify({ event: 'error', code: reason, message: reason })) @@ -633,55 +547,6 @@ export function createSessionState( } } -function normalizeAnalytics(input: AudioSpeechSessionAnalytics): Required { - return { - trigger: normalizeTrigger(input.trigger), - source: normalizeSource(input.source), - voiceType: normalizeVoiceType(input.voiceType), - } -} - -function normalizeTrigger(trigger: AudioSpeechSessionAnalytics['trigger']): StreamingTtsTrigger { - return trigger === 'auto' ? 'auto' : 'manual' -} - -function normalizeSource(source: AudioSpeechSessionAnalytics['source']): StreamingTtsSource { - switch (source) { - case 'audio.speech.ws': - case 'chat_auto_tts': - case 'manual_preview': - case 'settings_test': - return source - default: - return 'audio.speech.ws' - } -} - -/** - * Normalizes streaming TTS voice type into bounded analytics values. - */ -function normalizeVoiceType(voiceType: AudioSpeechSessionAnalytics['voiceType']): StreamingTtsVoiceType { - switch (voiceType) { - case 'official_default': - case 'official_selected': - case 'custom_configured': - case 'voice_pack': - return voiceType - default: - return 'unknown' - } -} - -/** - * Builds reusable streaming TTS voice metadata after the start frame is known. - */ -function streamingVoiceMetadata(voiceId: string | undefined, voiceType: StreamingTtsVoiceType): Record { - return { - ...(voiceId ? { voice_id: voiceId } : {}), - voice_type: voiceType, - } -} - function isPaymentRequiredError(err: unknown): boolean { if (err instanceof ApiError) return err.statusCode === 402 diff --git a/server/apps/api/src/routes/audio-speech-ws/types.ts b/server/apps/api/src/routes/audio-speech-ws/types.ts index aea679f25..e3fb19c68 100644 --- a/server/apps/api/src/routes/audio-speech-ws/types.ts +++ b/server/apps/api/src/routes/audio-speech-ws/types.ts @@ -1,7 +1,6 @@ import type { ConfigKVService } from '../../services/adapters/config-kv' import type { FluxMeter } from '../../services/domain/billing/flux-meter' import type { FluxService } from '../../services/domain/flux' -import type { ProductEventService } from '../../services/domain/product-events' import type { RequestLogService } from '../../services/domain/request-log' import type { EnvelopeCrypto } from '../../utils/envelope-crypto' @@ -19,6 +18,4 @@ export interface AudioSpeechWsHandlersOptions { ttsMeter: FluxMeter /** Persists request accounting after a stream finishes. */ requestLogService: RequestLogService - /** Writes first-party product analytics for distinct-user aggregation. */ - productEventService: ProductEventService } diff --git a/server/apps/api/src/routes/internal-auth.ts b/server/apps/api/src/routes/internal-auth.ts index 401568405..421d1c638 100644 --- a/server/apps/api/src/routes/internal-auth.ts +++ b/server/apps/api/src/routes/internal-auth.ts @@ -12,8 +12,8 @@ const UserDeletionRequestSchema = object({ const AuthEventRequestSchema = object({ userId: pipe(string(), trim(), nonEmpty()), - action: picklist(['user_signed_up', 'session_started']), - source: picklist(['better-auth.user.create', 'better-auth.session.create']), + action: picklist(['user_signed_up']), + source: picklist(['better-auth.user.create']), }) /** diff --git a/server/apps/api/src/routes/openai/v1/operations/chat-completions/index.ts b/server/apps/api/src/routes/openai/v1/operations/chat-completions/index.ts index 103da8569..38dffcce7 100644 --- a/server/apps/api/src/routes/openai/v1/operations/chat-completions/index.ts +++ b/server/apps/api/src/routes/openai/v1/operations/chat-completions/index.ts @@ -71,19 +71,6 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple stream, messageCount: Array.isArray(body.messages) ? body.messages.length : undefined, }).log('chat completion request') - void deps.productEventService.track({ - userId: input.userId, - feature: 'gen_ai_chat', - action: 'completion_requested', - status: 'started', - source: 'openai.chat.completions', - model: requestModel, - metadata: { - stream, - message_count: Array.isArray(body.messages) ? body.messages.length : null, - }, - }) - // Server-connection attrs come from the router (which knows the actual // upstream baseURL it dispatched to) — it enriches the active span with // its own `airi.gen_ai.gateway.*` attrs on success. @@ -126,20 +113,6 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple sessionId: input.sessionId, }).fail('Router exhausted or unknown model') telemetry.recordMetrics({ model: requestModel, status: 502, type: 'chat', provider: routeCtx.provider, durationMs: Date.now() - startedAt, fluxConsumed: 0 }) - void deps.productEventService.track({ - userId: input.userId, - feature: 'gen_ai_chat', - action: 'completion_failed', - status: 'failed', - source: 'openai.chat.completions', - model: requestModel, - provider: routeCtx.provider, - reason: 'router_exhausted', - metadata: { - duration_ms: Date.now() - startedAt, - stream, - }, - }) throw err } @@ -165,21 +138,6 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple telemetry.failSpan(span, `Gateway ${response.status}`) generationTrace.fail(`Gateway ${response.status}`) telemetry.recordMetrics({ model: requestModel, status: response.status, type: 'chat', provider: routeCtx.provider, durationMs, fluxConsumed: 0 }) - void deps.productEventService.track({ - userId: input.userId, - feature: 'gen_ai_chat', - action: 'completion_failed', - status: 'failed', - source: 'openai.chat.completions', - model: requestModel, - provider: routeCtx.provider, - reason: 'upstream_error', - metadata: { - http_status: response.status, - duration_ms: durationMs, - stream, - }, - }) logger.withFields({ requestId, userId: input.userId, model: requestModel, status: response.status, durationMs }) .warn('chat completion delivered with upstream error status') @@ -433,21 +391,6 @@ function streamChatCompletion(input: { input.telemetry.endSpan(input.span) input.generationTrace.fail('Gateway stream interrupted') input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed: 0 }) - void input.deps.productEventService.track({ - userId: input.userId, - feature: 'gen_ai_chat', - action: 'completion_failed', - status: 'failed', - source: 'openai.chat.completions', - model: input.requestModel, - provider: input.routeCtxProvider, - reason: 'stream_interrupted', - metadata: { - http_status: input.response.status, - duration_ms: input.durationMs, - stream: true, - }, - }) } else if (streamCompleted) { try { @@ -534,23 +477,6 @@ function streamChatCompletion(input: { promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, }) - void input.deps.productEventService.track({ - userId: input.userId, - feature: 'gen_ai_chat', - action: 'completion_succeeded', - status: 'succeeded', - source: 'openai.chat.completions', - model: input.requestModel, - provider: input.routeCtxProvider, - metadata: { - http_status: input.response.status, - duration_ms: input.durationMs, - prompt_tokens: usage.promptTokens ?? 0, - completion_tokens: usage.completionTokens ?? 0, - flux_consumed: actualCharged, - stream: true, - }, - }) input.logger.withFields({ requestId: input.requestId, @@ -604,21 +530,6 @@ async function completeNonStreamingChat(input: { input.telemetry.failSpan(input.span, 'Failed to parse upstream response body') input.generationTrace.fail('Failed to parse upstream response body') input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed: 0 }) - void input.deps.productEventService.track({ - userId: input.userId, - feature: 'gen_ai_chat', - action: 'completion_failed', - status: 'failed', - source: 'openai.chat.completions', - model: input.requestModel, - provider: input.routeCtxProvider, - reason: 'malformed_upstream_response', - metadata: { - http_status: input.response.status, - duration_ms: input.durationMs, - stream: false, - }, - }) throw err } const usage = extractUsageFromBody(responseBody) @@ -671,24 +582,6 @@ async function completeNonStreamingChat(input: { promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, }) - void input.deps.productEventService.track({ - userId: input.userId, - feature: 'gen_ai_chat', - action: 'completion_succeeded', - status: 'succeeded', - source: 'openai.chat.completions', - model: input.requestModel, - provider: input.routeCtxProvider, - metadata: { - http_status: input.response.status, - duration_ms: input.durationMs, - prompt_tokens: usage.promptTokens ?? 0, - completion_tokens: usage.completionTokens ?? 0, - flux_consumed: actualCharged, - stream: false, - }, - }) - input.logger.withFields({ requestId: input.requestId, userId: input.userId, diff --git a/server/apps/api/src/routes/openai/v1/operations/speech-generation/index.ts b/server/apps/api/src/routes/openai/v1/operations/speech-generation/index.ts index dddf244cc..42822fed8 100644 --- a/server/apps/api/src/routes/openai/v1/operations/speech-generation/index.ts +++ b/server/apps/api/src/routes/openai/v1/operations/speech-generation/index.ts @@ -18,7 +18,6 @@ export function speechGeneration(deps: V1RouteDeps): GatewayCallback<'speech.gen llmRouter: deps.llmRouter, llmTracing: deps.llmTracing, providerCatalogService: deps.providerCatalogService, - productEventService: deps.productEventService, requestLogService: deps.requestLogService, ttsMeter: deps.ttsMeter, voicePackService: deps.voicePackService, diff --git a/server/apps/api/src/routes/openai/v1/route.test.ts b/server/apps/api/src/routes/openai/v1/route.test.ts index edb53f17a..844b64e5f 100644 --- a/server/apps/api/src/routes/openai/v1/route.test.ts +++ b/server/apps/api/src/routes/openai/v1/route.test.ts @@ -149,7 +149,6 @@ function createMockProductEventService(): ProductEventService { return { track: vi.fn(async () => undefined), trackGeneration: vi.fn(async () => undefined), - countDistinctUsersByFeature: vi.fn(async () => []), } } @@ -1467,13 +1466,14 @@ describe('v1CompletionsRoutes', () => { * @example * POST /api/v1/audio/speech { "voice": "alloy" } */ - it('records TTS voice and Voice Pack metadata in product events', async () => { - globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1]), { + it('routes TTS requests with Voice Pack metadata', async () => { + const routeTts = vi.fn(async () => new Response(new Uint8Array([1]), { status: 200, headers: { 'Content-Type': 'audio/mpeg' }, })) const productEventService = createMockProductEventService() + const llmRouter = createMockLlmRouter({ routeTts }) const voicePackService = createMockVoicePackService({ findEnabledByVoiceId: vi.fn(async () => ({ id: 'vp-premium', @@ -1497,13 +1497,13 @@ describe('v1CompletionsRoutes', () => { undefined, undefined, undefined, - undefined, + llmRouter, createMockLlmTracing(), productEventService, voicePackService, ) - await app.fetch( + const response = await app.fetch( new Request('http://localhost/api/v1/audio/speech', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1522,23 +1522,15 @@ describe('v1CompletionsRoutes', () => { { user: testUser } as any, ) - expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ - action: 'speech_succeeded', - source: 'manual_preview', - metadata: expect.objectContaining({ - voice_id: 'alloy', - voice_type: 'voice_pack', - voice_pack_id: 'vp-premium', + expect(response.status).toBe(200) + expect(routeTts).toHaveBeenCalledWith(expect.objectContaining({ + modelName: 'tts-1', + input: expect.objectContaining({ + text: 'hello', + voice: 'upstream-alloy', }), - })) - expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ - action: 'speech_requested', - metadata: expect.objectContaining({ - voice_id: 'alloy', - voice_type: 'voice_pack', - voice_pack_id: 'vp-premium', - }), - })) + }), expect.any(Object)) + expect(productEventService.track).not.toHaveBeenCalled() }) it('should not charge when routeTts upstream returns error', async () => { @@ -1568,7 +1560,7 @@ describe('v1CompletionsRoutes', () => { * @example * routeTts throws ApiError(429, 'TOO_MANY_REQUESTS', 'Too many requests') */ - it('records routeTts ApiError status and reason in product events', async () => { + it('preserves routeTts ApiError status and reason', async () => { const productEventService = createMockProductEventService() const llmRouter = createMockLlmRouter({ routeTts: vi.fn(async () => { @@ -1596,19 +1588,9 @@ describe('v1CompletionsRoutes', () => { ) expect(res.status).toBe(429) - expect(productEventService.track).toHaveBeenCalledWith( - expect.objectContaining({ - action: 'speech_failed', - reason: 'TOO_MANY_REQUESTS', - metadata: expect.objectContaining({ - failure_reason: 'TOO_MANY_REQUESTS', - http_status: 429, - }), - }), - ) }) - it('returns 402 and records blocked event for manual TTS when flux is insufficient', async () => { + it('returns 402 for manual TTS when flux is insufficient', async () => { const productEventService = createMockProductEventService() const llmRouter = createMockLlmRouter() const app = createTestApp( @@ -1632,21 +1614,9 @@ describe('v1CompletionsRoutes', () => { ) expect(res.status).toBe(402) expect(llmRouter.routeTts).not.toHaveBeenCalled() - expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ - action: 'speech_blocked', - status: 'blocked', - source: 'audio.speech', - reason: 'insufficient_balance', - metadata: expect.objectContaining({ - trigger: 'manual', - block_reason: 'insufficient_balance', - balance_state: 'insufficient', - flux_balance_bucket: 'zero', - }), - })) }) - it('returns 204 and records blocked event for auto TTS when flux is insufficient', async () => { + it('returns 204 for auto TTS when flux is insufficient', async () => { const productEventService = createMockProductEventService() const llmRouter = createMockLlmRouter() const app = createTestApp( @@ -1680,18 +1650,6 @@ describe('v1CompletionsRoutes', () => { ) expect(res.status).toBe(204) expect(llmRouter.routeTts).not.toHaveBeenCalled() - expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ - action: 'speech_blocked', - status: 'blocked', - source: 'chat_auto_tts', - reason: 'insufficient_balance', - metadata: expect.objectContaining({ - trigger: 'auto', - block_reason: 'insufficient_balance', - balance_state: 'insufficient', - flux_balance_bucket: 'zero', - }), - })) }) it('should not charge when input is empty', async () => { diff --git a/server/apps/api/src/routes/stripe/operations/checkout.ts b/server/apps/api/src/routes/stripe/operations/checkout.ts index 3c5c3d9e8..b3334d098 100644 --- a/server/apps/api/src/routes/stripe/operations/checkout.ts +++ b/server/apps/api/src/routes/stripe/operations/checkout.ts @@ -136,6 +136,7 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) { feature: 'billing', action: 'checkout_started', status: 'succeeded', + eventId: session.id, source: 'stripe.checkout', metadata: { flux_amount: fluxAmount, diff --git a/server/apps/api/src/routes/stripe/operations/webhook.ts b/server/apps/api/src/routes/stripe/operations/webhook.ts index 7a1449b69..e79d683fd 100644 --- a/server/apps/api/src/routes/stripe/operations/webhook.ts +++ b/server/apps/api/src/routes/stripe/operations/webhook.ts @@ -3,7 +3,7 @@ import type Stripe from 'stripe' import type { RevenueMetrics } from '../../../otel' import type { BillingService } from '../../../services/domain/billing/billing-service' import type { FluxService } from '../../../services/domain/flux' -import type { ProductAction, ProductEventService } from '../../../services/domain/product-events' +import type { ProductEventService } from '../../../services/domain/product-events' import type { StripeService } from '../../../services/domain/stripe' import { useLogger } from '@guiiai/logg' @@ -96,6 +96,7 @@ export function createWebhookOperation(deps: WebhookOperationDeps) { feature: 'billing', action: 'payment_completed', status: 'succeeded', + eventId: event.data.object.id, source: 'stripe.webhook', metadata: { amount_total: event.data.object.amount_total, @@ -119,29 +120,15 @@ export function createWebhookOperation(deps: WebhookOperationDeps) { case 'customer.subscription.created': case 'customer.subscription.updated': case 'customer.subscription.deleted': { - const result = await handleSubscriptionEvent(event.data.object, deps.stripeService) + await handleSubscriptionEvent(event.data.object, deps.stripeService) deps.metrics?.stripeSubscriptionEvent.add(1, { event_type: event.type.replace('customer.subscription.', '') }) - const action = subscriptionActionForWebhookEvent(event.type) - if (result && action) { - void deps.productEventService?.track({ - userId: result.userId, - feature: 'billing', - action, - status: 'succeeded', - source: 'stripe.webhook', - metadata: { - stripe_price_id: result.stripePriceId ?? null, - stripe_subscription_status: result.subscriptionStatus ?? null, - }, - }) - } break } case 'invoice.created': case 'invoice.updated': case 'invoice.paid': case 'invoice.payment_failed': { - const result = await handleInvoiceEvent(event.data.object, deps.stripeService) + await handleInvoiceEvent(event.data.object, deps.stripeService) if (event.type === 'invoice.payment_failed') deps.metrics?.stripePaymentFailed.add(1) if (event.type === 'invoice.paid' && event.data.object.amount_paid && event.data.object.currency) { @@ -150,20 +137,6 @@ export function createWebhookOperation(deps: WebhookOperationDeps) { source: 'invoice', }) } - if (event.type === 'invoice.paid' && event.data.object.billing_reason === 'subscription_cycle' && result?.stripeSubscriptionId) { - void deps.productEventService?.track({ - userId: result.userId, - feature: 'billing', - action: 'subscription_renewed', - status: 'succeeded', - source: 'stripe.webhook', - metadata: { - amount_paid: result.amountPaid ?? null, - currency: result.currency ?? null, - stripe_price_id: result.stripePriceId ?? null, - }, - }) - } break } } @@ -172,14 +145,6 @@ export function createWebhookOperation(deps: WebhookOperationDeps) { } } -function subscriptionActionForWebhookEvent(eventType: Stripe.Event.Type): ProductAction | null { - if (eventType === 'customer.subscription.created') - return 'subscription_started' - if (eventType === 'customer.subscription.deleted') - return 'subscription_cancelled' - return null -} - async function handleCheckoutSessionCompleted( stripeEventId: string, session: Stripe.Checkout.Session, diff --git a/server/apps/api/src/routes/stripe/route.test.ts b/server/apps/api/src/routes/stripe/route.test.ts index 8c52fdfd6..17be908a2 100644 --- a/server/apps/api/src/routes/stripe/route.test.ts +++ b/server/apps/api/src/routes/stripe/route.test.ts @@ -591,6 +591,7 @@ describe('stripeRoutes', () => { feature: 'billing', action: 'payment_completed', status: 'succeeded', + eventId: 'cs_1', source: 'stripe.webhook', metadata: { amount_total: 500, @@ -604,7 +605,7 @@ describe('stripeRoutes', () => { }) }) - it('records subscription lifecycle product events from Stripe webhooks', async () => { + it('processes subscription lifecycle webhooks without product events', async () => { const subscriptionEvent = { id: 'evt_sub_created', type: 'customer.subscription.created', @@ -627,10 +628,10 @@ describe('stripeRoutes', () => { }, }, } - const productEventService = { track: vi.fn() } const stripeService = createMockStripeService({ getCustomerByStripeId: vi.fn(async () => createMockStripeCustomer()), }) + const productEventService = { track: vi.fn(async () => undefined) } const webhook = createWebhookOperation({ stripe: { webhooks: { @@ -646,17 +647,15 @@ describe('stripeRoutes', () => { await webhook({ signature: 'test_sig', body: '{}' }) - expect(productEventService.track).toHaveBeenCalledWith({ + expect(stripeService.upsertSubscription).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-1', - feature: 'billing', - action: 'subscription_started', - status: 'succeeded', - source: 'stripe.webhook', - metadata: { - stripe_price_id: 'price_1', - stripe_subscription_status: 'active', - }, - }) + stripeSubscriptionId: 'sub_1', + stripeCustomerId: 'cus_1', + stripePriceId: 'price_1', + status: 'active', + cancelAtPeriodEnd: false, + })) + expect(productEventService.track).not.toHaveBeenCalled() }) it('records subscription renewals only for subscription-cycle paid invoices', async () => { @@ -688,10 +687,10 @@ describe('stripeRoutes', () => { }, }, } - const productEventService = { track: vi.fn() } const stripeService = createMockStripeService({ getCustomerByStripeId: vi.fn(async () => createMockStripeCustomer()), }) + const productEventService = { track: vi.fn(async () => undefined) } const webhook = createWebhookOperation({ stripe: { webhooks: { @@ -707,18 +706,16 @@ describe('stripeRoutes', () => { await webhook({ signature: 'test_sig', body: '{}' }) - expect(productEventService.track).toHaveBeenCalledWith({ + expect(stripeService.upsertInvoice).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-1', - feature: 'billing', - action: 'subscription_renewed', - status: 'succeeded', - source: 'stripe.webhook', - metadata: { - amount_paid: 1200, - currency: 'usd', - stripe_price_id: null, - }, - }) + stripeInvoiceId: 'inv_1', + stripeCustomerId: 'cus_1', + stripeSubscriptionId: 'sub_1', + status: 'paid', + amountDue: 1_200, + amountPaid: 1_200, + })) + expect(productEventService.track).not.toHaveBeenCalled() }) }) }) diff --git a/server/apps/api/src/schemas/index.ts b/server/apps/api/src/schemas/index.ts index a3db0119d..575579831 100644 --- a/server/apps/api/src/schemas/index.ts +++ b/server/apps/api/src/schemas/index.ts @@ -4,7 +4,6 @@ export * from './config-kv' export * from './flux' export * from './flux-transaction' export * from './llm-request-log' -export * from './product-events' export * from './provider-catalog' export * from './providers' export * from './stripe' diff --git a/server/apps/api/src/schemas/product-events.ts b/server/apps/api/src/schemas/product-events.ts deleted file mode 100644 index 3aef1d736..000000000 --- a/server/apps/api/src/schemas/product-events.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' - -import { index, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core' - -import { nanoid } from '../utils/id' - -export type ProductEventMetadata = Record - -export const productEvents = pgTable( - 'product_events', - { - id: text('id').primaryKey().$defaultFn(() => nanoid()), - userId: text('user_id').notNull(), - feature: text('feature').notNull(), - action: text('action').notNull(), - status: text('status').notNull(), - source: text('source'), - model: text('model'), - provider: text('provider'), - reason: text('reason'), - metadata: jsonb('metadata').$type(), - createdAt: timestamp('created_at').defaultNow().notNull(), - }, - table => [ - index('product_events_feature_action_created_at_idx').on(table.feature, table.action, table.createdAt), - index('product_events_user_id_created_at_idx').on(table.userId, table.createdAt), - index('product_events_created_at_idx').on(table.createdAt), - ], -) - -export type ProductEvent = InferSelectModel -export type NewProductEvent = InferInsertModel diff --git a/server/apps/api/src/services/adapters/posthog.ts b/server/apps/api/src/services/adapters/posthog.ts index bbbe2d8b9..931e9b911 100644 --- a/server/apps/api/src/services/adapters/posthog.ts +++ b/server/apps/api/src/services/adapters/posthog.ts @@ -11,6 +11,8 @@ export interface PosthogCaptureInput { distinctId: string event: string properties: Record + /** Stable event UUID used by PostHog ingestion for replay deduplication. */ + uuid?: string } /** @@ -37,9 +39,8 @@ export interface PosthogSink { * generation facts use `captureQueued`, which is buffered by the SDK and * flushed on shutdown, so chat completion requests don't wait on PostHog. * - * Capture failures are logged and swallowed — analytics forwarding must - * never fail the Stripe webhook or auth flow that triggered it. The - * Postgres `product_events` row is the source of truth either way. + * Capture failures are logged and swallowed. Analytics forwarding never + * fails the Stripe webhook or auth flow that produced the business fact. */ export function createPosthogSink(options: { projectKey: string, host: string }): PosthogSink { const client = new PostHog(options.projectKey, { host: options.host }) @@ -51,6 +52,7 @@ export function createPosthogSink(options: { projectKey: string, host: string }) distinctId: input.distinctId, event: input.event, properties: input.properties, + ...(input.uuid && { uuid: input.uuid }), }) } catch (err) { @@ -64,6 +66,7 @@ export function createPosthogSink(options: { projectKey: string, host: string }) distinctId: input.distinctId, event: input.event, properties: input.properties, + ...(input.uuid && { uuid: input.uuid }), }) } catch (err) { diff --git a/server/apps/api/src/services/domain/chats.ts b/server/apps/api/src/services/domain/chats.ts index c28ba79c2..9e8c6e481 100644 --- a/server/apps/api/src/services/domain/chats.ts +++ b/server/apps/api/src/services/domain/chats.ts @@ -2,7 +2,6 @@ import type { MessageRole, WireMessage } from '@proj-airi/server-sdk-shared' import type { Database } from '../../libs/db' import type { EngagementMetrics } from '../../otel' -import type { ProductEventService } from './product-events' import { useLogger } from '@guiiai/logg' import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm' @@ -50,7 +49,7 @@ export function resolveSenderId(role: string, userId: string): string | null { // Service factory // --------------------------------------------------------------------------- -export function createChatService(db: Database, metrics?: EngagementMetrics | null, productEventService?: ProductEventService) { +export function createChatService(db: Database, metrics?: EngagementMetrics | null) { // ---- internal helpers --------------------------------------------------- async function verifyMembership(tx: Parameters[0]>[0], chatId: string, userId: string) { @@ -341,17 +340,6 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu if (result.totalCount > 0) { metrics?.chatMessages.add(result.totalCount) - void productEventService?.track({ - userId, - feature: 'chat', - action: 'message_pushed', - status: 'succeeded', - source: 'chat.ws.push_messages', - metadata: { - message_count: result.totalCount, - new_count: result.newCount, - }, - }) } metrics?.wsMessagesReceived.add(result.totalCount) diff --git a/server/apps/api/src/services/domain/openai-speech/index.ts b/server/apps/api/src/services/domain/openai-speech/index.ts index 2c386008e..2b223bc18 100644 --- a/server/apps/api/src/services/domain/openai-speech/index.ts +++ b/server/apps/api/src/services/domain/openai-speech/index.ts @@ -4,7 +4,6 @@ import type { FluxMeter } from '../billing/flux-meter' import type { FluxService } from '../flux' import type { LlmRouterService } from '../llm-router' import type { startTtsGeneration, TtsGenerationTrace } from '../llm-tracing' -import type { ProductEventService } from '../product-events' import type { ProviderCatalogService } from '../provider-catalog' import type { RequestLogService } from '../request-log' import type { VoicePackService } from '../voice-packs' @@ -19,7 +18,6 @@ import { AIRI_ATTR_GEN_AI_OPERATION_KIND, GEN_AI_ATTR_REQUEST_MODEL, } from '../../../utils/observability' -import { fluxBalanceBucket } from '../flux-balance' const tracer = trace.getTracer('v1-completions') @@ -51,7 +49,6 @@ export interface OpenAiSpeechServiceDeps { llmRouter: LlmRouterService voicePackService: VoicePackService providerCatalogService: ProviderCatalogService - productEventService: ProductEventService genAi?: GenAiMetrics | null llmTracing: { startTtsGeneration: (input: Parameters[0]) => TtsGenerationTrace @@ -66,12 +63,9 @@ export interface OpenAiSpeechRequest { } type TtsTrigger = 'auto' | 'manual' -type TtsVoiceType = 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' | 'unknown' - interface TtsAnalyticsContext { trigger: TtsTrigger source: 'audio.speech' | 'chat_auto_tts' | 'manual_preview' | 'settings_test' - voiceType: TtsVoiceType } /** @@ -112,11 +106,6 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { if (!voicePackRequest.voicePackId && routedVoice) await deps.providerCatalogService.assertTtsVoiceEnabled(requestModel, routedVoice) - const voiceMetadata = ttsVoiceMetadata({ - voice: requestVoice, - voicePackId: voicePackRequest.voicePackId, - voiceType: analytics.voiceType, - }) const billingUnits = Math.ceil(inputText.length * voicePackRequest.costMultiplier) logger.withFields({ @@ -127,20 +116,6 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { voice: requestVoice, }).log('tts speech request') - void deps.productEventService.track({ - userId: input.userId, - feature: 'tts', - action: 'speech_requested', - status: 'started', - source: analytics.source, - model: requestModel, - metadata: { - input_chars: inputText.length, - trigger: analytics.trigger, - ...voiceMetadata, - }, - }) - const flux = await deps.fluxService.getFlux(input.userId) try { await deps.ttsMeter.assertCanAfford(input.userId, billingUnits, flux.flux) @@ -149,24 +124,6 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { if (!(err instanceof ApiError) || err.statusCode !== 402) throw err - void deps.productEventService.track({ - userId: input.userId, - feature: 'tts', - action: 'speech_blocked', - status: 'blocked', - source: analytics.source, - model: requestModel, - reason: 'insufficient_balance', - metadata: { - input_chars: inputText.length, - billing_units: billingUnits, - block_reason: 'insufficient_balance', - balance_state: 'insufficient', - flux_balance_bucket: fluxBalanceBucket(flux.flux), - trigger: analytics.trigger, - ...voiceMetadata, - }, - }) logger.withError(err).withFields({ requestId, userId: input.userId, @@ -227,23 +184,6 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { provider: routeCtx.provider, status: failure.status, }) - void deps.productEventService.track({ - userId: input.userId, - feature: 'tts', - action: 'speech_failed', - status: 'failed', - source: analytics.source, - model: requestModel, - provider: routeCtx.provider, - reason: failure.reason, - metadata: { - http_status: failure.status, - duration_ms: Date.now() - startedAt, - failure_reason: failure.reason, - trigger: analytics.trigger, - ...voiceMetadata, - }, - }) throw err } @@ -255,23 +195,6 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { span.end() generationTrace.fail(`Gateway ${response.status}`) recordMetrics({ model: requestModel, status: response.status, provider: routeCtx.provider, durationMs, fluxConsumed: 0 }) - void deps.productEventService.track({ - userId: input.userId, - feature: 'tts', - action: 'speech_failed', - status: 'failed', - source: analytics.source, - model: requestModel, - provider: routeCtx.provider, - reason: 'upstream_error', - metadata: { - http_status: response.status, - duration_ms: durationMs, - failure_reason: 'upstream_error', - trigger: analytics.trigger, - ...voiceMetadata, - }, - }) logger.withFields({ requestId, userId: input.userId, model: requestModel, status: response.status, durationMs }) .warn('tts speech delivered with upstream error status') return new Response(response.body, { @@ -306,25 +229,6 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { } recordMetrics({ model: requestModel, status: response.status, provider: routeCtx.provider, durationMs, fluxConsumed }) - void deps.productEventService.track({ - userId: input.userId, - feature: 'tts', - action: 'speech_succeeded', - status: 'succeeded', - source: analytics.source, - model: requestModel, - provider: routeCtx.provider, - metadata: { - http_status: response.status, - input_chars: inputText.length, - billing_units: billingUnits, - cost_multiplier: voicePackRequest.costMultiplier, - duration_ms: durationMs, - flux_consumed: fluxConsumed, - trigger: analytics.trigger, - ...voiceMetadata, - }, - }) deps.requestLogService.logRequest({ userId: input.userId, model: requestModel, @@ -380,40 +284,7 @@ function ttsAnalyticsContext(body: Record): TtsAnalyticsContext || rawSource === 'settings_test' ? rawSource : 'audio.speech' - const voiceType = normalizeVoiceType(analytics?.voice_type) - - return { trigger, source, voiceType } -} - -/** - * Normalizes client-provided TTS voice type into bounded analytics values. - */ -function normalizeVoiceType(value: unknown): TtsVoiceType { - switch (value) { - case 'official_default': - case 'official_selected': - case 'custom_configured': - case 'voice_pack': - return value - default: - return 'unknown' - } -} - -/** - * Builds reusable low-cardinality voice metadata for every TTS product event. - */ -function ttsVoiceMetadata(input: { - voice?: string - voicePackId?: string - voiceType: TtsVoiceType -}): Record { - const voiceType = input.voicePackId ? 'voice_pack' : input.voiceType - return { - ...(input.voice ? { voice_id: input.voice } : {}), - voice_type: voiceType, - ...(input.voicePackId ? { voice_pack_id: input.voicePackId } : {}), - } + return { trigger, source } } async function voicePackRequestOptions( diff --git a/server/apps/api/src/services/domain/product-events.test.ts b/server/apps/api/src/services/domain/product-events.test.ts index 0c32f38b0..581d43c03 100644 --- a/server/apps/api/src/services/domain/product-events.test.ts +++ b/server/apps/api/src/services/domain/product-events.test.ts @@ -1,148 +1,25 @@ -import type { Database } from '../../libs/db' -import type { ProductMetrics } from '../../otel' +import { describe, expect, it, vi } from 'vitest' -import { sql } from 'drizzle-orm' -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' - -import { mockDB } from '../../libs/mock-db' import { createProductEventService } from './product-events' -import * as schema from '../../schemas' - describe('productEventService', () => { - let db: Database - - beforeAll(async () => { - db = await mockDB(schema) - }) - - beforeEach(async () => { - await db.delete(schema.productEvents) - }) - - it('writes first-party events and increments only low-cardinality metric labels', async () => { - const events = { add: vi.fn() } - const service = createProductEventService(db, { events } as unknown as ProductMetrics) - - await service.track({ - userId: 'user-1', - feature: 'gen_ai_chat', - action: 'completion_succeeded', - status: 'succeeded', - source: 'openai.chat.completions', - model: 'openrouter/anthropic/claude-sonnet-4', - provider: 'openrouter', - metadata: { - stream: false, - flux_consumed: 3, - }, - }) - - const rows = await db.select().from(schema.productEvents) - expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ - userId: 'user-1', - feature: 'gen_ai_chat', - action: 'completion_succeeded', - status: 'succeeded', - source: 'openai.chat.completions', - model: 'openrouter/anthropic/claude-sonnet-4', - provider: 'openrouter', - }) - - expect(events.add).toHaveBeenCalledWith(1, { - feature: 'gen_ai_chat', - action: 'completion_succeeded', - status: 'succeeded', - source: 'openai.chat.completions', - }) - }) - - it('aggregates event volume and distinct users by feature/action/status', async () => { - const service = createProductEventService(db) - const createdAt = new Date('2026-06-03T00:00:00.000Z') - - await service.track({ - userId: 'user-1', - feature: 'tts', - action: 'speech_succeeded', - status: 'succeeded', - source: 'audio.speech', - createdAt, - }) - await service.track({ - userId: 'user-1', - feature: 'tts', - action: 'speech_succeeded', - status: 'succeeded', - source: 'audio.speech.ws', - createdAt, - }) - await service.track({ - userId: 'user-2', - feature: 'tts', - action: 'speech_succeeded', - status: 'succeeded', - source: 'audio.speech', - createdAt, - }) - - const rows = await service.countDistinctUsersByFeature({ - from: new Date('2026-06-02T00:00:00.000Z'), - to: new Date('2026-06-04T00:00:00.000Z'), - }) - - expect(rows).toEqual([{ - feature: 'tts', - action: 'speech_succeeded', - status: 'succeeded', - eventCount: 3, - distinctUsers: 2, - }]) - }) - - it('writes blocked TTS events for server-side preflight decisions', async () => { - const events = { add: vi.fn() } - const service = createProductEventService(db, { events } as unknown as ProductMetrics) - - await service.track({ - userId: 'user-1', - feature: 'tts', - action: 'speech_blocked', - status: 'blocked', - source: 'chat_auto_tts', - reason: 'insufficient_balance', - metadata: { - trigger: 'auto', - balance_state: 'insufficient', - flux_balance_bucket: 'zero', - }, - }) - - const rows = await db.select().from(schema.productEvents) - expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ - feature: 'tts', - action: 'speech_blocked', - status: 'blocked', - source: 'chat_auto_tts', - reason: 'insufficient_balance', - }) - expect(events.add).toHaveBeenCalledWith(1, { - feature: 'tts', - action: 'speech_blocked', - status: 'blocked', - source: 'chat_auto_tts', - reason: 'insufficient_balance', - flux_balance_bucket: 'zero', - }) - }) - - it('forwards allowlisted business facts to PostHog keyed by user id, mapping user_signed_up to signup_completed', async () => { + it('captures only the server-side funnel facts shared with the Go service', async () => { const capture = vi.fn(async () => {}) - const sink = { capture, shutdown: vi.fn(async () => {}) } - const service = createProductEventService(db, null, sink) + const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) + await service.track({ + userId: 'user-1', + feature: 'auth', + action: 'user_signed_up', + status: 'succeeded', + }) + await service.track({ + userId: 'user-1', + feature: 'billing', + action: 'checkout_started', + status: 'succeeded', + source: 'stripe.checkout', + }) await service.track({ userId: 'user-1', feature: 'billing', @@ -151,14 +28,29 @@ describe('productEventService', () => { source: 'stripe.webhook', metadata: { amount_minor_unit: 990, currency: 'usd' }, }) - await service.track({ - userId: 'user-2', - feature: 'auth', - action: 'user_signed_up', - status: 'succeeded', - }) expect(capture).toHaveBeenNthCalledWith(1, { + distinctId: 'user-1', + event: 'signup_completed', + properties: { + app_surface: 'server', + airi_user_id: 'user-1', + feature: 'auth', + status: 'succeeded', + }, + }) + expect(capture).toHaveBeenNthCalledWith(2, { + distinctId: 'user-1', + event: 'checkout_created', + properties: { + app_surface: 'server', + airi_user_id: 'user-1', + feature: 'billing', + status: 'succeeded', + source: 'stripe.checkout', + }, + }) + expect(capture).toHaveBeenNthCalledWith(3, { distinctId: 'user-1', event: 'payment_completed', properties: { @@ -171,195 +63,113 @@ describe('productEventService', () => { currency: 'usd', }, }) - expect(capture).toHaveBeenNthCalledWith(2, { - distinctId: 'user-2', - event: 'signup_completed', - properties: { - app_surface: 'server', - airi_user_id: 'user-2', - feature: 'auth', - status: 'succeeded', - }, - }) - - const rows = await db.select().from(schema.productEvents) - expect(rows).toHaveLength(2) }) - it('merges Stripe webhook conversions with the browser PostHog person when a distinct id is present', async () => { + it('merges a Stripe conversion with its browser PostHog person', async () => { const capture = vi.fn(async () => {}) - const sink = { capture, shutdown: vi.fn(async () => {}) } - const service = createProductEventService(db, null, sink) + const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) await service.track({ userId: 'user-1', feature: 'billing', action: 'payment_completed', status: 'succeeded', - source: 'stripe.webhook', + eventId: 'cs_123', metadata: { posthog_distinct_id: 'anon-browser-1', posthog_session_id: 'ph-session-1', - stripe_checkout_session_id: 'cs_1', }, }) expect(capture).toHaveBeenNthCalledWith(1, { distinctId: 'user-1', event: '$identify', + uuid: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), properties: { + $insert_id: 'cs_123', $anon_distinct_id: 'anon-browser-1', $session_id: 'ph-session-1', airi_user_id: 'user-1', }, }) - expect(capture).toHaveBeenNthCalledWith(2, { + expect(capture).toHaveBeenNthCalledWith(2, expect.objectContaining({ distinctId: 'user-1', event: 'payment_completed', - properties: { - app_surface: 'server', - airi_user_id: 'user-1', - posthog_distinct_id: 'anon-browser-1', - $session_id: 'ph-session-1', - feature: 'billing', - status: 'succeeded', - source: 'stripe.webhook', - posthog_session_id: 'ph-session-1', - stripe_checkout_session_id: 'cs_1', - }, - }) + uuid: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), + properties: expect.objectContaining({ $insert_id: 'cs_123' }), + })) }) - it('does not forward high-volume per-request actions to PostHog', async () => { + it('uses a stable PostHog UUID for replayed conversion captures', async () => { const capture = vi.fn(async () => {}) - const sink = { capture, shutdown: vi.fn(async () => {}) } - const service = createProductEventService(db, null, sink) + const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) + const input = { + userId: 'user-1' as const, + feature: 'billing' as const, + action: 'payment_completed' as const, + status: 'succeeded' as const, + eventId: 'cs_replayed', + } - await service.track({ - userId: 'user-1', - feature: 'gen_ai_chat', - action: 'completion_succeeded', - status: 'succeeded', - }) - await service.track({ + await service.track(input) + await service.track(input) + + expect(capture).toHaveBeenCalledTimes(2) + const captures = capture.mock.calls as unknown as Array<[ + { + uuid?: string + properties: Record + }, + ]> + const first = captures[0]![0] + const replay = captures[1]![0] + expect(first.uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + expect(replay.uuid).toBe(first.uuid) + expect(first.properties.$insert_id).toBe('cs_replayed') + }) + + it('rejects metadata that can overwrite service-controlled PostHog properties', async () => { + const capture = vi.fn(async () => {}) + const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) + + await expect(service.track({ userId: 'user-1', feature: 'billing', - action: 'checkout_started', - status: 'started', - }) + action: 'payment_completed', + status: 'succeeded', + metadata: { $insert_id: 'spoofed' }, + })).resolves.toBeUndefined() expect(capture).not.toHaveBeenCalled() - const rows = await db.select().from(schema.productEvents) - expect(rows).toHaveLength(2) }) - it('captures an LLM generation as a PostHog AI fact without storing prompts or responses', async () => { - const capture = vi.fn(async () => {}) - const captureQueued = vi.fn() - const sink = { capture, captureQueued, shutdown: vi.fn(async () => {}) } - const service = createProductEventService(db, null, sink) + it('still captures the funnel event when identity merging fails', async () => { + const capture = vi.fn() + .mockRejectedValueOnce(new Error('identify failed')) + .mockResolvedValueOnce(undefined) + const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) - service.trackGeneration({ + await expect(service.track({ userId: 'user-1', - traceId: 'session-1', - generationId: 'round-1', - model: 'openai/gpt-5-mini', - provider: 'openai', - providerType: 'official', - usageSource: 'reported', - inputTokens: 12, - outputTokens: 8, - totalTokens: 20, - conversationId: 'session-1', - conversationIdSource: 'client_header', - roundId: 'round-1', - appSurface: 'electron', - captureSurface: 'server', - }) + feature: 'billing', + action: 'payment_completed', + status: 'succeeded', + eventId: 'cs_456', + metadata: { posthog_distinct_id: 'anon-browser-1' }, + })).resolves.toBeUndefined() - expect(capture).not.toHaveBeenCalled() - expect(captureQueued).toHaveBeenCalledWith({ - distinctId: 'user-1', - event: '$ai_generation', - properties: { - $ai_trace_id: 'session-1', - $ai_session_id: 'session-1', - $ai_span_id: 'round-1', - $ai_model: 'openai/gpt-5-mini', - $ai_provider: 'openai', - $ai_input_tokens: 12, - $ai_output_tokens: 8, - $ai_total_tokens: 20, - $insert_id: 'ai-generation:round-1', - airi_user_id: 'user-1', - provider_type: 'official', - usage_source: 'reported', - token_usage_available: true, - cost_usd_source: 'unavailable', - cost_usd_known: false, - conversation_id: 'session-1', - conversation_id_source: 'client_header', - round_id: 'round-1', - app_surface: 'electron', - capture_surface: 'server', - }, - }) - - const rows = await db.select().from(schema.productEvents) - expect(rows).toHaveLength(0) + expect(capture).toHaveBeenCalledTimes(2) + expect(capture).toHaveBeenNthCalledWith(2, expect.objectContaining({ + event: 'payment_completed', + properties: expect.objectContaining({ $insert_id: 'cs_456' }), + })) }) - it('does not forward to PostHog when the DB write fails', async () => { - // ROOT CAUSE: - // - // track() swallows DB insert errors to protect the caller, but the - // PostHog forwarding block ran unconditionally afterwards — a Postgres - // outage during a Stripe webhook would mint `payment_completed` in - // PostHog with no `product_events` row backing it, breaking the - // "Postgres is the fact of record" invariant and later reconciliation. - // Found by PR #2038 review. - // - // Fixed by gating forwarding on a `persisted` flag set only after the - // insert resolves. - const capture = vi.fn(async () => {}) - const sink = { capture, shutdown: vi.fn(async () => {}) } - const service = createProductEventService(db, null, sink) - - // Simulate a DB outage by renaming the table out from under the insert. - await db.execute(sql`ALTER TABLE product_events RENAME TO product_events_outage`) - try { - await expect(service.track({ - userId: 'user-1', - feature: 'billing', - action: 'payment_completed', - status: 'succeeded', - })).resolves.toBeUndefined() - } - finally { - await db.execute(sql`ALTER TABLE product_events_outage RENAME TO product_events`) - } - - expect(capture).not.toHaveBeenCalled() - const rows = await db.select().from(schema.productEvents) - expect(rows).toHaveLength(0) - }) - - it('persists the product event even when a misbehaving sink throws', async () => { - // ROOT CAUSE: - // - // The PosthogSink contract says implementations swallow transport - // errors, but the forwarding call sits on the Stripe webhook path — - // if a sink ever throws, an unguarded `await` would fail the webhook - // after the fact was already persisted, causing Stripe to retry and - // (before the idempotency guard) double-process the payment. - // - // track() therefore wraps forwarding in its own try/catch: the DB row - // must survive and track() must resolve regardless of sink behavior. + it('does not fail a business path when capture throws', async () => { const capture = vi.fn(async () => { throw new Error('posthog exploded') }) - const sink = { capture, shutdown: vi.fn(async () => {}) } - const service = createProductEventService(db, null, sink) + const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) await expect(service.track({ userId: 'user-1', @@ -367,9 +177,5 @@ describe('productEventService', () => { action: 'payment_completed', status: 'succeeded', })).resolves.toBeUndefined() - - const rows = await db.select().from(schema.productEvents) - expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ action: 'payment_completed', status: 'succeeded' }) }) }) diff --git a/server/apps/api/src/services/domain/product-events.ts b/server/apps/api/src/services/domain/product-events.ts index 56a6bf0bc..1238bf701 100644 --- a/server/apps/api/src/services/domain/product-events.ts +++ b/server/apps/api/src/services/domain/product-events.ts @@ -1,43 +1,33 @@ -import type { Database } from '../../libs/db' -import type { ProductMetrics } from '../../otel' -import type { ProductEventMetadata } from '../../schemas/product-events' import type { PosthogSink } from '../adapters/posthog' -import { useLogger } from '@guiiai/logg' -import { and, asc, count, gte, lt, sql } from 'drizzle-orm' +import { createHash } from 'node:crypto' -import * as schema from '../../schemas/product-events' +import { useLogger } from '@guiiai/logg' const logger = useLogger('product-events') -export type ProductFeature = 'auth' | 'chat' | 'gen_ai_chat' | 'tts' | 'billing' | 'voice_pack' +const RESERVED_POSTHOG_METADATA_KEYS = new Set([ + '$insert_id', + '$session_id', + 'airi_user_id', + 'app_surface', + 'feature', + 'source', + 'status', +]) -export type ProductEventStatus = 'started' | 'succeeded' | 'failed' | 'blocked' +export type ProductFeature = 'auth' | 'billing' + +export type ProductEventStatus = 'succeeded' + +export type ProductEventMetadata = Record export type ProductAction = | 'user_signed_up' - | 'session_started' - | 'message_pushed' - | 'completion_requested' - | 'completion_succeeded' - | 'completion_failed' - | 'speech_requested' - | 'speech_succeeded' - | 'speech_failed' - | 'speech_blocked' - | 'voice_pack_created' - | 'voice_pack_updated' - | 'voice_pack_disabled' | 'checkout_started' | 'payment_completed' - | 'subscription_started' - | 'subscription_renewed' - | 'subscription_cancelled' - | 'topic_classified' -/** - * Product event fact written to AIRI's own Postgres analytics table. - */ +/** Product funnel fact forwarded to PostHog from the server. */ export interface ProductEventInput { /** Better Auth user id. Kept in Postgres only; never emitted as a Prometheus label. */ userId: string @@ -49,31 +39,10 @@ export interface ProductEventInput { status: ProductEventStatus /** Optional bounded route/surface label such as `openai.chat.completions`. */ source?: string - /** Optional model alias for DB-side drilldown. Do not expose as a Prometheus label. */ - model?: string - /** Optional provider name for DB-side drilldown. */ - provider?: string - /** Optional bounded failure reason or business outcome. */ - reason?: string /** Optional primitive metadata for product analysis. Avoid PII and raw prompts. */ metadata?: ProductEventMetadata - /** Override for tests/backfills. Defaults to database/server current time. */ - createdAt?: Date -} - -export interface ProductEventAggregateInput { - /** Inclusive lower time bound. */ - from: Date - /** Exclusive upper time bound. Omit for open-ended queries. */ - to?: Date -} - -export interface ProductEventAggregateRow { - feature: string - action: string - status: string - eventCount: number - distinctUsers: number + /** Stable source event id used by PostHog for replay-safe deduplication. */ + eventId?: string } /** Product runtime where the user initiated the AI generation. */ @@ -116,10 +85,8 @@ export interface AiGenerationEventInput { } /** - * Server-side actions worth a PostHog copy, mapped to the event name the - * client-side funnels expect. Only business facts that terminate or anchor - * a funnel are forwarded — per-request LLM/TTS volume stays in Postgres and - * Grafana where it belongs (see `docs/ai-context/metrics-ownership.md`). + * Server-side actions that anchor a PostHog product funnel. Per-request LLM + * and TTS telemetry stays in operational systems and does not enter this path. * * `user_signed_up` maps to `signup_completed` because the identified server * hook is the canonical registration fact for every signup method. Anonymous @@ -127,31 +94,8 @@ export interface AiGenerationEventInput { */ const POSTHOG_FORWARDED_ACTIONS: Partial> = { user_signed_up: 'signup_completed', + checkout_started: 'checkout_created', payment_completed: 'payment_completed', - subscription_started: 'subscription_started', - subscription_renewed: 'subscription_renewed', - subscription_cancelled: 'subscription_cancelled', -} - -/** - * Builds bounded Prometheus labels from product event inputs. - */ -function metricLabels(input: ProductEventInput): Record { - const attrs: Record = { - feature: input.feature, - action: input.action, - status: input.status, - } - if (input.source) - attrs.source = input.source - if (input.reason) - attrs.reason = input.reason - - const fluxBalanceBucket = input.metadata?.flux_balance_bucket - if (typeof fluxBalanceBucket === 'string') - attrs.flux_balance_bucket = fluxBalanceBucket - - return attrs } function stringMetadata(input: ProductEventInput, key: string): string | undefined { @@ -159,24 +103,32 @@ function stringMetadata(input: ProductEventInput, key: string): string | undefin return typeof value === 'string' && value.length > 0 ? value : undefined } +function posthogEventUuid(event: string, eventId: string): string { + const digest = createHash('sha256').update(`airi:posthog:${event}:${eventId}`, 'utf8').digest() + digest[6] = (digest[6] & 0x0F) | 0x50 + digest[8] = (digest[8] & 0x3F) | 0x80 + const hex = digest.subarray(0, 16).toString('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +function hasReservedMetadataKey(metadata: ProductEventMetadata | undefined): boolean { + return metadata != null && Object.keys(metadata).some(key => RESERVED_POSTHOG_METADATA_KEYS.has(key)) +} + /** - * Creates AIRI's first-party product analytics event writer. + * Creates AIRI's server-side PostHog product analytics writer. * * Use when: - * - Server-side product behavior has a user id and should be queryable by - * distinct users, funnels, or retention windows. - * - Grafana needs low-cardinality event volume while Postgres keeps user-level - * detail. + * - A server has an authenticated user id and confirms a funnel fact. * * Expects: * - Callers pass only bounded `feature` / `action` / `status` values. - * - PII, prompts, request ids, sessions, and user ids are not written into - * Prometheus labels. User id is stored only in the DB row. + * - Callers pass only the typed funnel actions in this module. * * Returns: - * - Best-effort event writer plus a DB aggregation helper for analytics jobs. + * - A best-effort event writer. Capture errors never change the business flow. */ -export function createProductEventService(db: Database, metrics?: ProductMetrics | null, posthog?: PosthogSink | null) { +export function createProductEventService(posthog?: PosthogSink | null) { return { trackGeneration(input: AiGenerationEventInput): void { if (!posthog) @@ -222,106 +174,57 @@ export function createProductEventService(db: Database, metrics?: ProductMetrics }, async track(input: ProductEventInput): Promise { - // Postgres is the fact of record, so forwarding is gated both ways: - // the DB write comes first (a PostHog outage can't lose the row) and - // forwarding only runs when the row actually landed (a DB outage - // can't mint PostHog events with no DB backing). - let persisted = false - try { - await db.insert(schema.productEvents).values({ - userId: input.userId, - feature: input.feature, - action: input.action, - status: input.status, - source: input.source, - model: input.model, - provider: input.provider, - reason: input.reason, - metadata: input.metadata, - createdAt: input.createdAt, - }) - persisted = true - - metrics?.events.add(1, metricLabels(input)) - } - catch (err) { - logger.withError(err).withFields({ - userId: input.userId, - feature: input.feature, - action: input.action, - status: input.status, - }).warn('Failed to write product event; swallowing to protect caller') - } - - // PostHog copy so browser funnels (identified by the same Better Auth - // user id) get their server-side terminator events. const forwardedEvent = POSTHOG_FORWARDED_ACTIONS[input.action] - if (persisted && posthog && forwardedEvent) { - try { - const posthogDistinctId = stringMetadata(input, 'posthog_distinct_id') - const posthogSessionId = stringMetadata(input, 'posthog_session_id') - if (posthogDistinctId && posthogDistinctId !== input.userId) { - await posthog.capture({ - distinctId: input.userId, - event: '$identify', - properties: { - $anon_distinct_id: posthogDistinctId, - airi_user_id: input.userId, - ...(posthogSessionId && { $session_id: posthogSessionId }), - }, - }) - } + if (!posthog || !forwardedEvent) + return + if (hasReservedMetadataKey(input.metadata)) { + logger.withFields({ action: input.action }).warn('Rejected reserved PostHog product event metadata') + return + } + + const posthogDistinctId = stringMetadata(input, 'posthog_distinct_id') + const posthogSessionId = stringMetadata(input, 'posthog_session_id') + if (posthogDistinctId && posthogDistinctId !== input.userId) { + try { await posthog.capture({ distinctId: input.userId, - event: forwardedEvent, + event: '$identify', properties: { - app_surface: 'server', + ...(input.eventId && { $insert_id: input.eventId }), + $anon_distinct_id: posthogDistinctId, airi_user_id: input.userId, - ...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }), ...(posthogSessionId && { $session_id: posthogSessionId }), - feature: input.feature, - status: input.status, - ...(input.source && { source: input.source }), - ...(input.reason && { reason: input.reason }), - ...input.metadata, }, + ...(input.eventId && { uuid: posthogEventUuid('$identify', input.eventId) }), }) } catch (err) { - // The sink contract already swallows transport errors; this guard - // is the last line so a misbehaving sink can never fail the - // webhook/auth flow that produced the business fact. - logger.withError(err).withFields({ action: input.action }).warn('PostHog forwarding threw; product event already persisted') + logger.withError(err).withFields({ action: input.action }).warn('PostHog anonymous identity capture failed') } } - }, - async countDistinctUsersByFeature(input: ProductEventAggregateInput): Promise { - const where = input.to - ? and(gte(schema.productEvents.createdAt, input.from), lt(schema.productEvents.createdAt, input.to)) - : gte(schema.productEvents.createdAt, input.from) - - const rows = await db - .select({ - feature: schema.productEvents.feature, - action: schema.productEvents.action, - status: schema.productEvents.status, - eventCount: count(), - distinctUsers: sql`count(distinct ${schema.productEvents.userId})::int`, + try { + await posthog.capture({ + distinctId: input.userId, + event: forwardedEvent, + properties: { + ...input.metadata, + ...(input.eventId && { $insert_id: input.eventId }), + app_surface: 'server', + airi_user_id: input.userId, + ...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }), + ...(posthogSessionId && { $session_id: posthogSessionId }), + feature: input.feature, + status: input.status, + ...(input.source && { source: input.source }), + }, + ...(input.eventId && { uuid: posthogEventUuid(forwardedEvent, input.eventId) }), }) - .from(schema.productEvents) - .where(where) - .groupBy(schema.productEvents.feature, schema.productEvents.action, schema.productEvents.status) - .orderBy(asc(schema.productEvents.feature), asc(schema.productEvents.action), asc(schema.productEvents.status)) - - return rows.map(row => ({ - feature: row.feature, - action: row.action, - status: row.status, - eventCount: Number(row.eventCount), - distinctUsers: Number(row.distinctUsers), - })) + } + catch (err) { + logger.withError(err).withFields({ action: input.action }).warn('PostHog product analytics capture failed') + } }, } } diff --git a/server/apps/api/src/utils/observability.ts b/server/apps/api/src/utils/observability.ts index d42189c2d..9d263b845 100644 --- a/server/apps/api/src/utils/observability.ts +++ b/server/apps/api/src/utils/observability.ts @@ -85,11 +85,6 @@ export const METRIC_AIRI_TTS_PREFLIGHT_REJECTIONS = 'airi.billing.tts.preflight_ // AIRI observability — self-monitoring for the metric pipeline export const METRIC_AIRI_OBSERVABILITY_READ_ERRORS = 'airi.observability.read_errors' -// Product analytics — low-cardinality event volume only. User-level product -// analytics live in Postgres `product_events`; never add user identifiers to -// this metric's labels. -export const METRIC_AIRI_PRODUCT_EVENTS = 'airi.product.events' - // AIRI revenue — actual money in (smallest currency unit, e.g. cents) export const METRIC_AIRI_STRIPE_REVENUE = 'airi.stripe.revenue' diff --git a/server/apps/auth/src/auth.ts b/server/apps/auth/src/auth.ts index b4616c3ff..7cd9f4230 100644 --- a/server/apps/auth/src/auth.ts +++ b/server/apps/auth/src/auth.ts @@ -787,11 +787,6 @@ export function createAuth( .set({ lastSeenAt: new Date() }) .where(eq(authSchema.user.id, session.userId)) .catch(err => logger.withError(err).withFields({ userId: session.userId }).warn('Failed to update user lastSeenAt; continuing session create')) - void resourceApi?.trackAuthEvent({ - userId: session.userId, - action: 'session_started', - source: 'better-auth.session.create', - }) }, }, }, diff --git a/server/apps/auth/src/resource-api.ts b/server/apps/auth/src/resource-api.ts index ee30d4123..87284ee11 100644 --- a/server/apps/auth/src/resource-api.ts +++ b/server/apps/auth/src/resource-api.ts @@ -6,8 +6,8 @@ export type UserDeletionReason = 'user-requested' | 'admin' | 'compliance' export interface AuthEventInput { userId: string - action: 'user_signed_up' | 'session_started' - source: 'better-auth.user.create' | 'better-auth.session.create' + action: 'user_signed_up' + source: 'better-auth.user.create' } /**