feat(analytics): add product instrumentation for activation and voice flows (#2023)

This commit is contained in:
Lovehsigure_520
2026-06-30 23:36:08 +08:00
committed by GitHub
parent f8d3e3db2c
commit 3d7a8300d8
33 changed files with 3487 additions and 135 deletions
+6
View File
@@ -33,6 +33,10 @@
- 全量 metric 目录(按域分组:HTTP / Auth / Engagement / Revenue / GenAI / Email / Rate limit / Runtime),含名字、类型、Labels、落点
- `metrics-ownership.md`
- 指标分层规则:什么走 Grafana / 什么走 PostHog / 什么是 Postgres truth;含 7 题判定 Checklist、PostHog 事件命名约定、当前指标归属总表、PostHog 接入路线图
- `product-analytics-instrumentation.md`
- 面向社区 / 产品问题的埋点补充方案:上手激活、Provider 配置、TTS 音色、语音输入、反馈、看板和异常播报
- `product-analytics-dashboard-setup.md`
- 产品分析看板落地说明:PostHog insights、Grafana 产品事件面板、告警表达式;不含 Discord / QQ 同步和日报 / 周报脚本
- `auth-and-oidc.md`
- 认证与 OIDC Provider 架构、登录流程、trusted clients、踩坑记录
- `email-auth-resend.md`
@@ -55,6 +59,8 @@
- 70.2K 历史漏账的取证 SQL + Loki query 模板、处理决策框架、修补后的监控建议
- `verifications/admin-user-balance-ban.md`
- Admin role 鉴权 / 封禁热路径闸 / 改余额:role adminGuard、resolveRequestAuth+userinfo 封禁、setFlux 的真实 PGlite/Hono 执行证据(含 flux-grants 集成测试走 role),及 better-auth admin 端点本身待端到端实测
- `verifications/product-analytics-smoke.md`
- 产品分析埋点上线冒烟清单:PostHog journey events、Postgres TTS metadata、Grafana Product Analytics row、Prometheus label 安全边界
## 快速结论
@@ -0,0 +1,305 @@
# Product Analytics Dashboard Setup
This document turns `product-analytics-instrumentation.md` into dashboard setup steps. It intentionally excludes Discord / QQ ingestion and daily / weekly report automation.
After setting up the dashboards, run `verifications/product-analytics-smoke.md` against the deployed environment.
## Scope
In scope:
- PostHog insights for frontend product journeys.
- Grafana panels for server-side product event health.
- Alert rules that can be configured directly in PostHog / Grafana.
Out of scope for this pass:
- Discord / QQ bot or spreadsheet synchronization.
- Daily / weekly report generation scripts.
## Destination Rules
| Question | Destination | Reason |
|---|---|---|
| Can a user start chatting? | PostHog | Frontend journey and distinct user funnels |
| Where does provider setup fail? | PostHog | Provider config events are frontend PostHog events |
| Which voice is selected or previewed? | PostHog / Postgres metadata | `voice_id` is high-cardinality and must not be a Prometheus label |
| Is server TTS healthy right now? | Grafana | Server product events and OTel metrics are Prometheus-safe |
| Are users submitting feedback? | PostHog | App feedback is frontend product analytics |
## PostHog Dashboard
Create a dashboard named `AIRI Activation And Feedback`.
Live dashboard created on 2026-06-30:
- Project: `Project AIRI (Web)` (`90721`)
- URL: `https://us.posthog.com/project/90721/dashboard/1779029`
- Current cards:
- Text card: `AIRI product analytics runbook`
- Funnel: `Chat activation funnel`
- Trend: `Provider config failures`
- Trend: `TTS voice selection and preview`
- Trend: `Top selected TTS voices`
- Trend: `Voice input friction`
- Trend: `Feedback and bug reports`
### Insight 1: Chat Activation Funnel
Type: Funnel
Steps:
1. `chat_activation_started`
2. `chat_activation_succeeded`
Breakdowns:
- `provider_mode`
- `surface`
Filters:
- Date range: last 7 days
- Exclude internal users if the project has an internal user cohort.
Watch for:
- Official provider conversion lower than custom provider conversion.
- Large drop after `chat_activation_started`.
### Insight 2: Chat Activation Failures
Type: Trends
Events:
- `chat_activation_failed`
Breakdowns:
- `failure_stage`
- `error_code`
- `provider_mode`
Display:
- Stacked bar or line chart.
Watch for:
- `failure_stage = provider_config`
- `failure_stage = model_list`
- `failure_stage = llm_response`
### Insight 3: Provider Configuration Health
Type: Funnel
Steps:
1. `provider_config_started`
2. `provider_config_succeeded`
Breakdowns:
- `provider_mode`
- `step`
Companion trend:
- Event: `provider_config_failed`
- Breakdown: `error_code`
Watch for:
- Official provider failures greater than zero for more than 15 minutes.
- `step = manual_chat_ping` failures after auto validation succeeds.
### Insight 4: Model List Health
Type: Trends
Events:
- `model_list_loaded`
- `model_list_failed`
Breakdowns:
- `provider_id`
- `provider_mode`
Watch for:
- `model_list_failed` spikes for one provider.
- High failure rate after a release.
### Insight 5: TTS Voice Selection
Type: Trends
Events:
- `voice_selected`
- `voice_preview_played`
- `voice_pack_bound`
Breakdowns:
- `voice_type`
- `tts_provider_id`
- `source`
Do not use:
- Prometheus labels for `voice_id` or `voice_pack_id`.
Use PostHog or SQL when grouping by:
- `voice_id`
- `voice_pack_id`
### Insight 6: Voice Input Friction
Type: Funnel
Steps:
1. `voice_input_started`
2. `stt_succeeded`
Companion trends:
- `microphone_permission_denied`
- `audio_device_unavailable`
- `voice_input_cancelled`
- `stt_failed`
Breakdowns:
- `stt_provider_id`
- `error_code`
- `surface`
### Insight 7: Feedback And Bug Reports
Type: Trends
Events:
- `feedback_submitted`
- `bug_report_submitted`
Breakdowns:
- `category`
- `severity`
- `entrypoint`
- `surface`
Watch for:
- `severity = blocker` spikes.
- `entrypoint = about_update_error` after desktop releases.
## Grafana Dashboard
Source of truth:
- `apps/server/otel/grafana/dashboards/build.ts`
- Generated JSON: `apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json`
The `Product Analytics` row includes:
- `Product Events (range)`
- `Product Failure %`
- `TTS Success %`
- `TTS Failed / Blocked (range)`
- `Top Product Actions (range)`
- `Product Event Rate`
- `TTS Event Rate by Source`
Live import status:
- Imported on 2026-06-30.
- Live URL: `https://projairi.grafana.net/d/ad8qbp5/airi-server-overview`
- Dashboard: `AIRI Server Overview - Product Analytics` (`ad8qbp5`)
- The live dashboard now shows the full `Product Analytics` row:
- `Product Events (range)`
- `Product Failure %`
- `TTS Success %`
- `TTS Failed / Blocked (range)`
- `Top Product Actions (range)`
- `Product Event Rate`
- `TTS Event Rate by Source`
- The generated JSON remains the source of truth for the Product Analytics / TTS panel set.
Permission notes from the import retry:
- The first import attempt with title `AIRI Server Overview` and UID `rbr55dn` showed duplicate title / UID warnings because it targets the existing dashboard.
- A second import attempt with a new title / UID (`AIRI Server Overview - Product Analytics Test`, `airi-product-analytics-test`) removed the duplicate warnings, but still did not import.
- API confirmation returned `403 Access denied`: `You'll need additional permissions to perform this action. Permissions needed: any of dashboards:create, dashboards:write`.
- The logged-in Grafana user `1260907335@qq.com` has org role `Viewer`; API metadata for `/d/rbr55dn/airi-server-overview` reports `canSave=false`, `canEdit=false`, `canAdmin=false`.
- After permissions were updated, the generated dashboard was imported from Microsoft Edge. Grafana assigned the imported dashboard UID `ad8qbp5` instead of overwriting the earlier `rbr55dn` dashboard, so the imported dashboard was renamed to `AIRI Server Overview - Product Analytics` to avoid ambiguity.
Regenerate after dashboard changes:
```bash
node node_modules/tsx/dist/cli.mjs apps/server/otel/grafana/dashboards/build.ts
```
## Alert Setup
### PostHog Alerts
Configure these as insight subscriptions or monitor-style alerts.
| Alert | Insight | Trigger |
|---|---|---|
| Activation drop | Chat Activation Funnel | `chat_activation_succeeded / chat_activation_started` drops by 15% vs previous 24h |
| Provider config regression | Provider Configuration Health | Official provider `provider_config_failed` is greater than 0 for 15 minutes |
| Voice input spike | Voice Input Friction | `stt_failed / voice_input_started` exceeds 20% over 1h |
| Feedback spike | Feedback And Bug Reports | `bug_report_submitted` doubles vs previous 24h |
### Grafana Alerts
Use these PromQL expressions from the server dashboard context.
TTS success below 95% over 15 minutes:
```promql
100 * sum(increase(airi_product_events_total{feature="tts", action="speech_succeeded", status="succeeded"}[15m]))
/
clamp_min(sum(increase(airi_product_events_total{feature="tts", action="speech_requested", status="started"}[15m])), 1)
< 95
```
TTS blocked spike over 15 minutes:
```promql
sum(increase(airi_product_events_total{feature="tts", action="speech_blocked", status="blocked"}[15m])) > 10
```
TTS failed spike over 15 minutes:
```promql
sum(increase(airi_product_events_total{feature="tts", action="speech_failed", status="failed"}[15m])) > 5
```
Product failure ratio above 10% over 15 minutes:
```promql
100 * sum(increase(airi_product_events_total{feature!="", action!="", status="failed"}[15m]))
/
clamp_min(sum(increase(airi_product_events_total{feature!="", action!=""}[15m])), 1)
> 10
```
## Verification Checklist
- PostHog can show `chat_activation_started -> chat_activation_succeeded` by `provider_mode`.
- PostHog can show `voice_selected` by `voice_type` and `tts_provider_id`.
- PostHog can show `feedback_submitted` and `bug_report_submitted`.
- Grafana dashboard JSON contains `TTS Success %`, `TTS Failed / Blocked (range)`, and `TTS Event Rate by Source`.
- Grafana product analytics panels use only bounded labels: `feature`, `action`, `status`, `source`.
@@ -0,0 +1,891 @@
# Product Analytics Instrumentation Plan(产品埋点与数据播报方案)
这份文档把 AIRI 当前埋点现状、社区侧最关心的问题、缺口、事件 schema、看板和异常播报整理到一处。它补充 [`metrics-ownership.md`](./metrics-ownership.md):后者定义指标归属和命名规则,本文定义“为了回答产品/社区问题,接下来要补什么”。
## TL;DR
- 现在已经能看 activation、Provider 配置次数、国家来源、付费漏斗、LLM 请求和服务端 TTS 请求健康。
- 现在还不能可靠回答“用户最常用哪个 TTS 音色 / Voice Pack”,因为前端和服务端事件都缺稳定的 `voice_id` / `voice_type` / `voice_pack_id`
- 最优先补的不是更多点击,而是“能不能开始聊天”“卡在哪个配置步骤”“语音 / TTS 为什么失败”。
- 官方 Provider 要和自配置 Provider 分开看,核心判断是官方路径是否提高 activation、降低配置失败和缩短首次聊天时间。
- 自配置用户可能更高价值,不能只看失败率;需要同时看 retention、paid conversion 和 feedback rate。
- PostHog 负责用户路径、漏斗、留存和分群;Grafana 负责服务端健康和异常;Postgres / SQL 负责高基数字段聚合,例如 voice / voice pack。
- Prometheus 不要加 `voice_id``voice_pack_id`、用户自定义模型名等高基数字段。
- 日报先做“指标 + 异常提醒”,周报再加入社区侧解释和下周行动建议。
- Discord / QQ 反馈要有轻量标签,不能完全靠埋点替代社区观察。
## 背景
社区反馈里最常见的劝退点集中在:
- 性能问题
- 模型 / Provider 配置复杂
- 配置失败或模型列表加载失败
- Bug 多,用户不知道卡在哪
- 语音输入体验不稳定
产品方向是降低上手门槛,让 AIRI 更开箱即用;官方 Provider 已经上线,但仍有一部分用户喜欢自配置模型和音色。社区侧同时关心海外用户、付费用户、二次开发用户、角色聊天用户,以及愿意反馈问题的用户。
因此埋点目标不是单纯统计点击,而是回答:
1. 新用户有没有正常开始聊天?
2. 用户卡在 Provider / 模型配置的哪一步?
3. 官方 Provider 是否真的降低了上手门槛?
4. 自配置用户和官方 Provider 用户在留存、付费、反馈上有什么差异?
5. TTS 音色和提供商到底怎么被选择、试听和实际使用?
6. Bug、性能、语音输入失败分别造成了多少流失?
## 现状审计
### PostHog
线上 `Project AIRI (Web)` 已有核心 dashboard`My App Dashboard`
已覆盖:
- first message 趋势
- Provider 配置次数
- 国家来源
- Signup activation funnel
- 7-day activation retention
- Paywall conversion
- LLM request volume / error rate
- rage-click friction
近 7 天仍活跃的主要产品事件包括:
- `app_loaded`
- `first_message_sent`
- `first_model_selected`
- `model_switched`
- `message_send_started`
- `llm_request_started`
- `llm_first_token`
- `message_round`
- `chat_session_started`
- `chat_session_selected`
- `chat_message_deleted`
- `chat_messages_cleared`
- `pricing_page_viewed`
- `plan_selected`
- `checkout_started`
- `provider_card_clicked`
- `stt_started`
- `stt_succeeded`
- `stt_failed`
- `tts_stop_clicked`
- `character_switched`
- `character_deleted`
PostHog 当前缺口:
- 没有 `voice_id``voice_pack_id``voice_type` 等 TTS 音色维度。
- 近 30 天匹配 `tts` / `speech` / `voice` 的客户端事件里,`voice``voice_id``voiceId``voice_pack_id``voice_type` 均未出现有效值。
- `model` / `model_id` 仍有自由文本风险,需要收敛白名单和脱敏策略。
- PostHog 漏斗主要覆盖前端旅程,服务端 TTS 真实请求没有同步进入 PostHog。
### Grafana / Prometheus
线上 `AIRI Server Overview` 已覆盖:
- DAU / WAU / MAU
- Active sessions
- HTTP / WS health
- Product Analyticsevent volume、failure rate、top product actions、event rate
- LLM Gatewayrequest rate by model、latency、provider failure
- TTS Characters/s by Model
- Stripe / revenue
Grafana 当前能回答:
- TTS 请求量、成功量、失败量、余额挡住量。
- TTS 字符消耗按 model 聚合,例如 `stepfun/stepaudio-2.5-tts``volcengine/seed-tts-2.0``alibaba/cosyvoice-v1`
- 服务端 `product_events` 的 Prometheus label 当前包括 `feature``action``status``source`,再加部分 metric 的 `model` / `provider`
Grafana 当前不能回答:
- 哪个 TTS 音色使用最多。
- 哪个 Voice Pack 使用最多。
- 官方默认音色与用户自选音色的转化差异。
- 试听音色后是否真的绑定 / 使用。
## 用户分群
第一版不要求用户手动选 persona,先从行为推断。
| Segment | 初始推断规则 | 用途 |
|---|---|---|
| `new_user` | 账号创建后 7 天内或首次 `app_loaded` 后 7 天内 | 新手引导、激活漏斗 |
| `official_provider_user` | 关键事件里 `provider_mode = official` | 衡量开箱即用效果 |
| `custom_provider_user` | 关键事件里 `provider_mode = custom` | 衡量自配置门槛和失败率 |
| `role_chat_user` | 导入 / 切换角色、绑定音色、频繁角色聊天 | 角色陪伴体验 |
| `developer_user` | 使用插件、API、Devtools、二开相关入口 | 二次开发群体 |
| `paid_user` | Stripe / Postgres 付费状态为真 | 付费转化与保留 |
| `feedback_user` | 提交 bug report / feedback | 高价值社区用户 |
公共字段建议:
| Field | Values | Notes |
|---|---|---|
| `surface` | `web` / `desktop` / `mobile` | 所有关键前端事件必带 |
| `provider_mode` | `official` / `custom` / `unknown` | 官方开箱即用 vs 用户自配置 |
| `provider_id` | 白名单 ID | 不传 raw URL / raw key / 用户输入 |
| `model_id` | 白名单或归一化后的 ID | 自定义模型用 `is_custom_model = true` |
| `is_custom_model` | boolean | 避免把任意文本当 group-by |
| `is_paid_user` | boolean | 从服务端或 PostHog person profile 派生 |
| `setup_completed` | boolean | 是否完成基础配置 |
| `region` | PostHog geo | 不由客户端手动传 |
## P0 埋点
### Chat Activation
目标:社区侧判断“能正常开始聊天”。
| Event | Owner | Truth | When |
|---|---|---|---|
| `chat_activation_started` | frontend | PostHog | 用户进入首次聊天路径或点击发送第一条消息前 |
| `chat_activation_succeeded` | frontend | PostHog | 首次消息完成并看到 assistant response |
| `chat_activation_failed` | frontend | PostHog | 首次消息未完成,包含配置、网络、鉴权、余额、模型等失败 |
字段:
| Field | Required | Notes |
|---|---|---|
| `provider_mode` | yes | `official` / `custom` |
| `provider_id` | yes | 归一化 ID |
| `model_id` | yes | 归一化 ID |
| `surface` | yes | web / desktop / mobile |
| `time_to_first_message_ms` | success only | 从 app start 或 onboarding complete 到首次成功 |
| `error_code` | failed only | 稳定错误码 |
| `failure_stage` | failed only | `provider_config` / `model_list` / `message_send` / `llm_response` / `tts` |
推荐看板:
- 新用户 `chat_activation_started -> chat_activation_succeeded` 漏斗。
-`provider_mode` 拆分 activation conversion。
- `chat_activation_failed``failure_stage` / `provider_id` 排名。
异常提醒:
- 新用户 activation conversion 24h 环比下降超过 15%。
- `provider_mode = official` 的 activation failure 上升,优先排查官方 Provider。
### Provider And Model Configuration
目标:定位配置复杂和失败劝退。
| Event | Owner | Truth | When |
|---|---|---|---|
| `provider_config_started` | frontend | PostHog | 打开 Provider 配置表单 |
| `provider_config_succeeded` | frontend | PostHog | 配置保存并通过最小校验 |
| `provider_config_failed` | frontend | PostHog | 保存、校验、鉴权或连接测试失败 |
| `model_list_loaded` | frontend | PostHog | 模型列表加载成功 |
| `model_list_failed` | frontend | PostHog | 模型列表加载失败 |
字段:
| Field | Required | Notes |
|---|---|---|
| `provider_id` | yes | 归一化 ID |
| `provider_mode` | yes | 官方 Provider 也要记录 |
| `step` | yes | `open_form` / `save` / `validate` / `load_models` / `select_model` |
| `duration_ms` | no | 保存或加载耗时 |
| `error_code` | failed only | 不能写 raw error message |
| `http_status` | failed only | 有 HTTP 边界时记录 |
推荐看板:
- Provider 配置成功率。
- 模型列表加载成功率。
- Top failing providers。
- 官方 Provider vs 自配置 Provider 配置耗时和失败率。
异常提醒:
- `model_list_failed` 任一 Provider 1h 内增长超过 2 倍。
- 官方 Provider `provider_config_failed` 非零连续 15 分钟。
### TTS Voice And Provider
目标:回答“用户选择了哪种音色和提供商”。
| Event | Owner | Truth | When |
|---|---|---|---|
| `tts_provider_selected` | frontend | PostHog | 用户选择或切换 TTS Provider |
| `voice_selected` | frontend | PostHog | 用户选择音色,包括官方默认落地时的 baseline |
| `voice_preview_played` | frontend | PostHog | 用户试听音色 |
| `voice_pack_bound` | frontend | PostHog | 用户把 Voice Pack 绑定到角色 |
| `speech_requested` | server | Postgres/Grafana | REST / WS TTS 请求开始;沿用现有 `feature = tts` action |
| `speech_succeeded` | server | Postgres/Grafana | REST / WS TTS 交付成功;沿用现有 `feature = tts` action |
| `speech_failed` | server | Postgres/Grafana | TTS 上游、配置、路由失败;沿用现有 `feature = tts` action |
| `speech_blocked` | server | Postgres/Grafana | Flux 不足等业务阻断;沿用现有 `feature = tts` action |
字段:
| Field | Required | Notes |
|---|---|---|
| `tts_provider_id` | yes | 归一化 ID |
| `tts_model_id` | yes | 归一化 ID |
| `voice_id` | yes | 官方 catalog ID;自定义音色见数据卫生 |
| `voice_type` | yes | `official_default` / `official_selected` / `custom_configured` / `voice_pack` |
| `voice_pack_id` | no | Voice Pack 绑定和服务端使用时记录 |
| `source` | yes | `settings` / `onboarding` / `chat_auto_tts` / `manual_preview` |
| `trigger` | server TTS | `auto` / `manual` |
| `input_chars` | server TTS | 字数 |
| `duration_ms` | server TTS | 端到端耗时 |
| `error_code` | failed only | 稳定错误码 |
推荐看板:
- Top voices by `voice_selected`
- Top voices by server `feature = tts` + `action = speech_succeeded`
- Voice preview to selection conversion。
- Official default vs official selected vs custom configured。
- TTS failure / blocked rate by provider, model, voice type。
异常提醒:
- 某个官方音色的 server `speech_failed` 持续上升。
- `voice_preview_played -> voice_selected` 转化下降。
- server `speech_blocked` 突增,提示 Flux 或默认策略可能影响体验。
### Voice Input
目标:语音输入是明确社区痛点,需要把权限、设备和 Provider 失败拆开。
当前已有:
- `stt_started`
- `stt_succeeded`
- `stt_failed`
补充:
| Event | Owner | Truth | When |
|---|---|---|---|
| `voice_input_started` | frontend | PostHog | 用户开始语音输入 |
| `microphone_permission_requested` | frontend | PostHog | 首次或重新请求麦克风权限 |
| `microphone_permission_denied` | frontend | PostHog | 权限被拒绝 |
| `audio_device_unavailable` | frontend | PostHog | 无设备、设备被占用、采样失败 |
| `voice_input_cancelled` | frontend | PostHog | 用户主动取消或超时 |
字段:
| Field | Required | Notes |
|---|---|---|
| `stt_provider_id` | yes | 归一化 ID |
| `surface` | yes | web / desktop / mobile |
| `duration_ms` | no | 用户按住或录音时长 |
| `error_code` | failed only | `permission_denied` / `device_unavailable` / `provider_error` / `timeout` |
推荐看板:
- Voice input start -> STT success funnel。
- Permission denied rate by browser / surface。
- STT failure rate by Provider。
### Feedback And Bug Reports
目标:社区侧认为“愿意反馈问题”是高价值行为,应进入核心指标。
| Event | Owner | Truth | When |
|---|---|---|---|
| `bug_report_opened` | frontend | PostHog | 打开 bug report dialog |
| `bug_report_submitted` | frontend/server | PostHog + Postgres optional | 成功提交 |
| `feedback_submitted` | frontend/server | PostHog + Postgres optional | 非 bug 的反馈 |
| `community_feedback_tagged` | manual/job | Postgres optional | Discord / QQ 反馈被人工归类 |
字段:
| Field | Required | Notes |
|---|---|---|
| `source` | yes | `app` / `discord` / `qq` / `github` / `email` / `other` |
| `category` | no | 见下方社区反馈标签 |
| `severity` | yes | `blocker` / `major` / `minor` / `suggestion` |
| `user_type` | yes | `new_user` / `paid_user` / `overseas_user` / `developer_user` / `role_chat_user` / `unknown` |
| `entrypoint` | yes | `about_update_error` / `community_manual_tag` 等低基数入口 |
| `surface` | in-app | web / desktop / mobile |
| `provider_mode` | no | 可从最近一次配置状态补齐 |
| `description_length_bucket` | bug report | `empty` / `short` / `medium` / `long`,不要上传正文 |
| `include_triage_context` | bug report | 是否附带页面上下文 |
| `screenshot_attached` | bug report | 是否附带截图或录屏 |
推荐看板:
- Feedback users count。
- Bug report trend by category。
- 配置失败后 24h 内是否反馈。
社区反馈标签建议:
| Category | 适用反馈 |
|---|---|
| `performance` | 卡顿、慢、首 token 慢、内存 / CPU 异常 |
| `provider_config` | Provider 配置复杂、保存失败、Key / Endpoint 不知道怎么填 |
| `model_list` | 模型列表加载失败、模型不可选、模型名不符合预期 |
| `crash` | 崩溃、白屏、不可恢复异常 |
| `ui_ux` | UI 操作不顺、按钮找不到、信息表达不清 |
| `voice_input` | 麦克风权限、录音、STT、语音输入失败 |
| `tts` | TTS 音色、试听、Voice Pack、音色绑定问题 |
| `payment` | Flux、余额不足、付费、checkout、扣费疑问 |
| `chat_activation` | 新手无法开始聊天、首次发送失败、开箱即用失败 |
| `live2d` | Live2D / 模型显示 / 角色舞台问题 |
| `desktop_window` | 桌宠窗口、置顶、显示器、多屏问题 |
| `mobile` | iOS / Android / 移动端体验 |
| `unknown` | 信息不足,待社区负责人二次归类 |
社区侧现在可以这样做:
1. Discord / QQ 反馈先人工记录,不等 App 内入口完善。
2. 每条反馈只需要填:日期、来源、用户类型、分类、严重程度、原文链接、简短摘要、是否已转 issue。
3. 原文和截图留在 Discord / QQ / issue,不进 PostHogPostHog 只放标签和计数。
4. 每周把 `category` + `severity` 聚合进周报,用来解释为什么某个指标变差。
## P1 埋点
### Onboarding
当前 `onboarding_step_completed` 不足以定位用户卡点。
补充:
- `onboarding_started`
- `onboarding_step_viewed`
- `onboarding_step_completed`
- `onboarding_skipped`
- `onboarding_failed`
字段:
- `step`
- `time_spent_ms`
- `provider_mode`
- `provider_id`
- `error_code`
看板:
- Onboarding step drop-off。
- 官方 Provider onboarding conversion。
- Skip 后是否仍完成 `chat_activation_succeeded`
### Chat Reliability
补充:
- `message_send_failed`
- `assistant_response_failed`
- `generation_cancelled`
- `generation_retried`
- `response_regenerated`
字段:
- `model_id`
- `provider_id`
- `provider_mode`
- `has_voice`
- `latency_ms`
- `error_code`
- `surface`
看板:
- Message send success rate。
- Retry / cancel rate。
- Failure by provider and model。
### Character And Role Usage
补充:
- `character_created`
- `character_imported`
- `character_edited`
- `character_switched`
- `character_deleted`
- `display_model_changed`
- `voice_pack_bound`
字段:
- `character_type`: `built_in` / `imported` / `custom`
- `has_voice`
- `voice_type`
- `surface`
看板:
- Character adoption。
- 角色用户与普通聊天用户 retention / payment 差异。
### Payment And Flux Friction
当前已有 `pricing_page_viewed``plan_selected``checkout_started`,服务端有 payment truth。补充:
- `flux_low_warning_shown`
- `flux_topup_clicked`
- `checkout_failed`
- `payment_completed_imported`
字段:
- `surface`
- `balance_state`
- `plan_id`
- `currency`
- `error_code`
注意:
- `payment_completed` 真相仍在 Postgres / Stripe webhook。
- PostHog 只用于漏斗展示,优先用 Stripe connector 或离线导入。
## 事件复用关系
新增埋点时先复用现有事件,不要把相同事实拆成多个名字。
| Existing / New | Relationship | Notes |
|---|---|---|
| `first_message_sent` | 保留历史指标 | 继续用于老 dashboard;新激活口径用 `chat_activation_succeeded` |
| `chat_activation_started` / `chat_activation_succeeded` / `chat_activation_failed` | 新核心 activation 口径 | 用来回答“用户能不能正常开始聊天” |
| `provider_card_clicked` | 保留入口点击 | 不等于配置成功;成功 / 失败看 `provider_config_succeeded` / `provider_config_failed` |
| `first_model_selected` / `model_switched` | 保留模型选择行为 | 配置链路和模型列表健康看 `model_list_loaded` / `model_list_failed` |
| `stt_started` / `stt_succeeded` / `stt_failed` | 保留 STT Provider 结果 | 权限和设备问题用新增 `microphone_*` / `audio_device_unavailable` 拆开 |
| `tts_stop_clicked` | 保留用户停止行为 | 不代表音色选择;音色选择用 `voice_selected` |
| `speech_requested` / `speech_succeeded` / `speech_failed` / `speech_blocked` | 服务端 TTS truth | 继续沿用 `feature = tts`,只补 metadata 字段 |
| `pricing_page_viewed` / `plan_selected` / `checkout_started` | 保留付费漏斗前段 | 真正 payment completed 仍以 Stripe / Postgres 为准 |
## 数据卫生
### 不要把自由文本直接作为分析维度
PostHog 线上已经能看到 `model` / `model_id` 存在自由文本风险。后续新增字段必须遵循:
- Provider、model、voice 使用稳定 ID。
- 自定义值不要直接 group-by。
- 自定义模型传:
- `provider_id = custom`
- `model_family = custom`
- `is_custom_model = true`
- `custom_model_hash` 可选,必须单向 hash,不能还原原文。
- 自定义 voice 传:
- `voice_type = custom_configured`
- `voice_id = custom`
- `custom_voice_hash` 可选,必须单向 hash。
- 错误字段传稳定 `error_code`,不要传 raw error message。
### 字段基数约束
| Field | Cardinality | Rule |
|---|---|---|
| `provider_id` | low | 白名单 |
| `provider_mode` | low | enum |
| `model_id` | medium | 官方 catalog 或归一化 ID |
| `voice_id` | medium | 官方 catalog 或 `custom` |
| `voice_pack_id` | medium | 只进 PostHog / Postgres,不进 Prometheus label |
| `error_code` | low | enum |
| `source` | low | enum |
| `surface` | low | enum |
Prometheus label 不放 `user_id``session_id``voice_pack_id`、自定义模型名、自定义音色名。
### 不要做什么
- 不要把用户输入、聊天正文、角色 prompt、raw API key、raw Endpoint、raw model name、raw voice name 发到 PostHog / Grafana。
- 不要把 raw error message 作为分析字段;统一映射成稳定 `error_code`
- 不要在 Prometheus label 里加入 `voice_id``voice_pack_id``session_id``user_id`、自定义模型名、自定义音色名。
- 不要为了看一个 funnel 同时新增两个语义相同的事件;优先查上面的事件复用关系。
- 不要在请求主链路里同步等待 PostHog 发送完成;失败不能影响用户聊天 / TTS。
- 不要只看点击量判断用户意图;至少结合 success / failed / blocked 和社区反馈标签。
## 看板建议
### PostHog
新建或扩展 dashboard`Onboarding and Activation`
- 新用户 activation funnel
- `app_loaded`
- `chat_activation_started`
- `provider_config_succeeded`
- `model_list_loaded`
- `chat_activation_succeeded`
- Official vs custom Provider activation split。
- Provider configuration failure ranking。
- Onboarding step drop-off。
新建 dashboard`Voice and TTS Adoption`
- Top voices by selected users。
- Top voices by successful TTS requests。
- Voice preview -> selection conversion。
- Official default vs selected vs custom configured。
- Voice input start -> STT success funnel。
扩展 `My App Dashboard`
- 保留现有 activation、paywall、LLM、rage-click。
- 增加 `chat_activation_succeeded``provider_config_failed``stt_failed``voice_selected` 摘要卡。
### Grafana
扩展 `AIRI Server Overview`
- TTS request / success / failed / blocked by source。
- TTS character total by model over range。
- Product action failure rate by feature/action。
不要在 Prometheus 增加 `voice_id` label。若要看音色排行:
- Postgres `product_events.metadata.voice_id` 做 SQL / admin API 聚合。
- 或离线导入 PostHog,用 PostHog group-by 展示。
## 播报、分析和可视化方案
### 分层原则
播报要分三层,不要把所有指标塞进一个 dashboard。
| Layer | Frequency | Audience | Goal | Tool |
|---|---|---|---|---|
| 实时异常 | 5m - 1h | 工程 / on-call / 社区负责人 | 发现“今天是不是坏了” | Grafana alert + PostHog insight alert |
| 日报 | daily | 社区 / 产品 / 工程 | 看上手、聊天、语音、付费是否正常 | PostHog + Grafana + 少量 SQL |
| 周报 | weekly | 产品 / 战略讨论 | 看趋势、用户意图、投入方向 | PostHog cohort/funnel + SQL 聚合 |
工具分工:
| Tool | 用途 | 不适合做什么 |
|---|---|---|
| PostHog | 用户路径、漏斗、留存、分群、前端行为 | 服务端真实扣费、低延迟 on-call |
| Grafana | 服务端健康、TTS/LLM 请求、错误、余额阻断、告警 | 高基数用户行为和音色排行 |
| Postgres / SQL | 付费事实、`product_events.metadata`、voice / voice pack 聚合 | 实时看板和复杂前端路径 |
| Community tags | Discord / QQ 反馈归类 | 自动替代埋点 |
### 日报模板
日报回答“今天是否健康,有没有需要马上处理的问题”。
```md
# AIRI 数据日报 YYYY-MM-DD
## 核心状态
- DAU: <value> (<day-over-day>)
- New users: <value>
- Chat activation: <chat_activation_succeeded / chat_activation_started>
- Official provider activation: <value>
- Custom provider activation: <value>
- Paid conversion proxy: pricing -> plan -> checkout = <value>
## 上手和配置
- Top provider config failures:
1. <provider_id> / <error_code> / <count>
2. <provider_id> / <error_code> / <count>
- Model list failure rate: <value>
- New-user first-message median time: <value>
## 聊天与性能
- Message round success: <value>
- LLM first-token p95: <value>
- LLM / chat failures by provider:
1. <provider_id> / <error_code> / <count>
## 语音与 TTS
- STT success rate: <value>
- Microphone permission denied: <value>
- TTS success / failed / blocked: <succeeded>/<failed>/<blocked>
- Top voices selected:
1. <voice_id> / <users>
2. <voice_id> / <users>
- Top voices used:
1. <voice_id> / <successful_requests>
2. <voice_id> / <successful_requests>
## 反馈与异常
- Bug reports: <value>
- Feedback submitted: <value>
- Discord / QQ 高关键词:
- <category>: <count>
- 异常提醒:
- <alert_name>: <current> vs <baseline>, 建议动作 <action>
```
第一版日报可以先不追求自动生成完整解释,只要自动填指标,并把异常规则命中的项放到“异常提醒”即可。
### 周报模板
周报回答“用户意图有什么变化,战略上要改什么”。
```md
# AIRI 数据周报 YYYY-WW
## 本周结论
1. <最重要趋势,例如 official provider 激活率上升>
2. <最大风险,例如 custom provider 配置失败仍高>
3. <建议动作,例如下周优先修 model list failed>
## 新手上手
- New users: <value>
- Activation funnel:
- app_loaded -> chat_activation_started: <value>
- chat_activation_started -> provider_config_succeeded: <value>
- provider_config_succeeded -> chat_activation_succeeded: <value>
- Official vs custom:
- official activation: <value>
- custom activation: <value>
- conclusion: <which path is healthier>
## 用户意图
- Role chat users: <value>
- Developer users: <value>
- Voice users: <value>
- Paid users: <value>
- Feedback users: <value>
- 海外用户占比: <value>
## 语音和音色
- Top selected voices: <voice_id list>
- Top used voices: <voice_id list>
- Default voice adoption: <value>
- Custom configured voice adoption: <value>
- Voice preview -> selected conversion: <value>
- TTS blocked / failed trend: <value>
## 配置和 Bug
- Top failing providers: <list>
- Top failing error codes: <list>
- Rage-click pages / surfaces: <list>
- Discord / QQ feedback categories:
- performance: <count>
- config: <count>
- bug: <count>
- voice_input: <count>
## 下周建议
- Product: <one action>
- Engineering: <one action>
- Community: <one action>
```
周报需要人工写“结论”和“建议动作”。指标只负责提示方向,社区反馈负责解释为什么。
### 可视化布局
#### Executive Overview
给产品 / 社区快速看:
- Activation conversion
- Official vs custom activation
- Provider config failures
- STT success rate
- TTS success / blocked
- Top voices selected / used
- Bug reports / feedback
- Paywall funnel
#### Onboarding And Activation
给负责上手体验的人看:
- Funnel`app_loaded -> chat_activation_started -> provider_config_succeeded -> model_list_loaded -> chat_activation_succeeded`
- Breakdown`provider_mode``surface``region`
- TableTop `provider_config_failed` by `provider_id` / `error_code`
- Timeseries`time_to_first_message_ms` p50 / p95
#### Voice And TTS Adoption
给语音和角色体验看:
- BarTop voices by selected users
- BarTop voices by successful TTS requests
- Funnel`voice_preview_played -> voice_selected -> speech_succeeded`
- Timeseries`speech_succeeded` / `speech_failed` / `speech_blocked`
- Breakdown`voice_type` = official default / official selected / custom configured / voice pack
#### Reliability And Friction
给工程和社区排障看:
- Grafana5xx、LLM latency、provider failure、TTS blocked
- PostHograge-click trend、failed frontend events
- TableTop error_code by surface / provider
- Community tagsDiscord / QQ 反馈分类趋势
### 分析方法
#### Official provider 是否降低门槛
看:
- `chat_activation_succeeded / chat_activation_started` by `provider_mode`
- `time_to_first_message_ms` by `provider_mode`
- `provider_config_failed` by `provider_mode`
- D7 retention by `provider_mode`
如果 official 激活率高、耗时短、失败率低,说明开箱即用策略有效。若 official 使用率高但失败率也高,优先修官方 Provider 稳定性。
#### 自配置用户是不是更高价值
看:
- `custom_provider_user` 的 D7 / D30 retention
- `custom_provider_user` 的 feedback rate
- `custom_provider_user` 的 paid conversion
- `custom_provider_user` 的 config failure rate
如果自配置用户付费和反馈更高,但失败率也高,可以把高级配置保留,但需要更好的错误提示和导入模板。
#### 哪些 Bug 最劝退
看:
- `chat_activation_failed` by `failure_stage`
- `provider_config_failed` by `error_code`
- `model_list_failed` by `provider_id`
- `$rageclick` by page / surface
- Discord / QQ `category = bug` 的高频词
日报只报异常;周报把异常和社区反馈合并成“优先修复建议”。
#### TTS 音色策略
看:
- `voice_selected` users by `voice_id`
- `speech_succeeded` count by `voice_id`
- `voice_preview_played -> voice_selected` conversion by `voice_id`
- `speech_failed / speech_requested` by `voice_id`
- retention / paid conversion by `voice_type`
用法:
- 选择多但使用少:可能试听不错,实际聊天不合适。
- 使用多但失败高:优先修该音色或 Provider。
- 默认音色使用高但切换少:默认可能足够好,也可能用户没发现入口,需要结合 `voice_preview_played` 看。
- 自定义音色用户留存高:说明高阶用户重视声音个性化。
### 自动化路线
第一阶段:半自动日报。
- Grafana 提供服务端健康和 TTS/LLM 指标。
- PostHog 提供 activation、provider、voice、STT、feedback 指标。
- SQL 提供 voice / voice pack 聚合。
- 由脚本生成 Markdown,发到 Discord / QQ / 飞书其中一个固定频道。
第二阶段:异常驱动播报。
- 每小时检查 activation、provider config、model list、STT、TTS blocked、bug report。
- 只有超过阈值才发提醒。
- 提醒里必须带“建议查看哪个 dashboard / query”。
第三阶段:周报带人工结论。
- 自动填指标和 Top lists。
- 社区负责人补充 Discord / QQ 反馈解释。
- 产品 / 工程共同确认下周行动。
## 异常播报
第一版日报 / 周报走“指标 + 异常提醒”。日报不要做复杂归因;周报允许加入人工结论。
### 每日必看指标
建议内容:
- DAU / WAU / MAU。
- New users。
- `chat_activation_succeeded` 转化率。
- Official vs custom Provider activation conversion。
- Top Provider config failures。
- STT success rate / failure rate。
- TTS success / failed / blocked。
- Top voices selected / used。
- Bug reports / feedback count。
- Paywall funnelpricing -> plan -> checkout。
### 异常规则
| Alert | Trigger | Action |
|---|---|---|
| Activation drop | 24h `chat_activation_succeeded / chat_activation_started` 环比下降 15% | 查 Provider config / model_list / LLM failures |
| Official Provider regression | 官方 Provider `provider_config_failed` 连续 15 分钟非零或 24h 明显上升 | 优先排官方配置 |
| Model list failure spike | 任一 Provider `model_list_failed` 1h 翻倍 | 查 Provider API / auth / CORS |
| STT failure spike | `stt_failed / stt_started` 超过阈值 | 查权限、设备、Provider |
| TTS blocked spike | `feature = tts` + `action = speech_blocked` 1h 翻倍 | 查 Flux、默认音色成本、余额提示 |
| Bug report spike | `bug_report_submitted` 24h 翻倍 | 社区同步归类 |
| Rage-click spike | `$rageclick` 7d trend 异常 | 结合页面和 session replay |
## 实施顺序
### 分阶段落地
| Phase | Scope | Owner | 产出 |
|---|---|---|---|
| Phase 0 | 字段归一化、事件复用确认、敏感字段拦截 | frontend / server | 公共 helper、事件字典、测试用例 |
| Phase 1 | Chat activation、Provider / model 配置、TTS voice 字段 | frontend / server | 能回答“能不能开始聊天”和“哪个音色常用” |
| Phase 2 | Voice input、feedback、community tags | frontend / community | 能定位语音输入和社区反馈高频问题 |
| Phase 3 | PostHog / Grafana dashboard、半自动日报、异常提醒 | data / server / community | 每日播报和异常告警可用 |
| Phase 4 | 周报、cohort、retention、付费 / 反馈关联分析 | product / community / data | 支持战略复盘和下周优先级 |
### 任务顺序
1. 先补字段归一化 helpers,尤其是 Provider / model / voice。
2. 补 Chat Activation 三个事件。
3. 补 Provider / model 配置成功失败事件。
4. 补 TTS voice 选择、试听、Voice Pack 绑定事件。
5. 服务端 `product_events.metadata` 补 TTS `voice_id``voice_type``voice_pack_id`
6. 补语音输入权限 / 设备事件。
7. 扩 PostHog dashboard。
8. 扩 Grafana dashboard,但不把 voice 放进 Prometheus label。
9. 建半自动日报 MarkdownPostHog + Grafana + SQL 聚合,先发固定频道。
10. 建异常检查:activation、provider config、model list、STT、TTS blocked、bug report。
11. 建周报模板:自动填指标,社区负责人补充 Discord / QQ 反馈解释和下周建议。
### 当前接入状态(2026-06-30
已接入代码:
- Chat activation`chat_activation_started``chat_activation_succeeded``chat_activation_failed`
- Model list`model_list_loaded``model_list_failed`
- Provider config`provider_config_started``provider_config_succeeded``provider_config_failed`
- TTS voice`tts_provider_selected``voice_selected``voice_preview_played``voice_pack_bound`
- TTS 服务端 metadataREST / WS TTS `product_events.metadata` 已补 `voice_id``voice_type``voice_pack_id`
- Voice input`voice_input_started``microphone_permission_requested``microphone_permission_denied``audio_device_unavailable``voice_input_cancelled`
- STT:保留 `stt_started``stt_succeeded``stt_failed`,并将失败码收敛到稳定枚举,避免上报 raw error。
- Feedback`feedback_submitted` / `bug_report_submitted` 的低基数字段与 analytics API 已定义;产品内反馈提交入口与服务端收件流程拆到单独 PR。
- Grafana Dashboard`Product Analytics` 行已补 TTS success、TTS failed / blocked、TTS event rate by source 面板,并保留 voice drilldown 在 Postgres metadata / PostHog,不进入 Prometheus labels。
- Dashboard setup 文档:`product-analytics-dashboard-setup.md` 已补 PostHog insights、Grafana panels、PostHog / Grafana alert 配置建议。
- 上线冒烟文档:`verifications/product-analytics-smoke.md` 已补 PostHog、Postgres、Grafana 三层验证步骤。
待接入或待产品确认:
- Discord / QQ 社区标签的数据入口,例如人工表格、bot 或 issue 同步。
- PostHog Dashboard 需要在 PostHog 账号里按 setup 文档创建 insights / alerts。
- Grafana Dashboard JSON 已更新,仍需部署 / import 到线上 Grafana。
- 日报 / 周报自动拉取脚本与提醒频道。
## 验证清单
- 新用户完成一次官方 Provider 聊天后,PostHog 能看到 `chat_activation_succeeded`
- 自配置 Provider 失败时,PostHog 能按 `provider_id` + `error_code` 聚合。
- 选择官方默认音色后,PostHog 能看到 `voice_selected``voice_type = official_default`
- 试听音色后,PostHog 能看到 `voice_preview_played`
- REST 和 WS TTS 成功后,Postgres `product_events.metadata` 能看到 `voice_id`
- Grafana 继续只按低基数字段聚合,不新增高基数 voice label。
- 日报能输出:activation conversion、Top failing providers、STT success rate、TTS success/blocked、Top voices、feedback count。
- 异常提醒命中时能带上:当前值、基线值、影响范围、建议查看的 dashboard / query。
- 周报能输出:本周结论、用户意图变化、Discord / QQ 反馈分类、下周 Product / Engineering / Community 行动建议。
@@ -0,0 +1,275 @@
# Verification: Product Analytics Smoke Test
Status: **code-level instrumentation verified; live PostHog dashboard created; Grafana dashboard imported; alert setup pending**
Owner: Community / Product Analytics
Last updated: 2026-06-30
Related:
- [`product-analytics-instrumentation.md`](../product-analytics-instrumentation.md)
- [`product-analytics-dashboard-setup.md`](../product-analytics-dashboard-setup.md)
- [`airi-server-overview-cloud.json`](../../../otel/grafana/dashboards/airi-server-overview-cloud.json)
## 用户路径
- **场景**:验证新增埋点能回答“用户是否能正常开始聊天”“Provider 配置卡在哪里”“哪个 TTS 音色被选择 / 实际播放”“语音输入卡在哪里”“用户是否提交反馈”。
- **预期**PostHog 能看到前端 journey eventsPostgres `product_events` 能看到服务端 TTS metadataGrafana 能看到低基数 server-side product health。
- **当前状态**:代码与 dashboard JSON 已验证;线上 PostHog dashboard 已创建;线上 Grafana `AIRI Server Overview - Product Analytics` (`ad8qbp5`) 已导入完整 Product Analytics rowalert 仍需人工配置。
## 已经由代码验证
| Area | Evidence |
|---|---|
| Frontend analytics API | `packages/stage-ui/src/composables/use-analytics.test.ts` 覆盖 activation、model list、provider config、voice selection、voice input、feedback event API |
| Chat activation hooks | `packages/core-agent/src/runtime/chat-orchestrator-runtime.test.ts` 覆盖 activation started / succeeded / failed hook |
| Voice input failures | `packages/stage-ui/src/composables/audio/audio-device.test.ts``packages/stage-ui/src/stores/modules/hearing.analytics.test.ts` 覆盖 permission / device / cancel / STT failed |
| Server TTS metadata | `apps/server/src/routes/openai/v1/route.test.ts``apps/server/src/routes/audio-speech-ws/route.test.ts` 覆盖 REST / WS TTS `voice_id``voice_type``voice_pack_id` metadata |
| Grafana product row | `apps/server/otel/grafana/dashboards/build.test.ts` 覆盖 Product Analytics panels、layout references、PromQL 不包含 high-cardinality voice / user fields |
## Live Smoke Checklist
Run this after deploying a build with the instrumentation changes. The PostHog dashboard and Grafana dashboard shell are already created, but they still need live event traffic from the deployed build.
### 1. PostHog: chat activation
Action:
1. Use a fresh or test account.
2. Start with an official provider.
3. Send the first chat message and wait for the assistant response.
Expected PostHog events:
```text
chat_activation_started
chat_activation_succeeded
```
Required properties:
```text
provider_mode = official
provider_id = <official provider id>
model_id = <selected model id>
surface = web | mobile | electron
```
Fail if:
- `chat_activation_started` appears but `chat_activation_succeeded` never appears for a successful chat.
- `provider_mode` is missing or always `unknown`.
- `surface` is missing.
### 2. PostHog: provider config failure
Action:
1. Configure a custom provider with an invalid key or invalid endpoint.
2. Trigger settings validation or manual chat ping.
Expected PostHog events:
```text
provider_config_started
provider_config_failed
```
Required properties:
```text
provider_mode = custom
provider_id = <provider id>
step = settings_auto_validate | manual_chat_ping
error_code = <bounded error code>
```
Fail if:
- Raw error text, API key fragments, endpoint secrets, or stack traces appear in event properties.
- `provider_config_failed` has no `error_code`.
### 3. PostHog: TTS voice selection
Action:
1. Open speech settings.
2. Select an official TTS provider.
3. Select or keep an official voice.
4. Play voice preview once.
Expected PostHog events:
```text
tts_provider_selected
voice_selected
voice_preview_played
```
Required properties:
```text
tts_provider_id = <provider id>
tts_model_id = <model id>
voice_id = <catalog voice id or custom>
voice_type = official_default | official_selected | custom_configured | voice_pack | unknown
source = settings | manual_preview
```
Fail if:
- `voice_selected` is missing, because this blocks “哪个 TTS 音色比较多”的核心问题。
- Official default voice is indistinguishable from custom configured voice.
### 4. PostHog: voice input friction
Action:
1. Start voice input.
2. Test one failure path: deny microphone permission, use a browser/device with no microphone, or cancel input.
Expected PostHog events:
```text
voice_input_started
microphone_permission_requested
microphone_permission_denied
audio_device_unavailable
voice_input_cancelled
stt_failed
```
Only the events that match the exercised path need to appear.
Fail if:
- Permission denied or device unavailable is only visible as a generic `stt_failed`.
- `error_code` contains raw browser error text.
### 5. Postgres: server-side TTS metadata
Action:
1. Trigger one REST TTS request.
2. Trigger one chat/WS TTS request if the deployed environment supports it.
Query:
```sql
SELECT
created_at,
user_id,
source,
provider,
model,
action,
status,
metadata->>'voice_id' AS voice_id,
metadata->>'voice_type' AS voice_type,
metadata->>'voice_pack_id' AS voice_pack_id
FROM product_events
WHERE feature = 'tts'
AND created_at >= now() - interval '1 hour'
ORDER BY created_at DESC
LIMIT 50;
```
Expected:
- `speech_requested` and `speech_succeeded` rows exist for successful TTS.
- `voice_id` is present when the request provided a selected voice.
- `voice_type` distinguishes official default / selected / custom / voice pack where available.
Fail if:
- TTS succeeds but no `product_events` row is written.
- Metadata contains raw prompts, message text, API keys, or request bodies.
### 7. Grafana: Product Analytics row
Action:
1. Open `https://projairi.grafana.net/d/ad8qbp5/airi-server-overview`.
2. Open the `Product Analytics` row.
3. Use a 1h time range after running the smoke actions above.
Expected panels:
```text
Product Events (range)
Product Failure %
TTS Success %
TTS Failed / Blocked (range)
Top Product Actions (range)
Product Event Rate
TTS Event Rate by Source
```
PromQL sanity:
```promql
sum(increase(airi_product_events_total{feature="tts"}[1h]))
```
Expected:
- Query returns a non-zero value after TTS smoke actions.
- Legends only use bounded labels: `feature`, `action`, `status`, `source`.
Fail if:
- Prometheus labels contain `voice_id`, `voice_pack_id`, `user_id`, `session_id`, or `request_id`.
- Grafana shows product panels but Postgres has no matching TTS rows.
## Quick Analysis Queries
Top selected voices should come from PostHog `voice_selected` for frontend intent. Server-side playback can be cross-checked from Postgres:
```sql
SELECT
metadata->>'voice_id' AS voice_id,
metadata->>'voice_type' AS voice_type,
provider,
model,
COUNT(*) AS play_count,
COUNT(DISTINCT user_id) AS distinct_users
FROM product_events
WHERE feature = 'tts'
AND action = 'speech_succeeded'
AND created_at >= now() - interval '7 days'
GROUP BY 1, 2, 3, 4
ORDER BY play_count DESC
LIMIT 20;
```
Server-side TTS blocked / failed ranking:
```sql
SELECT
action,
status,
source,
reason,
COUNT(*) AS event_count,
COUNT(DISTINCT user_id) AS distinct_users
FROM product_events
WHERE feature = 'tts'
AND action IN ('speech_failed', 'speech_blocked')
AND created_at >= now() - interval '24 hours'
GROUP BY 1, 2, 3, 4
ORDER BY event_count DESC;
```
## Exit Criteria
| Item | Pass condition |
|---|---|
| Activation | PostHog funnel shows `chat_activation_started -> chat_activation_succeeded` by `provider_mode` |
| Provider config | Failed custom config emits `provider_config_failed` with bounded `error_code` |
| TTS voice | PostHog can rank `voice_selected` by `voice_id`; Postgres can rank actual `speech_succeeded` by metadata voice |
| Voice input | Permission / device / cancel paths are distinguishable |
| Feedback | Feedback event API exists with bounded fields; product feedback UI/server submission is split into a separate PR |
| Grafana | Product Analytics row renders and uses only bounded Prometheus labels |
## Known Pending Work
- PostHog dashboard and alerts still need to be created inside the PostHog account.
- Updated Grafana JSON still needs to be imported or deployed to the production Grafana workspace.
- Discord / QQ ingestion and daily / weekly automation scripts are intentionally excluded from this pass.
@@ -1274,6 +1274,294 @@
}
}
},
"panel-99": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"datasource": {
"name": "grafanacloud-projairi-prom"
},
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"expr": "100 * sum(increase(airi_product_events_total{service_name=~\"$service\", deployment_environment=~\"$env\", feature!=\"\", action!=\"\", feature=\"tts\", action=\"speech_succeeded\", status=\"succeeded\"}[$__range])) / clamp_min(sum(increase(airi_product_events_total{service_name=~\"$service\", deployment_environment=~\"$env\", feature!=\"\", action!=\"\", feature=\"tts\", action=\"speech_requested\", status=\"started\"}[$__range])), 1)",
"legendFormat": "success %"
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "Server-side TTS successes divided by TTS requests over the dashboard range. Includes REST and WS TTS product events. Drops here mean users are asking for speech but not receiving audio; inspect failed/blocked panels next.",
"id": 99,
"links": [],
"title": "TTS Success %",
"vizConfig": {
"group": "gauge",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "yellow",
"value": 90
},
{
"color": "green",
"value": 98
}
]
},
"unit": "percent",
"decimals": 2,
"noValue": "0",
"min": 0,
"max": 100
},
"overrides": []
},
"options": {
"minVizHeight": 75,
"minVizWidth": 75,
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showThresholdLabels": false,
"showThresholdMarkers": true,
"sizing": "auto"
}
},
"version": "13.0.0-23630096546"
}
}
},
"panel-100": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"datasource": {
"name": "grafanacloud-projairi-prom"
},
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"expr": "topk(12, sum by (action, status, source) (increase(airi_product_events_total{service_name=~\"$service\", deployment_environment=~\"$env\", feature!=\"\", action!=\"\", feature=\"tts\", action=~\"speech_failed|speech_blocked\"}[$__range])))",
"legendFormat": "{{action}} · {{status}} · {{source}}",
"instant": true,
"range": false
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "TTS user-impacting failures over the dashboard range, split by action/status/source. `speech_failed` usually means upstream/runtime failure; `speech_blocked` usually means balance/preflight blocked. Keep voice/model drilldown in Postgres metadata, not Prometheus labels.",
"id": 100,
"links": [],
"title": "TTS Failed / Blocked (range)",
"vizConfig": {
"group": "bargauge",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
},
"unit": "short",
"noValue": "0"
},
"overrides": []
},
"options": {
"displayMode": "gradient",
"maxVizHeight": 300,
"minVizHeight": 12,
"minVizWidth": 8,
"namePlacement": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showUnfilled": true,
"sizing": "auto",
"valueMode": "color"
}
},
"version": "13.0.0-23630096546"
}
}
},
"panel-101": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"datasource": {
"name": "grafanacloud-projairi-prom"
},
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"expr": "sum by (source, action, status) (rate(airi_product_events_total{service_name=~\"$service\", deployment_environment=~\"$env\", feature!=\"\", action!=\"\", feature=\"tts\"}[$__rate_interval]))",
"legendFormat": "{{source}} · {{action}} · {{status}}"
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "TTS product event rate by source and action. Use this to distinguish chat auto-TTS, manual previews/settings tests, and API audio.speech traffic when speech health changes.",
"id": 101,
"links": [],
"title": "TTS Event Rate by Source",
"vizConfig": {
"group": "timeseries",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 15,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
},
"unit": "eps"
},
"overrides": []
},
"options": {
"annotations": {
"clustering": -1,
"multiLane": false
},
"legend": {
"calcs": [
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "right",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
}
},
"version": "13.0.0-23630096546"
}
}
},
"panel-16": {
"kind": "Panel",
"spec": {
@@ -4217,14 +4505,40 @@
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-97"
"name": "panel-99"
},
"height": 9,
"width": 12,
"height": 5,
"width": 6,
"x": 12,
"y": 0
}
},
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-100"
},
"height": 5,
"width": 6,
"x": 18,
"y": 0
}
},
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-97"
},
"height": 8,
"width": 12,
"x": 0,
"y": 5
}
},
{
"kind": "GridLayoutItem",
"spec": {
@@ -4234,9 +4548,22 @@
},
"height": 4,
"width": 12,
"x": 0,
"x": 12,
"y": 5
}
},
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-101"
},
"height": 4,
"width": 12,
"x": 12,
"y": 9
}
}
]
}
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { checkDashboardLayoutReferences, dashboard } from './build'
/**
* Narrows unknown dashboard nodes into indexable records for assertions.
*/
function asRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== 'object')
throw new TypeError(`${label} is not an object`)
return value as Record<string, unknown>
}
/**
* Reads a generated panel title from the dashboard object.
*/
function panelTitle(panelName: string): string {
const panel = asRecord(dashboard.elements[panelName], panelName)
const spec = asRecord(panel.spec, `${panelName}.spec`)
if (typeof spec.title !== 'string')
throw new TypeError(`${panelName}.spec.title is not a string`)
return spec.title
}
/**
* Collects PromQL expression strings from nested Grafana panel objects.
*/
function collectQueryExpressions(value: unknown, expressions: string[] = []): string[] {
if (!value || typeof value !== 'object')
return expressions
const record = value as Record<string, unknown>
if (typeof record.expr === 'string')
expressions.push(record.expr)
for (const nestedValue of Object.values(record))
collectQueryExpressions(nestedValue, expressions)
return expressions
}
describe('grafana dashboard builder', () => {
/**
* @example
* const result = checkDashboardLayoutReferences(dashboard)
* expect(result.orphanRefs).toEqual([])
*/
it('keeps every generated panel connected to the row layout', () => {
const result = checkDashboardLayoutReferences(dashboard)
expect(result.orphanRefs).toEqual([])
expect(result.unusedElems).toEqual([])
})
/**
* @example
* expect(panelTitle('panel-99')).toBe('TTS Success %')
*/
it('keeps the product analytics row focused on server-side TTS health', () => {
expect(panelTitle('panel-95')).toBe('Product Events (range)')
expect(panelTitle('panel-96')).toBe('Product Failure %')
expect(panelTitle('panel-99')).toBe('TTS Success %')
expect(panelTitle('panel-100')).toBe('TTS Failed / Blocked (range)')
expect(panelTitle('panel-101')).toBe('TTS Event Rate by Source')
})
/**
* @example
* const rendered = JSON.stringify(dashboard.elements['panel-101'])
* expect(rendered).not.toContain('voice_id')
*/
it('keeps high-cardinality voice fields out of Prometheus queries', () => {
const productPanelExpressions = collectQueryExpressions([
dashboard.elements['panel-95'],
dashboard.elements['panel-96'],
dashboard.elements['panel-97'],
dashboard.elements['panel-98'],
dashboard.elements['panel-99'],
dashboard.elements['panel-100'],
dashboard.elements['panel-101'],
]).join('\n')
expect(productPanelExpressions).not.toContain('voice_id')
expect(productPanelExpressions).not.toContain('voice_pack_id')
expect(productPanelExpressions).not.toContain('user_id')
expect(productPanelExpressions).not.toContain('session_id')
expect(productPanelExpressions).not.toContain('request_id')
})
})
+105 -29
View File
@@ -29,8 +29,8 @@
import { writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { exit } from 'node:process'
import { fileURLToPath } from 'node:url'
import { argv, exit } from 'node:process'
import { fileURLToPath, pathToFileURL } from 'node:url'
const PROM = { name: 'grafanacloud-projairi-prom' }
const LOKI = { name: 'grafanacloud-projairi-logs' }
@@ -527,7 +527,7 @@ elements['panel-92'] = timeseriesPanel(
{ unit: 'short', fillOpacity: 30 },
)
// --- Product Analytics — event-volume view only ---------------------------
// --- Product Analytics — event volume + server-side TTS health -------------
// Prometheus deliberately does not carry user_id. These panels answer
// "which product actions are happening and failing"; DB-side product_events
// queries answer "how many distinct users used each feature".
@@ -577,6 +577,42 @@ elements['panel-98'] = timeseriesPanel(
{ unit: 'eps', fillOpacity: 15 },
)
elements['panel-99'] = gaugePanel(
99,
'TTS Success %',
'Server-side TTS successes divided by TTS requests over the dashboard range. Includes REST and WS TTS product events. Drops here mean users are asking for speech but not receiving audio; inspect failed/blocked panels next.',
[query(
`100 * sum(increase(airi_product_events_total{${PRODUCT_EVENT_FILTER}, feature="tts", action="speech_succeeded", status="succeeded"}[$__range])) / clamp_min(sum(increase(airi_product_events_total{${PRODUCT_EVENT_FILTER}, feature="tts", action="speech_requested", status="started"}[$__range])), 1)`,
'success %',
)],
{ steps: [{ color: 'red', value: 0 }, { color: 'yellow', value: 90 }, { color: 'green', value: 98 }], max: 100, decimals: 2, noValue: '0' },
)
elements['panel-100'] = barGaugePanel(
100,
'TTS Failed / Blocked (range)',
'TTS user-impacting failures over the dashboard range, split by action/status/source. `speech_failed` usually means upstream/runtime failure; `speech_blocked` usually means balance/preflight blocked. Keep voice/model drilldown in Postgres metadata, not Prometheus labels.',
[query(
`topk(12, sum by (action, status, source) (increase(airi_product_events_total{${PRODUCT_EVENT_FILTER}, feature="tts", action=~"speech_failed|speech_blocked"}[$__range])))`,
'{{action}} · {{status}} · {{source}}',
'A',
PROM,
{ instant: true },
)],
{ unit: 'short', noValue: '0' },
)
elements['panel-101'] = timeseriesPanel(
101,
'TTS Event Rate by Source',
'TTS product event rate by source and action. Use this to distinguish chat auto-TTS, manual previews/settings tests, and API audio.speech traffic when speech health changes.',
[query(
`sum by (source, action, status) (rate(airi_product_events_total{${PRODUCT_EVENT_FILTER}, feature="tts"}[$__rate_interval]))`,
'{{source}} · {{action}} · {{status}}',
)],
{ unit: 'eps', fillOpacity: 15 },
)
// --- Row 2: HTTP — traffic ranking, error trend, latency trend -------------
elements['panel-16'] = barGaugePanel(
16,
@@ -908,14 +944,17 @@ const rows = [
item('panel-81', 8, 0, 8, 4),
item('panel-82', 16, 0, 8, 4),
]),
// Row 3: Product Analytics — Prom-safe event volume and failure trend.
// Row 3: Product Analytics — Prom-safe event volume and server TTS health.
// Distinct-user analytics stay in Postgres `product_events`; this row
// intentionally never uses user_id/session/request labels.
row('Product Analytics', [
item('panel-95', 0, 0, 6, 5),
item('panel-96', 6, 0, 6, 5),
item('panel-97', 12, 0, 12, 9),
item('panel-98', 0, 5, 12, 4),
item('panel-99', 12, 0, 6, 5),
item('panel-100', 18, 0, 6, 5),
item('panel-97', 0, 5, 12, 8),
item('panel-98', 12, 5, 12, 4),
item('panel-101', 12, 9, 12, 4),
]),
// Row 3: HTTP — full-width error breakdown on top, then traffic ranking +
// latency trend side by side.
@@ -1039,7 +1078,7 @@ const variables = [
* 1. Service Health — signup/sessions/WS counts, req-rate, 5xx, status-code
* heatmap, live WS trend: "is anything broken right now?"
* 2. User Engagement — rolling DAU/WAU/MAU from user.last_seen_at
* 3. Product Analytics — Prom-safe product event volume + failure trend
* 3. Product Analytics — Prom-safe product event volume + server TTS health
* 4. HTTP — error breakdown by route, request ranking, latency by route
* 5. LLM Gateway — per-model request rate + latency (TTFB + end-to-end)
* 6. Provider Upstreams — per-provider rate/latency/failure + TTS chars
@@ -1056,7 +1095,7 @@ const variables = [
* Variables source from `target_info` (always present, no business-metric
* dependency) so the dashboard never goes blank when an app metric is renamed.
*/
const dashboard = {
export const dashboard = {
annotations: [
{
kind: 'AnnotationQuery',
@@ -1097,28 +1136,65 @@ const dashboard = {
variables,
}
const here = dirname(fileURLToPath(import.meta.url))
const outPath = join(here, 'airi-server-overview-cloud.json')
writeFileSync(outPath, `${JSON.stringify(dashboard, null, 2)}\n`)
console.info(`wrote ${outPath}`)
export interface DashboardLayoutCheckResult {
orphanRefs: string[]
unusedElems: string[]
}
// Cross-check elements ↔ layout references
const elementNames = new Set(Object.keys(dashboard.elements))
const refs = new Set<string>()
function walk(o: unknown): void {
if (!o || typeof o !== 'object')
/**
* Validates that every dashboard layout reference points to a defined element.
*
* Use when:
* - Regenerating the Grafana JSON from this dashboard builder.
* - Testing that row changes did not orphan panels or leave panels unused.
*
* Expects:
* - A Grafana dashboard object shaped like {@link dashboard}.
*
* Returns:
* - Orphan layout references and unused element names.
*/
export function checkDashboardLayoutReferences(targetDashboard: typeof dashboard): DashboardLayoutCheckResult {
const elementNames = new Set(Object.keys(targetDashboard.elements))
const refs = new Set<string>()
collectElementReferences(targetDashboard.layout, refs)
return {
orphanRefs: [...refs].filter(r => !elementNames.has(r)),
unusedElems: [...elementNames].filter(e => !refs.has(e)),
}
}
/**
* Recursively collects Grafana row element references from the layout tree.
*/
function collectElementReferences(node: unknown, refs: Set<string>): void {
if (!node || typeof node !== 'object')
return
const node = o as { kind?: unknown, name?: unknown }
if (node.kind === 'ElementReference' && typeof node.name === 'string')
refs.add(node.name)
for (const v of Object.values(o)) walk(v)
const layoutNode = node as { kind?: unknown, name?: unknown }
if (layoutNode.kind === 'ElementReference' && typeof layoutNode.name === 'string')
refs.add(layoutNode.name)
for (const value of Object.values(node)) collectElementReferences(value, refs)
}
walk(dashboard.layout)
const orphanRefs = [...refs].filter(r => !elementNames.has(r))
const unusedElems = [...elementNames].filter(e => !refs.has(e))
console.info(`panels defined: ${elementNames.size}, referenced: ${refs.size}, orphans: ${orphanRefs.length}, unused: ${unusedElems.length}`)
if (orphanRefs.length || unusedElems.length) {
console.error('orphans:', orphanRefs)
console.error('unused:', unusedElems)
exit(1)
/**
* Writes the generated dashboard JSON and fails the CLI on layout drift.
*/
function writeDashboard(): void {
const here = dirname(fileURLToPath(import.meta.url))
const outPath = join(here, 'airi-server-overview-cloud.json')
writeFileSync(outPath, `${JSON.stringify(dashboard, null, 2)}\n`)
console.info(`wrote ${outPath}`)
const { orphanRefs, unusedElems } = checkDashboardLayoutReferences(dashboard)
const definedCount = Object.keys(dashboard.elements).length
const referencedCount = definedCount - unusedElems.length + orphanRefs.length
console.info(`panels defined: ${definedCount}, referenced: ${referencedCount}, orphans: ${orphanRefs.length}, unused: ${unusedElems.length}`)
if (orphanRefs.length || unusedElems.length) {
console.error('orphans:', orphanRefs)
console.error('unused:', unusedElems)
exit(1)
}
}
if (import.meta.url === pathToFileURL(argv[1] ?? '').href)
writeDashboard()
+19
View File
@@ -4,6 +4,7 @@ import type { AuthInstance } from './libs/auth'
import type { Database } from './libs/db'
import type { Env } from './libs/env'
import type { OtelInstance } from './otel'
import type { StreamingTtsVoiceType } from './routes/audio-speech-ws/session'
import type { ConfigKVService } from './services/adapters/config-kv'
import type { AdminFluxGrantsService } from './services/domain/admin/flux-grants'
import type { AdminRouterConfigService } from './services/domain/admin/router-config'
@@ -213,6 +214,7 @@ export async function buildApp(deps: AppDeps) {
return audioSpeechWsSetup(session.user.id, {
trigger: c.req.query('tts_trigger') === 'auto' ? 'auto' : 'manual',
source: parseTtsSource(c.req.query('tts_source'), 'audio.speech.ws'),
voiceType: parseTtsVoiceType(c.req.query('tts_voice_type')),
})
}))
@@ -458,6 +460,23 @@ function parseTtsSource(
}
}
/**
* Normalizes the client-provided streaming TTS voice bucket for product events.
*/
function parseTtsVoiceType(
value: string | undefined,
): StreamingTtsVoiceType {
switch (value) {
case 'official_default':
case 'official_selected':
case 'custom_configured':
case 'voice_pack':
return value
default:
return 'unknown'
}
}
export type AppType = Awaited<ReturnType<typeof buildApp>>['app']
export async function createApp() {
@@ -230,7 +230,7 @@ describe('audio-speech-ws route', () => {
const deps = makeFakeDeps({ upstreamURL: upstream.url, fluxBalance: 100 })
const handlers = createAudioSpeechWsHandlers(deps as any)
const events = handlers('user-123')
const events = handlers('user-123', { voiceType: 'official_selected' })
const client = makeMockClientWs()
await driveClientSession(events, client, [
@@ -279,6 +279,17 @@ 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 () => {
@@ -52,10 +52,12 @@ export interface AudioSpeechSessionState {
export type StreamingTtsTrigger = 'auto' | 'manual'
export type StreamingTtsSource = 'audio.speech.ws' | 'chat_auto_tts' | 'manual_preview' | 'settings_test'
export type StreamingTtsVoiceType = 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' | 'unknown'
export interface AudioSpeechSessionAnalytics {
trigger?: StreamingTtsTrigger
source?: StreamingTtsSource
voiceType?: StreamingTtsVoiceType
}
/**
@@ -93,6 +95,7 @@ export function createSessionState(
let billed = false
let totalInputChars = 0
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
@@ -114,6 +117,7 @@ export function createSessionState(
model: modelLabel,
metadata: {
trigger: analytics.trigger,
...streamingVoiceMetadata(voiceLabel, analytics.voiceType),
},
})
@@ -227,6 +231,7 @@ export function createSessionState(
metadata: {
duration_ms: Date.now() - startedAt,
trigger: analytics.trigger,
...streamingVoiceMetadata(voiceLabel, analytics.voiceType),
},
})
try {
@@ -361,6 +366,9 @@ export function createSessionState(
const model = (parsed as Record<string, unknown>).model
if (typeof model === 'string' && model.length > 0)
modelLabel = model
const voice = (parsed as Record<string, unknown>).voice
if (typeof voice === 'string' && voice.length > 0)
voiceLabel = voice
}
}
catch {
@@ -433,6 +441,7 @@ export function createSessionState(
duration_ms: durationMs,
flux_consumed: fluxConsumed,
trigger: analytics.trigger,
...streamingVoiceMetadata(voiceLabel, analytics.voiceType),
},
})
@@ -470,6 +479,7 @@ export function createSessionState(
close_code: code,
duration_ms: Date.now() - startedAt,
trigger: analytics.trigger,
...streamingVoiceMetadata(voiceLabel, analytics.voiceType),
},
})
if (clientWs) {
@@ -503,6 +513,7 @@ export function createSessionState(
close_code: code,
duration_ms: Date.now() - startedAt,
trigger: analytics.trigger,
...streamingVoiceMetadata(voiceLabel, analytics.voiceType),
},
})
if (clientWs) {
@@ -531,6 +542,7 @@ function normalizeAnalytics(input: AudioSpeechSessionAnalytics): Required<AudioS
return {
trigger: normalizeTrigger(input.trigger),
source: normalizeSource(input.source),
voiceType: normalizeVoiceType(input.voiceType),
}
}
@@ -550,6 +562,31 @@ function normalizeSource(source: AudioSpeechSessionAnalytics['source']): Streami
}
}
/**
* 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<string, unknown> {
return {
...(voiceId ? { voice_id: voiceId } : {}),
voice_type: voiceType,
}
}
function isPaymentRequiredError(err: unknown): boolean {
if (err instanceof ApiError)
return err.statusCode === 402
@@ -849,6 +849,87 @@ describe('v1CompletionsRoutes', () => {
}))
})
/**
* @example
* POST /api/v1/audio/speech { "voice": "alloy", "extra_body": { "voice_pack": { "pack_id": "vp-premium" } } }
*/
it('records TTS voice and Voice Pack metadata in product events', async () => {
globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1]), {
status: 200,
headers: { 'Content-Type': 'audio/mpeg' },
}))
const productEventService = createMockProductEventService()
const voicePackService = createMockVoicePackService({
findById: vi.fn(async () => ({
id: 'vp-premium',
name: 'Premium',
description: null,
provider: 'azure',
model: 'microsoft/v1',
voiceId: 'alloy',
ttsModelId: 'tts-1',
params: {},
costMultiplier: 2,
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
})),
})
const app = createTestApp(
createMockFluxService(),
createMockConfigKV(),
undefined,
undefined,
undefined,
undefined,
createMockLlmTracing(),
productEventService,
voicePackService,
)
await app.fetch(
new Request('http://localhost/api/v1/audio/speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'auto',
input: 'hello',
voice: 'alloy',
extra_body: {
voice_pack: {
pack_id: 'vp-premium',
cost_multiplier: 2,
},
airi_analytics: {
source: 'manual_preview',
voice_type: 'voice_pack',
},
},
}),
}),
{ 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(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
action: 'speech_requested',
metadata: expect.objectContaining({
voice_id: 'alloy',
voice_type: 'voice_pack',
voice_pack_id: 'vp-premium',
}),
}))
})
it('should not charge when routeTts upstream returns error', async () => {
const llmRouter = createMockLlmRouter({
routeTts: vi.fn(async () => new Response('{"error":"service down"}', {
@@ -63,10 +63,12 @@ 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
}
/**
@@ -100,6 +102,11 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
voice: typeof input.body.voice === 'string' ? input.body.voice : undefined,
voicePackService: deps.voicePackService,
})
const voiceMetadata = ttsVoiceMetadata({
voice: typeof input.body.voice === 'string' ? input.body.voice : undefined,
voicePackId: voicePackRequest.voicePackId,
voiceType: analytics.voiceType,
})
const billingUnits = Math.ceil(inputText.length * voicePackRequest.costMultiplier)
logger.withFields({
@@ -120,6 +127,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
metadata: {
input_chars: inputText.length,
trigger: analytics.trigger,
...voiceMetadata,
},
})
@@ -144,6 +152,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
billing_units: billingUnits,
balance_state: 'insufficient',
trigger: analytics.trigger,
...voiceMetadata,
},
})
logger.withError(err).withFields({
@@ -219,6 +228,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
http_status: failure.status,
duration_ms: Date.now() - startedAt,
trigger: analytics.trigger,
...voiceMetadata,
},
})
throw err
@@ -245,6 +255,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
http_status: response.status,
duration_ms: durationMs,
trigger: analytics.trigger,
...voiceMetadata,
},
})
logger.withFields({ requestId, userId: input.userId, model: requestModel, status: response.status, durationMs })
@@ -297,6 +308,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
duration_ms: durationMs,
flux_consumed: fluxConsumed,
trigger: analytics.trigger,
...voiceMetadata,
},
})
deps.requestLogService.logRequest({
@@ -354,8 +366,40 @@ function ttsAnalyticsContext(body: Record<string, unknown>): TtsAnalyticsContext
|| rawSource === 'settings_test'
? rawSource
: 'audio.speech'
const voiceType = normalizeVoiceType(analytics?.voice_type)
return { trigger, source }
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<string, unknown> {
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 } : {}),
}
}
async function voicePackRequestOptions(
@@ -365,12 +409,15 @@ async function voicePackRequestOptions(
voice?: string
voicePackService: VoicePackService
},
): Promise<{ extraOptions: Record<string, unknown> | undefined, costMultiplier: number }> {
): Promise<{ extraOptions: Record<string, unknown> | undefined, costMultiplier: number, voicePackId?: string }> {
const extraBody = asRecord(body.extra_body)
const voicePackOptions = asRecord(extraBody?.voice_pack)
const pitch = readOptionalNumber(voicePackOptions, 'pitch')
const volume = readOptionalNumber(voicePackOptions, 'volume')
const costMultiplier = await resolveVoicePackCostMultiplier(voicePackOptions, context)
const voicePackId = typeof voicePackOptions?.pack_id === 'string' && voicePackOptions.pack_id.trim()
? voicePackOptions.pack_id
: undefined
const extraOptions: Record<string, unknown> = {}
if (pitch != null)
extraOptions.pitch = pitch
@@ -380,6 +427,7 @@ async function voicePackRequestOptions(
return {
extraOptions: Object.keys(extraOptions).length > 0 ? extraOptions : undefined,
costMultiplier,
voicePackId,
}
}
@@ -35,6 +35,9 @@ function createHarness() {
const assistantTurns: unknown[] = []
const stateChanges: unknown[] = []
const telemetry = {
chatActivationStarted: [] as unknown[],
chatActivationSucceeded: [] as unknown[],
chatActivationFailed: [] as unknown[],
messageSendStarted: [] as unknown[],
llmRequestStarted: [] as unknown[],
llmFirstToken: [] as unknown[],
@@ -89,6 +92,9 @@ function createHarness() {
onUserTurnReady: event => userTurns.push(event),
onAssistantTurnReady: event => assistantTurns.push(event),
onStateChange: state => stateChanges.push(state),
onChatActivationStarted: event => telemetry.chatActivationStarted.push(event),
onChatActivationSucceeded: event => telemetry.chatActivationSucceeded.push(event),
onChatActivationFailed: event => telemetry.chatActivationFailed.push(event),
onMessageSendStarted: event => telemetry.messageSendStarted.push(event),
onLlmRequestStarted: event => telemetry.llmRequestStarted.push(event),
onLlmFirstToken: event => telemetry.llmFirstToken.push(event),
@@ -323,6 +329,46 @@ describe('createChatOrchestratorRuntime', () => {
hasVoice: true,
model: 'gpt-test',
}])
expect(harness.telemetry.chatActivationStarted).toEqual([{
model: 'gpt-test',
provider: 'mock-provider',
source: 'voice',
}])
expect(harness.telemetry.chatActivationSucceeded).toEqual([{
durationMs: 360,
model: 'gpt-test',
provider: 'mock-provider',
source: 'voice',
}])
expect(harness.telemetry.chatActivationFailed).toEqual([])
})
/**
* @example
* await expect(runtime.ingest('hello', { model, chatProvider })).rejects.toThrow('provider rejected')
*/
it('emits chat activation failure telemetry without raw provider messages', async () => {
const harness = createHarness()
harness.stream.mockRejectedValueOnce(new Error('provider rejected with sensitive details'))
await expect(harness.runtime.ingest('hello', {
model: 'gpt-test',
chatProvider: provider,
})).rejects.toThrow('provider rejected')
expect(harness.telemetry.chatActivationStarted).toEqual([{
model: 'gpt-test',
provider: 'mock-provider',
source: 'text',
}])
expect(harness.telemetry.chatActivationSucceeded).toEqual([])
expect(harness.telemetry.chatActivationFailed).toEqual([{
errorCode: 'llm_response_failed',
failureStage: 'llm_response',
model: 'gpt-test',
provider: 'mock-provider',
source: 'text',
}])
})
/**
@@ -190,6 +190,27 @@ export interface ChatOrchestratorRuntimeDeps {
onSendSettled?: (event: { sessionId: string }) => void
/** Called when a send starts and the first assistant placeholder is created. */
onTrackFirstMessage?: () => void
/** Called when a user starts a chat activation attempt. */
onChatActivationStarted?: (event: {
source: 'text' | 'voice'
model: string
provider: string
}) => void
/** Called after one user-to-assistant message round completes successfully. */
onChatActivationSucceeded?: (event: {
source: 'text' | 'voice'
model: string
provider: string
durationMs: number
}) => void
/** Called after a chat activation attempt fails before assistant completion. */
onChatActivationFailed?: (event: {
source: 'text' | 'voice'
model: string
provider: string
failureStage: 'llm_response'
errorCode: 'llm_response_failed'
}) => void
/** Called when a user message send begins. */
onMessageSendStarted?: (event: {
source: 'text' | 'voice'
@@ -401,9 +422,16 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
id: createId(),
}
patchForegroundStream(sessionId, buildingMessage)
const sendSource = options.input ? 'voice' : 'text'
const activeProvider = deps.getActiveProvider?.() ?? ''
deps.onTrackFirstMessage?.()
deps.onChatActivationStarted?.({
source: sendSource,
model: options.model,
provider: activeProvider,
})
deps.onMessageSendStarted?.({
source: options.input ? 'voice' : 'text',
source: sendSource,
model: options.model,
})
const roundStartedAt = monotonicNow()
@@ -710,14 +738,28 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
})
resetForegroundStream(sessionId)
const durationMs = Math.round(monotonicNow() - roundStartedAt)
deps.onMessageRound?.({
durationMs: Math.round(monotonicNow() - roundStartedAt),
durationMs,
hasVoice: !!options.input,
model: options.model,
})
deps.onChatActivationSucceeded?.({
durationMs,
source: sendSource,
model: options.model,
provider: activeProvider,
})
}
catch (error) {
console.error('Error sending message:', error)
deps.onChatActivationFailed?.({
source: sendSource,
model: options.model,
provider: activeProvider,
failureStage: 'llm_response',
errorCode: 'llm_response_failed',
})
throw error
}
finally {
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { VoiceType } from '@proj-airi/stage-ui/composables'
import type { VoicePackSnapshot } from '@proj-airi/stage-ui/stores/modules/airi-card'
import type { VoiceInfo } from '@proj-airi/stage-ui/stores/providers'
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils'
@@ -13,7 +14,7 @@ import {
VoiceCardManySelect,
} from '@proj-airi/stage-ui/components'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { OFFICIAL_SPEECH_PROVIDER_ID } from '@proj-airi/stage-ui/libs/providers/providers/official'
import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '@proj-airi/stage-ui/libs/providers/providers/official'
import { useAiriCardStore, useVoicePacksStore } from '@proj-airi/stage-ui/stores'
import { useSpeechStore, voicePackForSpeechProvider } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
@@ -55,7 +56,13 @@ const {
availableVoices,
} = storeToRefs(speechStore)
const { trackProviderClick } = useAnalytics()
const {
trackProviderClick,
trackTtsProviderSelected,
trackVoicePackBound,
trackVoicePreviewPlayed,
trackVoiceSelected,
} = useAnalytics()
const voiceSearchQuery = ref('')
const useSSML = ref(false)
@@ -71,6 +78,9 @@ const shouldShowVoicePackSection = computed(() =>
supportsVoicePackSelection.value
&& (isLoadingVoicePacks.value || voicePacksError.value != null || voicePacks.value.length > 0),
)
const boundVoicePack = computed(() =>
voicePackForSpeechProvider(activeSpeechProvider.value, activeCard.value?.extensions.airi.modules.speech.voicePack),
)
const selectableSpeechProvidersMetadata = computed(() => {
return [
@@ -95,6 +105,77 @@ function formatCostMultiplier(multiplier: number) {
return `${Number.isInteger(multiplier) ? multiplier : multiplier.toFixed(2).replace(/\.?0+$/, '')}x`
}
/**
* Resolves the current TTS model id for low-cardinality analytics payloads.
*/
function currentTtsModelId() {
return activeSpeechModel.value || 'unknown'
}
/**
* Classifies the selected voice without sending free-form provider config as a dimension.
*/
function currentVoiceType(voiceId: string, providerId = activeSpeechProvider.value, voicePack = boundVoicePack.value): VoiceType {
if (voicePack?.voiceId === voiceId)
return 'voice_pack'
const catalogVoice = availableVoices.value[providerId]?.some(voice => voice.id === voiceId)
if (catalogVoice)
return providerId === OFFICIAL_SPEECH_PROVIDER_ID || providerId === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID ? 'official_selected' : 'custom_configured'
return 'custom_configured'
}
/**
* Builds bounded voice analytics fields for catalog, voice pack, and manual voices.
*/
function voiceAnalyticsPayload(
voiceId: string,
voicePack: VoicePackSnapshot | undefined = boundVoicePack.value,
providerId = activeSpeechProvider.value,
): {
voice_id: string
voice_type: VoiceType
voice_pack_id?: string
} {
const voiceType = voicePack?.voiceId === voiceId ? 'voice_pack' : currentVoiceType(voiceId, providerId, voicePack)
const isCatalogVoice = availableVoices.value[providerId]?.some(voice => voice.id === voiceId) ?? false
const shouldBucketVoiceId = voiceType === 'custom_configured' && !isCatalogVoice
return {
voice_id: shouldBucketVoiceId ? 'custom' : voiceId,
voice_type: voiceType,
...(voiceType === 'voice_pack' && voicePack ? { voice_pack_id: voicePack.packId } : {}),
}
}
/**
* Tracks the active TTS provider while preserving the legacy provider-card event.
*/
function selectSpeechProvider(providerId: string) {
trackProviderClick(providerId, 'speech')
trackTtsProviderSelected({
tts_provider_id: providerId,
tts_model_id: currentTtsModelId(),
source: 'settings',
})
}
/**
* Tracks explicit voice selection from catalog or custom input controls.
*/
function selectSpeechVoice(voiceId: string | undefined) {
if (!voiceId)
return
trackVoiceSelected({
tts_provider_id: activeSpeechProvider.value || 'unknown',
tts_model_id: currentTtsModelId(),
...voiceAnalyticsPayload(voiceId),
source: 'settings',
})
}
// Sync OpenAI Compatible model and voice from provider config
function syncOpenAICompatibleSettings() {
if (activeSpeechProvider.value !== 'openai-compatible-audio-speech')
@@ -135,6 +216,19 @@ async function bindVoicePack(pack: (typeof voicePacks.value)[number]) {
if (!bound)
return
await speechStore.loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined)
trackVoicePackBound({
tts_provider_id: activeSpeechProvider.value || 'unknown',
tts_model_id: pack.ttsModelId,
voice_id: pack.voiceId,
voice_pack_id: pack.id,
source: 'settings',
})
trackVoiceSelected({
tts_provider_id: activeSpeechProvider.value || 'unknown',
tts_model_id: pack.ttsModelId,
...voiceAnalyticsPayload(pack.voiceId, boundVoicePack.value),
source: 'settings',
})
}
watch(activeSpeechProvider, async (newProvider, oldProvider) => {
@@ -203,7 +297,7 @@ async function generateTestSpeech() {
}
}
const voicePack = voicePackForSpeechProvider(activeSpeechProvider.value, activeCard.value?.extensions.airi.modules.speech.voicePack)
const voicePack = boundVoicePack.value
if (voicePack) {
model = voicePack.ttsModelId
if (!voice || voice.id !== voicePack.voiceId)
@@ -220,6 +314,11 @@ async function generateTestSpeech() {
return
}
const previewVoicePack = voicePack
const previewVoice = voice
const previewModel = model
const previewProvider = activeSpeechProvider.value || 'unknown'
isGenerating.value = true
errorMessage.value = ''
@@ -260,7 +359,16 @@ async function generateTestSpeech() {
// Play the audio
setTimeout(() => {
if (audioPlayer.value) {
audioPlayer.value.play()
void audioPlayer.value.play()
.then(() => {
trackVoicePreviewPlayed({
tts_provider_id: previewProvider,
tts_model_id: previewModel,
...voiceAnalyticsPayload(previewVoice.id, previewVoicePack, previewProvider),
source: 'manual_preview',
})
})
.catch(() => {})
}
}, 100)
}
@@ -309,6 +417,7 @@ function updateCustomVoiceName(value: string | undefined) {
provider: activeSpeechProvider.value,
gender: 'male',
}
selectSpeechVoice(value)
}
function updateCustomModelName(value: string | undefined) {
@@ -408,7 +517,7 @@ function handleDeleteProvider(providerId: string) {
:value="metadata.id"
:title="metadata.localizedName || 'Unknown'"
:description="metadata.localizedDescription"
@click="trackProviderClick(metadata.id, 'speech')"
@click="selectSpeechProvider(metadata.id)"
>
<template #topRight>
<button
@@ -618,6 +727,7 @@ function handleDeleteProvider(providerId: string) {
:play-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.play_sample')"
:pause-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.pause')"
@update:custom-value="updateCustomVoiceName"
@update:voice-id="selectSpeechVoice"
/>
</div>
@@ -35,7 +35,7 @@ import { initIOTracer } from '../../composables/use-io-tracer'
import { useSpeechPipelineAnalytics } from '../../composables/use-speech-pipeline-analytics'
import { Emotion, EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
import { getDefaultStreamingModel, getDefinedProvider } from '../../libs/providers/providers'
import { OFFICIAL_SPEECH_PROVIDER_ID } from '../../libs/providers/providers/official'
import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '../../libs/providers/providers/official'
import { bindSpeakingStateToPlaybackManager } from '../../libs/speech/playback-speaking-state'
import { createStageTtsSession } from '../../libs/speech/tts-session'
import { useAudioContext, useSpeakingStore } from '../../stores/audio'
@@ -344,6 +344,15 @@ function createVoicePackVoice(voicePack: VoicePackSnapshot): VoiceInfo {
}
}
/**
* Classifies chat auto-TTS voice usage before forwarding analytics to the server.
*/
function resolveStageVoiceType(voicePack: VoicePackSnapshot | undefined): 'official_selected' | 'custom_configured' | 'voice_pack' {
if (voicePack)
return 'voice_pack'
return activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID || activeSpeechProvider.value === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID ? 'official_selected' : 'custom_configured'
}
const speechPipeline = createSpeechPipeline<AudioBuffer>({
tts: async (request, signal) => {
if (signal.aborted)
@@ -461,6 +470,7 @@ const speechPipeline = createSpeechPipeline<AudioBuffer>({
airi_analytics: {
trigger: 'auto',
source: 'chat_auto_tts',
voice_type: resolveStageVoiceType(voicePack),
},
},
}
@@ -675,6 +685,7 @@ function buildStreamingSnapshot(): StreamingSessionSnapshot | null {
return {
model: sessionModel,
voice: voiceId,
voiceType: resolveStageVoiceType(undefined),
bufferEntireSession,
extraBody: {
api_resource_id: apiResourceId,
@@ -1,110 +1,100 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const vueUseMock = vi.hoisted(() => ({
audioInputs: undefined as unknown as { value: MediaDeviceInfo[] },
ensurePermissions: vi.fn(async () => {}),
startUserMediaStream: vi.fn(),
const audioDeviceMock = vi.hoisted(() => ({
audioInputsRef: undefined as unknown as { value: MediaDeviceInfo[] },
ensurePermissions: vi.fn(),
startStream: vi.fn(),
stopStream: vi.fn(),
trackAudioDeviceUnavailable: vi.fn(),
trackMicrophonePermissionDenied: vi.fn(),
trackMicrophonePermissionRequested: vi.fn(),
}))
vi.mock('@vueuse/core', async () => {
const vue = await vi.importActual<typeof import('vue')>('vue')
vueUseMock.audioInputs = vue.ref<MediaDeviceInfo[]>([])
const { ref } = await import('vue')
audioDeviceMock.audioInputsRef = ref([])
return {
useDevicesList: () => ({
audioInputs: vueUseMock.audioInputs,
permissionGranted: vue.ref(false),
ensurePermissions: vueUseMock.ensurePermissions,
audioInputs: audioDeviceMock.audioInputsRef,
permissionGranted: ref(false),
ensurePermissions: audioDeviceMock.ensurePermissions,
}),
useUserMedia: ({ constraints }: { constraints: { value: MediaStreamConstraints } }) => ({
stream: vue.shallowRef<MediaStream>(),
stop: vueUseMock.stopStream,
start: () => vueUseMock.startUserMediaStream(constraints.value),
useUserMedia: () => ({
stream: ref(undefined),
stop: audioDeviceMock.stopStream,
start: audioDeviceMock.startStream,
}),
}
})
function createAudioInput(deviceId: string): MediaDeviceInfo {
return {
deviceId,
groupId: '',
kind: 'audioinput',
label: deviceId,
toJSON: () => ({}),
}
}
vi.mock('../use-analytics', () => ({
useAnalytics: () => ({
trackAudioDeviceUnavailable: audioDeviceMock.trackAudioDeviceUnavailable,
trackMicrophonePermissionDenied: audioDeviceMock.trackMicrophonePermissionDenied,
trackMicrophonePermissionRequested: audioDeviceMock.trackMicrophonePermissionRequested,
}),
}))
function createDeviceNotFoundError() {
const error = new Error('Requested device not found')
error.name = 'NotFoundError'
return error
}
describe('useAudioDevice analytics lifecycle', () => {
beforeEach(() => {
if (audioDeviceMock.audioInputsRef)
audioDeviceMock.audioInputsRef.value = []
audioDeviceMock.ensurePermissions.mockReset()
audioDeviceMock.trackAudioDeviceUnavailable.mockReset()
audioDeviceMock.trackMicrophonePermissionDenied.mockReset()
audioDeviceMock.trackMicrophonePermissionRequested.mockReset()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
describe('useAudioDevice', () => {
afterEach(() => {
vueUseMock.audioInputs.value = []
vi.clearAllMocks()
vi.restoreAllMocks()
})
it('recognizes browser device-not-found errors that are not Error instances', async () => {
const { isMissingAudioInputDeviceError } = await import('./audio-device')
expect(isMissingAudioInputDeviceError({ name: 'NotFoundError' })).toBe(true)
expect(isMissingAudioInputDeviceError({ message: 'Requested device not found' })).toBe(true)
})
it('retries with the system default microphone when a persisted device id is stale', async () => {
/**
* @example
* await expect(askPermission()).rejects.toThrow()
*/
it('tracks microphone permission denial without exposing browser error text', async () => {
const { useAudioDevice } = await import('./audio-device')
const { selectedAudioInput, startStream } = useAudioDevice()
selectedAudioInput.value = 'stale-device-id'
const permissionError = new DOMException('User denied microphone', 'NotAllowedError')
audioDeviceMock.ensurePermissions.mockRejectedValue(permissionError)
vueUseMock.startUserMediaStream
.mockRejectedValueOnce(createDeviceNotFoundError())
.mockResolvedValueOnce(undefined)
const { askPermission } = useAudioDevice()
await startStream()
await expect(askPermission()).rejects.toThrow(permissionError)
expect(selectedAudioInput.value).toBe('')
expect(vueUseMock.startUserMediaStream).toHaveBeenNthCalledWith(1, {
audio: {
autoGainControl: true,
deviceId: { exact: 'stale-device-id' },
echoCancellation: true,
noiseSuppression: true,
},
expect(audioDeviceMock.trackMicrophonePermissionRequested).toHaveBeenCalledWith({
stt_provider_id: 'unknown',
})
expect(vueUseMock.startUserMediaStream).toHaveBeenNthCalledWith(2, {
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
expect(audioDeviceMock.trackMicrophonePermissionDenied).toHaveBeenCalledWith({
stt_provider_id: 'unknown',
error_code: 'permission_denied',
})
expect(audioDeviceMock.trackAudioDeviceUnavailable).not.toHaveBeenCalled()
})
it('prefers an enumerated default input before falling back to unconstrained audio', async () => {
vueUseMock.audioInputs.value = [
createAudioInput('default'),
createAudioInput('microphone-1'),
]
/**
* @example
* await askPermission()
* expect(trackAudioDeviceUnavailable).toHaveBeenCalledWith(expect.objectContaining({ error_code: 'device_unavailable' }))
*/
it('tracks successful permission requests that still expose no microphone devices', async () => {
const { useAudioDevice } = await import('./audio-device')
const { selectedAudioInput, startStream } = useAudioDevice()
selectedAudioInput.value = 'stale-device-id'
audioDeviceMock.ensurePermissions.mockResolvedValue(undefined)
vueUseMock.startUserMediaStream.mockResolvedValueOnce(undefined)
const { askPermission } = useAudioDevice()
await startStream()
await askPermission()
expect(selectedAudioInput.value).toBe('default')
expect(vueUseMock.startUserMediaStream).toHaveBeenCalledWith({
audio: {
autoGainControl: true,
deviceId: { exact: 'default' },
echoCancellation: true,
noiseSuppression: true,
},
expect(audioDeviceMock.trackMicrophonePermissionRequested).toHaveBeenCalledWith({
stt_provider_id: 'unknown',
})
expect(audioDeviceMock.trackAudioDeviceUnavailable).toHaveBeenCalledWith({
stt_provider_id: 'unknown',
error_code: 'device_unavailable',
})
expect(audioDeviceMock.trackMicrophonePermissionDenied).not.toHaveBeenCalled()
})
})
@@ -1,10 +1,20 @@
import { useDevicesList, useUserMedia } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useAnalytics } from '../use-analytics'
const UNKNOWN_STT_PROVIDER_ID = 'unknown'
/**
* Selects the default microphone when available, otherwise the first detected input.
*/
function resolvePreferredAudioInput(audioInputs: MediaDeviceInfo[]) {
return audioInputs.find(device => device.deviceId === 'default')?.deviceId || audioInputs[0]?.deviceId || ''
}
/**
* Detects browser errors caused by a stale or unavailable microphone device.
*/
export function isMissingAudioInputDeviceError(error: unknown) {
if (!error || typeof error !== 'object')
return false
@@ -16,9 +26,30 @@ export function isMissingAudioInputDeviceError(error: unknown) {
|| (typeof message === 'string' && message.includes('Requested device not found'))
}
/**
* Normalizes browser microphone failures into low-cardinality analytics codes.
*/
function audioDeviceErrorCode(error: unknown): 'permission_denied' | 'device_unavailable' {
if (error instanceof DOMException && (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError'))
return 'permission_denied'
return 'device_unavailable'
}
/**
* Provides microphone device selection, permission requests, and audio stream lifecycle state.
*/
export function useAudioDevice(requestPermission: boolean = false) {
const {
trackAudioDeviceUnavailable,
trackMicrophonePermissionDenied,
trackMicrophonePermissionRequested,
} = useAnalytics()
const { audioInputs, permissionGranted, ensurePermissions } = useDevicesList({ constraints: { audio: true }, requestPermissions: requestPermission })
const selectedAudioInput = ref<string>(audioInputs.value.find(device => device.deviceId === 'default')?.deviceId || '')
/**
* Keeps the selected microphone aligned with the currently available device list.
*/
function selectAvailableAudioInput() {
if (!audioInputs.value.length)
return
@@ -49,12 +80,33 @@ export function useAudioDevice(requestPermission: boolean = false) {
})
function askPermission() {
trackMicrophonePermissionRequested({ stt_provider_id: UNKNOWN_STT_PROVIDER_ID })
return ensurePermissions()
.then(() => nextTick())
.then(() => {
selectAvailableAudioInput()
if (audioInputs.value.length <= 0) {
trackAudioDeviceUnavailable({
stt_provider_id: UNKNOWN_STT_PROVIDER_ID,
error_code: 'device_unavailable',
})
}
})
.catch((error) => {
const errorCode = audioDeviceErrorCode(error)
if (errorCode === 'permission_denied') {
trackMicrophonePermissionDenied({
stt_provider_id: UNKNOWN_STT_PROVIDER_ID,
error_code: errorCode,
})
}
else {
trackAudioDeviceUnavailable({
stt_provider_id: UNKNOWN_STT_PROVIDER_ID,
error_code: errorCode,
})
}
console.error('Error ensuring permissions:', error)
throw error // Re-throw so callers can handle the error
})
@@ -132,4 +132,313 @@ describe('useAnalytics conversation product events', () => {
source: 'history',
})
})
/**
* @example
* analytics.trackChatActivationStarted({ provider_mode: 'official', provider_id: 'official-provider', model_id: 'gpt-test', source: 'text' })
* expect(posthog.capture).toHaveBeenCalledWith('chat_activation_started', expect.objectContaining({ surface: 'web' }))
*/
it('emits chat activation milestones with inferred surface and normalized fields', () => {
const analytics = useAnalytics()
analytics.trackChatActivationStarted({
provider_mode: 'official',
provider_id: 'official-provider',
model_id: 'gpt-test',
source: 'text',
})
analytics.trackChatActivationSucceeded({
provider_mode: 'official',
provider_id: 'official-provider',
model_id: 'gpt-test',
time_to_first_message_ms: 1200,
source: 'voice',
})
analytics.trackChatActivationFailed({
provider_mode: 'custom',
provider_id: 'openai-compatible',
model_id: 'custom',
error_code: 'provider_error',
failure_stage: 'llm_response',
source: 'voice',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'chat_activation_started', {
surface: 'web',
provider_mode: 'official',
provider_id: 'official-provider',
model_id: 'gpt-test',
source: 'text',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'chat_activation_succeeded', {
surface: 'web',
provider_mode: 'official',
provider_id: 'official-provider',
model_id: 'gpt-test',
time_to_first_message_ms: 1200,
source: 'voice',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'chat_activation_failed', {
surface: 'web',
provider_mode: 'custom',
provider_id: 'openai-compatible',
model_id: 'custom',
error_code: 'provider_error',
failure_stage: 'llm_response',
source: 'voice',
})
})
/**
* @example
* analytics.trackVoiceSelected({ tts_provider_id: 'official-provider', tts_model_id: 'stepfun/tts', voice_id: 'voice-1', voice_type: 'official_selected', source: 'settings' })
* expect(posthog.capture).toHaveBeenCalledWith('voice_selected', expect.objectContaining({ voice_id: 'voice-1' }))
*/
it('emits TTS voice selection events without losing provider context', () => {
const analytics = useAnalytics()
analytics.trackTtsProviderSelected({
tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts',
source: 'settings',
})
analytics.trackVoiceSelected({
tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts',
voice_id: 'longxiaochun_v2',
voice_type: 'official_selected',
source: 'settings',
})
analytics.trackVoicePreviewPlayed({
tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts',
voice_id: 'longxiaochun_v2',
voice_type: 'official_selected',
source: 'manual_preview',
})
analytics.trackVoicePackBound({
tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts',
voice_id: 'longxiaochun_v2',
voice_pack_id: 'pack-1',
source: 'settings',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'tts_provider_selected', {
surface: 'web',
tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts',
source: 'settings',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'voice_selected', {
surface: 'web',
tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts',
voice_id: 'longxiaochun_v2',
voice_type: 'official_selected',
source: 'settings',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'voice_preview_played', {
surface: 'web',
tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts',
voice_id: 'longxiaochun_v2',
voice_type: 'official_selected',
source: 'manual_preview',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'voice_pack_bound', {
surface: 'web',
tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts',
voice_id: 'longxiaochun_v2',
voice_pack_id: 'pack-1',
source: 'settings',
})
})
/**
* @example
* analytics.trackMicrophonePermissionDenied({ stt_provider_id: 'browser-web-speech-api' })
* expect(posthog.capture).toHaveBeenCalledWith('microphone_permission_denied', expect.objectContaining({ surface: 'web' }))
*/
it('emits voice input friction events with low-cardinality error fields', () => {
const analytics = useAnalytics()
analytics.trackVoiceInputStarted({
stt_provider_id: 'browser-web-speech-api',
})
analytics.trackMicrophonePermissionRequested({
stt_provider_id: 'browser-web-speech-api',
})
analytics.trackMicrophonePermissionDenied({
stt_provider_id: 'browser-web-speech-api',
error_code: 'permission_denied',
})
analytics.trackAudioDeviceUnavailable({
stt_provider_id: 'browser-web-speech-api',
error_code: 'device_unavailable',
})
analytics.trackVoiceInputCancelled({
stt_provider_id: 'browser-web-speech-api',
duration_ms: 420,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'voice_input_started', {
surface: 'web',
stt_provider_id: 'browser-web-speech-api',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'microphone_permission_requested', {
surface: 'web',
stt_provider_id: 'browser-web-speech-api',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'microphone_permission_denied', {
surface: 'web',
stt_provider_id: 'browser-web-speech-api',
error_code: 'permission_denied',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'audio_device_unavailable', {
surface: 'web',
stt_provider_id: 'browser-web-speech-api',
error_code: 'device_unavailable',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'voice_input_cancelled', {
surface: 'web',
stt_provider_id: 'browser-web-speech-api',
duration_ms: 420,
})
})
/**
* @example
* analytics.trackModelListLoaded({ provider_id: 'official-provider', provider_mode: 'official', model_count: 3, duration_ms: 25 })
* expect(posthog.capture).toHaveBeenCalledWith('model_list_loaded', expect.objectContaining({ provider_id: 'official-provider' }))
*/
it('emits provider model-list health events', () => {
const analytics = useAnalytics()
analytics.trackModelListLoaded({
provider_id: 'official-provider',
provider_mode: 'official',
model_count: 3,
duration_ms: 25,
})
analytics.trackModelListFailed({
provider_id: 'openai-compatible',
provider_mode: 'custom',
error_code: 'provider_error',
duration_ms: 40,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'model_list_loaded', {
surface: 'web',
provider_id: 'official-provider',
provider_mode: 'official',
model_count: 3,
duration_ms: 25,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'model_list_failed', {
surface: 'web',
provider_id: 'openai-compatible',
provider_mode: 'custom',
error_code: 'provider_error',
duration_ms: 40,
})
})
/**
* @example
* analytics.trackProviderConfigFailed({ provider_id: 'openai-compatible', provider_mode: 'custom', step: 'settings_auto_validate', error_code: 'validation_failed', duration_ms: 32 })
* expect(posthog.capture).toHaveBeenCalledWith('provider_config_failed', expect.objectContaining({ error_code: 'validation_failed' }))
*/
it('emits provider configuration health events with bounded fields', () => {
const analytics = useAnalytics()
analytics.trackProviderConfigStarted({
provider_id: 'openai-compatible',
provider_mode: 'custom',
step: 'settings_auto_validate',
})
analytics.trackProviderConfigSucceeded({
provider_id: 'official-provider',
provider_mode: 'official',
step: 'manual_chat_ping',
duration_ms: 18,
})
analytics.trackProviderConfigFailed({
provider_id: 'openai-compatible',
provider_mode: 'custom',
step: 'settings_auto_validate',
error_code: 'validation_failed',
duration_ms: 32,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'provider_config_started', {
surface: 'web',
provider_id: 'openai-compatible',
provider_mode: 'custom',
step: 'settings_auto_validate',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'provider_config_succeeded', {
surface: 'web',
provider_id: 'official-provider',
provider_mode: 'official',
step: 'manual_chat_ping',
duration_ms: 18,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'provider_config_failed', {
surface: 'web',
provider_id: 'openai-compatible',
provider_mode: 'custom',
step: 'settings_auto_validate',
error_code: 'validation_failed',
duration_ms: 32,
})
})
/**
* @example
* analytics.trackBugReportSubmitted({ source: 'app', category: 'update', severity: 'major', user_type: 'unknown', entrypoint: 'about_update_error', description_length_bucket: 'medium', include_triage_context: true, screenshot_attached: true })
* expect(posthog.capture).toHaveBeenCalledWith('bug_report_submitted', expect.objectContaining({ category: 'update' }))
*/
it('emits feedback and bug report events with community triage tags', () => {
const analytics = useAnalytics()
analytics.trackBugReportSubmitted({
source: 'app',
category: 'update',
severity: 'major',
user_type: 'unknown',
entrypoint: 'about_update_error',
description_length_bucket: 'medium',
include_triage_context: true,
screenshot_attached: true,
})
analytics.trackFeedbackSubmitted({
source: 'discord',
category: 'voice_input',
severity: 'minor',
user_type: 'new_user',
entrypoint: 'community_manual_tag',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'bug_report_submitted', {
surface: 'web',
source: 'app',
category: 'update',
severity: 'major',
user_type: 'unknown',
entrypoint: 'about_update_error',
description_length_bucket: 'medium',
include_triage_context: true,
screenshot_attached: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'feedback_submitted', {
surface: 'web',
source: 'discord',
category: 'voice_input',
severity: 'minor',
user_type: 'new_user',
entrypoint: 'community_manual_tag',
})
})
})
@@ -20,6 +20,49 @@ export type ConversationAnalyticsSurface = 'web' | 'mobile' | 'electron'
*/
export type ConversationAnalyticsSource = 'chat_controls' | 'history' | 'sessions_drawer'
export type ProviderMode = 'official' | 'custom' | 'unknown'
export type ChatActivationFailureStage = 'provider_config' | 'model_list' | 'message_send' | 'llm_response' | 'tts'
export type ProviderConfigStep = 'settings_auto_validate' | 'manual_chat_ping' | 'onboarding_validate'
export type VoiceType = 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' | 'unknown'
export type VoiceAnalyticsSource = 'settings' | 'onboarding' | 'chat_auto_tts' | 'manual_preview'
export type FeedbackSource = 'app' | 'discord' | 'qq' | 'github' | 'email' | 'other'
export type FeedbackCategory = 'provider_config' | 'model_list' | 'chat_activation' | 'tts' | 'voice_input' | 'performance' | 'payment' | 'ui_ux' | 'crash' | 'update' | 'live2d' | 'desktop_window' | 'mobile' | 'unknown'
export type FeedbackSeverity = 'blocker' | 'major' | 'minor' | 'suggestion'
export type FeedbackUserType = 'new_user' | 'paid_user' | 'overseas_user' | 'developer_user' | 'role_chat_user' | 'unknown'
export type FeedbackDescriptionLengthBucket = 'empty' | 'short' | 'medium' | 'long'
interface ChatActivationBaseProperties {
provider_mode: ProviderMode
provider_id: string
model_id: string
source: 'text' | 'voice'
}
interface TtsVoiceBaseProperties {
tts_provider_id: string
tts_model_id: string
source: VoiceAnalyticsSource
}
interface VoiceInputBaseProperties {
stt_provider_id: string
duration_ms?: number
}
interface ProviderConfigBaseProperties {
provider_id: string
provider_mode: ProviderMode
step: ProviderConfigStep
}
interface FeedbackBaseProperties {
source: FeedbackSource
category: FeedbackCategory
severity: FeedbackSeverity
user_type: FeedbackUserType
entrypoint: string
}
function getConversationAnalyticsSurface(): ConversationAnalyticsSurface {
if (isStageTamagotchi())
return 'electron'
@@ -228,6 +271,96 @@ export function useAnalytics() {
posthog.capture('message_round', properties)
}
// ─── Chat activation events ──────────────────────────────────────────
function trackChatActivationStarted(properties: ChatActivationBaseProperties) {
if (!canCapture())
return
posthog.capture('chat_activation_started', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackChatActivationSucceeded(properties: ChatActivationBaseProperties & { time_to_first_message_ms?: number }) {
if (!canCapture())
return
posthog.capture('chat_activation_succeeded', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackChatActivationFailed(properties: ChatActivationBaseProperties & {
error_code: string
failure_stage: ChatActivationFailureStage
}) {
if (!canCapture())
return
posthog.capture('chat_activation_failed', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackModelListLoaded(properties: {
provider_id: string
provider_mode: ProviderMode
model_count: number
duration_ms: number
}) {
if (!canCapture())
return
posthog.capture('model_list_loaded', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackModelListFailed(properties: {
provider_id: string
provider_mode: ProviderMode
error_code: string
duration_ms: number
}) {
if (!canCapture())
return
posthog.capture('model_list_failed', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackProviderConfigStarted(properties: ProviderConfigBaseProperties) {
if (!canCapture())
return
posthog.capture('provider_config_started', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackProviderConfigSucceeded(properties: ProviderConfigBaseProperties & { duration_ms: number }) {
if (!canCapture())
return
posthog.capture('provider_config_succeeded', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackProviderConfigFailed(properties: ProviderConfigBaseProperties & {
error_code: string
duration_ms: number
}) {
if (!canCapture())
return
posthog.capture('provider_config_failed', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
// ─── Conversation action events ─────────────────────────────────────
function trackTtsStopClicked(properties: { reason: 'manual-chat' }) {
@@ -295,6 +428,75 @@ export function useAnalytics() {
posthog.capture('stt_failed', properties)
}
function trackVoiceInputStarted(properties: VoiceInputBaseProperties) {
if (!canCapture())
return
posthog.capture('voice_input_started', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackMicrophonePermissionRequested(properties: VoiceInputBaseProperties) {
if (!canCapture())
return
posthog.capture('microphone_permission_requested', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackMicrophonePermissionDenied(properties: VoiceInputBaseProperties & { error_code?: 'permission_denied' | string }) {
if (!canCapture())
return
posthog.capture('microphone_permission_denied', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackAudioDeviceUnavailable(properties: VoiceInputBaseProperties & { error_code?: 'device_unavailable' | string }) {
if (!canCapture())
return
posthog.capture('audio_device_unavailable', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackVoiceInputCancelled(properties: VoiceInputBaseProperties) {
if (!canCapture())
return
posthog.capture('voice_input_cancelled', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
// ─── Feedback and community triage events ────────────────────────────
function trackBugReportSubmitted(properties: FeedbackBaseProperties & {
description_length_bucket: FeedbackDescriptionLengthBucket
include_triage_context: boolean
screenshot_attached: boolean
}) {
if (!canCapture())
return
posthog.capture('bug_report_submitted', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackFeedbackSubmitted(properties: FeedbackBaseProperties) {
if (!canCapture())
return
posthog.capture('feedback_submitted', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
// ─── PTT events ──────────────────────────────────────────────────────
function trackPttPressed() {
@@ -310,10 +512,9 @@ export function useAnalytics() {
}
// ─── TTS events (forwarded from speech bus by use-speech-pipeline-analytics) ─
// voice_id is `voice_type: 'catalog' | 'custom'` to keep cardinality
// bounded — MiMo voice clone allows arbitrary user-supplied voice ids
// (see codex F6). Actual voice_id is in properties for debug, NOT for
// PostHog group-by.
// Selection events use catalog `voice_id` values for adoption analysis.
// Custom voices must pass `voice_id = custom` from the callsite when the
// raw provider value is user supplied.
function trackTtsIntentStarted(properties: { intent_id: string, turn_id?: string }) {
if (!canCapture())
@@ -333,6 +534,53 @@ export function useAnalytics() {
posthog.capture('tts_intent_cancelled', properties)
}
function trackTtsProviderSelected(properties: TtsVoiceBaseProperties) {
if (!canCapture())
return
posthog.capture('tts_provider_selected', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackVoiceSelected(properties: TtsVoiceBaseProperties & {
voice_id: string
voice_type: VoiceType
voice_pack_id?: string
}) {
if (!canCapture())
return
posthog.capture('voice_selected', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackVoicePreviewPlayed(properties: TtsVoiceBaseProperties & {
voice_id: string
voice_type: VoiceType
voice_pack_id?: string
}) {
if (!canCapture())
return
posthog.capture('voice_preview_played', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackVoicePackBound(properties: TtsVoiceBaseProperties & {
voice_id: string
voice_pack_id: string
}) {
if (!canCapture())
return
posthog.capture('voice_pack_bound', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
// ─── Autonomous LLM path (artistry-autonomous bypasses chat orchestrator) ─
function trackAutonomousGenerateText(properties: { model: string, reason?: string }) {
@@ -430,6 +678,14 @@ export function useAnalytics() {
trackLlmFirstToken,
trackAssistantResponseRendered,
trackMessageRound,
trackChatActivationStarted,
trackChatActivationSucceeded,
trackChatActivationFailed,
trackModelListLoaded,
trackModelListFailed,
trackProviderConfigStarted,
trackProviderConfigSucceeded,
trackProviderConfigFailed,
trackTtsStopClicked,
trackChatSessionSelected,
trackChatMessageDeleted,
@@ -439,6 +695,13 @@ export function useAnalytics() {
trackSttStarted,
trackSttSucceeded,
trackSttFailed,
trackVoiceInputStarted,
trackMicrophonePermissionRequested,
trackMicrophonePermissionDenied,
trackAudioDeviceUnavailable,
trackVoiceInputCancelled,
trackBugReportSubmitted,
trackFeedbackSubmitted,
trackPttPressed,
trackPttReleased,
@@ -446,6 +709,10 @@ export function useAnalytics() {
trackTtsIntentStarted,
trackTtsIntentEnded,
trackTtsIntentCancelled,
trackTtsProviderSelected,
trackVoiceSelected,
trackVoicePreviewPlayed,
trackVoicePackBound,
trackAutonomousGenerateText,
@@ -1,5 +1,7 @@
import type { RemovableRef } from '@vueuse/core'
import type { ProviderConfigStep, ProviderMode } from './use-analytics'
import { errorMessageFrom } from '@moeru/std'
import { useDebounceFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
@@ -8,11 +10,29 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useProvidersStore } from '../stores/providers'
import { useAnalytics } from './use-analytics'
/**
* Classifies provider ids into bounded analytics buckets.
*/
function providerModeForAnalytics(providerId: string): ProviderMode {
if (!providerId)
return 'unknown'
return providerId.startsWith('official-provider') || providerId.startsWith('vision-official-provider')
? 'official'
: 'custom'
}
export function useProviderValidation(providerId: string) {
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const {
trackProviderConfigFailed,
trackProviderConfigStarted,
trackProviderConfigSucceeded,
} = useAnalytics()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
@@ -59,6 +79,17 @@ export function useProviderValidation(providerId: string) {
const manualTestPassed = ref(false)
const manualTestMessage = ref('')
/**
* Builds the stable provider analytics fields shared by validation events.
*/
function providerConfigAnalyticsBase(step: ProviderConfigStep) {
return {
provider_id: providerId,
provider_mode: providerModeForAnalytics(providerId),
step,
}
}
async function validateConfiguration() {
if (!providerMetadata.value)
return
@@ -66,6 +97,7 @@ export function useProviderValidation(providerId: string) {
isValidating.value++
validationMessage.value = ''
const startValidationTimestamp = performance.now()
trackProviderConfigStarted(providerConfigAnalyticsBase('settings_auto_validate'))
let finalValidationMessage = ''
try {
@@ -82,8 +114,14 @@ export function useProviderValidation(providerId: string) {
})
isValid.value = validationResult.valid
if (!isValid.value)
if (!isValid.value) {
finalValidationMessage = validationResult.reason
trackProviderConfigFailed({
...providerConfigAnalyticsBase('settings_auto_validate'),
error_code: 'validation_failed',
duration_ms: Math.round(performance.now() - startValidationTimestamp),
})
}
// When a provider validates successfully on its settings page,
// mark it as added so it appears in the model selector (e.g. Consciousness module).
@@ -91,6 +129,10 @@ export function useProviderValidation(providerId: string) {
// need an API key, yet should be selectable after successful validation.
if (isValid.value) {
providersStore.markProviderAdded(providerId)
trackProviderConfigSucceeded({
...providerConfigAnalyticsBase('settings_auto_validate'),
duration_ms: Math.round(performance.now() - startValidationTimestamp),
})
}
}
catch (error) {
@@ -98,6 +140,11 @@ export function useProviderValidation(providerId: string) {
finalValidationMessage = t('settings.dialogs.onboarding.validationError', {
error: errorMessageFrom(error) ?? 'Generic error (993b5ad7)',
})
trackProviderConfigFailed({
...providerConfigAnalyticsBase('settings_auto_validate'),
error_code: 'provider_error',
duration_ms: Math.round(performance.now() - startValidationTimestamp),
})
}
finally {
setTimeout(() => {
@@ -113,6 +160,8 @@ export function useProviderValidation(providerId: string) {
isManualTesting.value = true
manualTestMessage.value = ''
const startedAt = performance.now()
trackProviderConfigStarted(providerConfigAnalyticsBase('manual_chat_ping'))
try {
const config = { ...credentials.value }
@@ -125,12 +174,29 @@ export function useProviderValidation(providerId: string) {
onlyChatPingCheck: true,
})
manualTestPassed.value = result.valid
if (!result.valid)
if (result.valid) {
trackProviderConfigSucceeded({
...providerConfigAnalyticsBase('manual_chat_ping'),
duration_ms: Math.round(performance.now() - startedAt),
})
}
else {
manualTestMessage.value = result.reason
trackProviderConfigFailed({
...providerConfigAnalyticsBase('manual_chat_ping'),
error_code: 'validation_failed',
duration_ms: Math.round(performance.now() - startedAt),
})
}
}
catch (error) {
manualTestPassed.value = false
manualTestMessage.value = errorMessageFrom(error) ?? 'Generic error (e56ae24f)'
trackProviderConfigFailed({
...providerConfigAnalyticsBase('manual_chat_ping'),
error_code: 'provider_error',
duration_ms: Math.round(performance.now() - startedAt),
})
}
finally {
isManualTesting.value = false
@@ -18,6 +18,7 @@ vi.mock('../server', () => ({
interface MockServer {
url: string
receivedFrames: Array<{ kind: 'text' | 'binary', data: string | Buffer }>
observedVoiceTypes: string[]
/** Resolves when the server has observed a `start` frame from the client. */
startObserved: Promise<void>
stop: () => Promise<void>
@@ -25,6 +26,7 @@ interface MockServer {
async function startMockServer(handler: (ws: import('ws').WebSocket) => void): Promise<MockServer> {
const receivedFrames: MockServer['receivedFrames'] = []
const observedVoiceTypes: string[] = []
const httpServer = createServer()
const wss = new WebSocketServer({ server: httpServer })
@@ -33,7 +35,12 @@ async function startMockServer(handler: (ws: import('ws').WebSocket) => void): P
resolveStartObserved = res
})
wss.on('connection', (ws) => {
wss.on('connection', (ws, req) => {
const u = new URL(req.url!, 'http://localhost')
const voiceType = u.searchParams.get('tts_voice_type')
if (voiceType != null)
observedVoiceTypes.push(voiceType)
ws.on('message', (data, isBinary) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer)
const decoded = isBinary ? buf : buf.toString('utf8')
@@ -56,6 +63,7 @@ async function startMockServer(handler: (ws: import('ws').WebSocket) => void): P
return {
url: `http://127.0.0.1:${port}`,
receivedFrames,
observedVoiceTypes,
startObserved,
async stop() {
wss.close()
@@ -127,6 +135,7 @@ describe('createStreamingTtsPipeline', () => {
serverUrl: server.url,
model: 'volcengine/seed-tts-1.0',
voice: 'mock',
ttsVoiceType: 'official_selected',
audioContext: makeStubAudioContext(),
onSentence,
onError,
@@ -146,6 +155,7 @@ describe('createStreamingTtsPipeline', () => {
await server.startObserved
const textFrames = server.receivedFrames.filter(f => f.kind === 'text').map(f => JSON.parse(f.data as string))
expect(textFrames.map(f => f.event)).toEqual(['start', 'text', 'text', 'finish'])
expect(server.observedVoiceTypes).toEqual(['official_selected'])
expect(textFrames[1]).toMatchObject({ event: 'text', text: 'hi ' })
expect(textFrames[2]).toMatchObject({ event: 'text', text: 'there' })
@@ -52,6 +52,8 @@ export interface StreamingTtsPipelineOptions extends StreamingTtsPipelineEvents
ttsTrigger?: 'auto' | 'manual'
/** Low-cardinality source hint sent to server-side product analytics. */
ttsSource?: 'chat_auto_tts' | 'manual_preview' | 'settings_test'
/** Low-cardinality voice bucket sent to server-side product analytics. */
ttsVoiceType?: 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' | 'unknown'
/**
* Decoder context. The pipeline calls `decodeAudioData` on it for each
* sentence (or once at session end in buffered mode). Reusing the page's
@@ -122,6 +124,7 @@ export function createStreamingTtsPipeline(options: StreamingTtsPipelineOptions)
const wsUrl = toWebSocketUrl(options.serverUrl ?? SERVER_URL, '/api/v1/audio/speech/ws', token, {
ttsTrigger: options.ttsTrigger ?? 'auto',
ttsSource: options.ttsSource ?? 'chat_auto_tts',
ttsVoiceType: options.ttsVoiceType ?? 'unknown',
})
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
@@ -411,13 +414,18 @@ function toWebSocketUrl(
httpBase: string,
path: string,
token: string,
analytics: { ttsTrigger: 'auto' | 'manual', ttsSource: 'chat_auto_tts' | 'manual_preview' | 'settings_test' },
analytics: {
ttsTrigger: 'auto' | 'manual'
ttsSource: 'chat_auto_tts' | 'manual_preview' | 'settings_test'
ttsVoiceType: 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' | 'unknown'
},
): string {
const u = new URL(path, httpBase)
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'
u.searchParams.set('token', token)
u.searchParams.set('tts_trigger', analytics.ttsTrigger)
u.searchParams.set('tts_source', analytics.ttsSource)
u.searchParams.set('tts_voice_type', analytics.ttsVoiceType)
return u.toString()
}
@@ -19,12 +19,14 @@ vi.mock('../server', () => ({
interface MockServer {
url: string
observedTokens: string[]
observedVoiceTypes: string[]
closeUnexpectedly: () => void
stop: () => Promise<void>
}
async function startMockServer(handler: (ws: import('ws').WebSocket) => void): Promise<MockServer> {
const observedTokens: string[] = []
const observedVoiceTypes: string[] = []
const httpServer = createServer()
const wss = new WebSocketServer({ server: httpServer })
@@ -36,6 +38,9 @@ async function startMockServer(handler: (ws: import('ws').WebSocket) => void): P
const token = u.searchParams.get('token')
if (token != null)
observedTokens.push(token)
const voiceType = u.searchParams.get('tts_voice_type')
if (voiceType != null)
observedVoiceTypes.push(voiceType)
handler(ws)
})
@@ -46,6 +51,7 @@ async function startMockServer(handler: (ws: import('ws').WebSocket) => void): P
return {
url: `http://127.0.0.1:${port}`,
observedTokens,
observedVoiceTypes,
closeUnexpectedly: () => {
activeWs?.close(1011, 'simulated_truncation')
},
@@ -103,6 +109,7 @@ describe('streamingSynthesize', () => {
serverUrl: server.url,
model: 'volcengine/seed-tts-2.0',
voice: 'mock',
ttsVoiceType: 'official_selected',
input: 'hello',
})
@@ -114,6 +121,7 @@ describe('streamingSynthesize', () => {
expect(result.sentences).toHaveLength(1)
expect(result.sentences[0]).toMatchObject({ kind: 'end' })
expect(server.observedTokens).toEqual(['test-jwt'])
expect(server.observedVoiceTypes).toEqual(['official_selected'])
})
it('rejects when the ws closes before session.finished (codex HIGH #2)', async () => {
@@ -58,6 +58,8 @@ export interface StreamingTtsSessionOptions {
ttsTrigger?: 'auto' | 'manual'
/** Low-cardinality source hint sent to server-side product analytics. */
ttsSource?: 'chat_auto_tts' | 'manual_preview' | 'settings_test'
/** Low-cardinality voice bucket sent to server-side product analytics. */
ttsVoiceType?: 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' | 'unknown'
/** Caller-side abort signal. Closes the ws and rejects with `AbortError`. */
signal?: AbortSignal
}
@@ -92,6 +94,7 @@ export async function streamingSynthesize(options: StreamingTtsSessionOptions):
const wsUrl = toWebSocketUrl(baseUrl, '/api/v1/audio/speech/ws', token, {
ttsTrigger: options.ttsTrigger ?? 'manual',
ttsSource: options.ttsSource ?? 'manual_preview',
ttsVoiceType: options.ttsVoiceType ?? 'unknown',
})
const audioChunks: ArrayBuffer[] = []
@@ -236,13 +239,18 @@ function toWebSocketUrl(
httpBase: string,
path: string,
token: string,
analytics: { ttsTrigger: 'auto' | 'manual', ttsSource: 'chat_auto_tts' | 'manual_preview' | 'settings_test' },
analytics: {
ttsTrigger: 'auto' | 'manual'
ttsSource: 'chat_auto_tts' | 'manual_preview' | 'settings_test'
ttsVoiceType: 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' | 'unknown'
},
): string {
const u = new URL(path, httpBase)
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'
u.searchParams.set('token', token)
u.searchParams.set('tts_trigger', analytics.ttsTrigger)
u.searchParams.set('tts_source', analytics.ttsSource)
u.searchParams.set('tts_voice_type', analytics.ttsVoiceType)
return u.toString()
}
@@ -48,6 +48,7 @@ function makeStreamingSnapshot(overrides: Partial<StreamingSessionSnapshot> = {}
return {
model: 'volcengine/seed-tts-2.0',
voice: 'mock-voice',
voiceType: 'official_selected',
bufferEntireSession: false,
extraBody: { api_resource_id: 'seed-tts-2.0' },
ownerId: 'card-1',
@@ -191,6 +192,8 @@ describe('createStreamingTtsSession (adapter)', () => {
pipelineFactory: pipe.factory as any,
})
expect(pipe.options.ttsVoiceType).toBe('official_selected')
// Simulate the pipeline emitting two sentences.
const audio0 = { __id: 0 } as unknown as AudioBuffer
const audio1 = { __id: 1 } as unknown as AudioBuffer
@@ -71,6 +71,7 @@ function fromIntent(intent: IntentHandleSubset): StageTtsSession {
export interface StreamingSessionSnapshot {
model: string
voice: string
voiceType: 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' | 'unknown'
bufferEntireSession: boolean
extraBody: Record<string, unknown>
/**
@@ -158,6 +159,7 @@ export function createStreamingTtsSession<TAudio = AudioBuffer>(
const handle = pipelineFactory({
model: snapshot.model,
voice: snapshot.voice,
ttsVoiceType: snapshot.voiceType,
audioContext,
bufferEntireSession: snapshot.bufferEntireSession,
extraBody: snapshot.extraBody,
@@ -67,6 +67,9 @@ vi.mock('../composables', () => ({
trackLlmFirstToken: vi.fn(),
trackAssistantResponseRendered: vi.fn(),
trackMessageRound: vi.fn(),
trackChatActivationStarted: vi.fn(),
trackChatActivationSucceeded: vi.fn(),
trackChatActivationFailed: vi.fn(),
}),
}))
+33
View File
@@ -56,6 +56,9 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
trackLlmFirstToken,
trackAssistantResponseRendered,
trackMessageRound,
trackChatActivationStarted,
trackChatActivationSucceeded,
trackChatActivationFailed,
} = useAnalytics()
const chatSession = useChatSessionStore()
@@ -132,6 +135,15 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
ownedActiveTurnSpan = undefined
}
/**
* Classifies configured chat providers into low-cardinality product analytics buckets.
*/
function providerMode(providerId: string | undefined): 'official' | 'custom' | 'unknown' {
if (!providerId)
return 'unknown'
return providerId.startsWith('official-provider') ? 'official' : 'custom'
}
const runtime = createChatOrchestratorRuntime({
session: {
ensureSession: sessionId => chatSession.ensureSession(sessionId),
@@ -187,6 +199,27 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
has_voice: hasVoice,
model,
}),
onChatActivationStarted: ({ model, provider, source }) => trackChatActivationStarted({
provider_mode: providerMode(provider),
provider_id: provider || 'unknown',
model_id: model || 'unknown',
source,
}),
onChatActivationSucceeded: ({ model, provider, durationMs, source }) => trackChatActivationSucceeded({
provider_mode: providerMode(provider),
provider_id: provider || 'unknown',
model_id: model || 'unknown',
time_to_first_message_ms: durationMs,
source,
}),
onChatActivationFailed: ({ model, provider, errorCode, failureStage, source }) => trackChatActivationFailed({
provider_mode: providerMode(provider),
provider_id: provider || 'unknown',
model_id: model || 'unknown',
error_code: errorCode,
failure_stage: failureStage,
source,
}),
onLifecycle: record => contextObservability.recordLifecycle(record),
onPromptProjection: payload => contextObservability.capturePromptProjection(payload),
onUserMessageAppended: ({ sessionId, message, messageText }) => {
@@ -3,9 +3,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const analyticsMock = vi.hoisted(() => ({
allowComposableCall: true,
trackAudioDeviceUnavailable: vi.fn(),
trackMicrophonePermissionDenied: vi.fn(),
trackSttFailed: vi.fn(),
trackSttStarted: vi.fn(),
trackSttSucceeded: vi.fn(),
trackVoiceInputCancelled: vi.fn(),
trackVoiceInputStarted: vi.fn(),
}))
const transcriptionMock = vi.hoisted(() => ({
generateTranscription: vi.fn(async () => ({ text: 'hello' })),
}))
vi.mock('../../composables/use-analytics', () => ({
@@ -14,15 +22,19 @@ vi.mock('../../composables/use-analytics', () => ({
throw new Error('Must be called at the top of a `setup` function')
return {
trackAudioDeviceUnavailable: analyticsMock.trackAudioDeviceUnavailable,
trackMicrophonePermissionDenied: analyticsMock.trackMicrophonePermissionDenied,
trackSttFailed: analyticsMock.trackSttFailed,
trackSttStarted: analyticsMock.trackSttStarted,
trackSttSucceeded: analyticsMock.trackSttSucceeded,
trackVoiceInputCancelled: analyticsMock.trackVoiceInputCancelled,
trackVoiceInputStarted: analyticsMock.trackVoiceInputStarted,
}
},
}))
vi.mock('@xsai/generate-transcription', () => ({
generateTranscription: vi.fn(async () => ({ text: 'hello' })),
generateTranscription: transcriptionMock.generateTranscription,
}))
vi.mock('vue-i18n', () => ({
@@ -36,9 +48,15 @@ describe('useHearingStore analytics lifecycle', () => {
beforeEach(() => {
setActivePinia(createPinia())
analyticsMock.allowComposableCall = true
analyticsMock.trackAudioDeviceUnavailable.mockReset()
analyticsMock.trackMicrophonePermissionDenied.mockReset()
analyticsMock.trackSttFailed.mockReset()
analyticsMock.trackSttStarted.mockReset()
analyticsMock.trackSttSucceeded.mockReset()
analyticsMock.trackVoiceInputCancelled.mockReset()
analyticsMock.trackVoiceInputStarted.mockReset()
transcriptionMock.generateTranscription.mockReset()
transcriptionMock.generateTranscription.mockResolvedValue({ text: 'hello' })
})
/**
@@ -60,6 +78,9 @@ describe('useHearingStore analytics lifecycle', () => {
)
expect(result.text).toBe('hello')
expect(analyticsMock.trackVoiceInputStarted).toHaveBeenCalledWith({
stt_provider_id: 'openai-compatible-audio-transcription',
})
expect(analyticsMock.trackSttStarted).toHaveBeenCalledWith('openai-compatible-audio-transcription')
expect(analyticsMock.trackSttSucceeded).toHaveBeenCalledWith({
provider: 'openai-compatible-audio-transcription',
@@ -68,4 +89,34 @@ describe('useHearingStore analytics lifecycle', () => {
stream: false,
})
}, 10000)
/**
* @example
* await expect(hearingStore.transcription(providerId, provider, model, file)).rejects.toThrow()
*/
it('normalizes microphone permission failures for analytics', async () => {
const { useHearingStore } = await import('./hearing')
const hearingStore = useHearingStore()
const permissionError = new DOMException('User denied microphone', 'NotAllowedError')
transcriptionMock.generateTranscription.mockRejectedValueOnce(permissionError)
await expect(hearingStore.transcription(
'openai-compatible-audio-transcription',
{
transcription: () => ({}),
} as any,
'FunAudioLLM/SenseVoiceSmall',
new File(['hello'], 'recording.wav', { type: 'audio/wav' }),
)).rejects.toBe(permissionError)
expect(analyticsMock.trackSttFailed).toHaveBeenCalledWith({
provider: 'openai-compatible-audio-transcription',
error_code: 'permission_denied',
})
expect(analyticsMock.trackMicrophonePermissionDenied).toHaveBeenCalledWith({
stt_provider_id: 'openai-compatible-audio-transcription',
error_code: 'permission_denied',
})
expect(analyticsMock.trackAudioDeviceUnavailable).not.toHaveBeenCalled()
}, 10000)
})
@@ -48,6 +48,33 @@ function isExpectedStreamStopError(err: unknown): boolean {
&& (err.message === 'Stopped' || err.message === 'Aborted' || err.message === 'Closed' || err.message === 'Idle timeout')
}
type TranscriptionAnalyticsErrorCode = 'permission_denied' | 'device_unavailable' | 'input_unavailable' | 'provider_error' | 'unknown'
/**
* Normalizes transcription failures into bounded analytics error codes.
*/
function transcriptionAnalyticsErrorCode(err: unknown): TranscriptionAnalyticsErrorCode {
if (err instanceof DOMException) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError')
return 'permission_denied'
if (err.name === 'NotFoundError' || err.name === 'NotReadableError')
return 'device_unavailable'
}
const message = (errorMessageFrom(err) ?? '').toLowerCase()
if (message.includes('permission') || message.includes('notallowed'))
return 'permission_denied'
if (message.includes('microphone') || message.includes('audio track') || message.includes('device'))
return 'device_unavailable'
if (message.includes('file input') || message.includes('compatible input'))
return 'input_unavailable'
return message ? 'provider_error' : 'unknown'
}
function haveStreamingCallbacksChanged(
previous: { onSentenceEnd?: (delta: string) => void, onSpeechEnd?: (text: string) => void } | undefined,
next: { onSentenceEnd?: (delta: string) => void, onSpeechEnd?: (text: string) => void },
@@ -281,7 +308,14 @@ export function resolveTranscriptionProviderOptions(providerConfig?: Record<stri
export const useHearingStore = defineStore('hearing-store', () => {
const providersStore = useProvidersStore()
const { allAudioTranscriptionProvidersMetadata } = storeToRefs(providersStore)
const { trackSttStarted, trackSttSucceeded, trackSttFailed } = useAnalytics()
const {
trackAudioDeviceUnavailable,
trackMicrophonePermissionDenied,
trackSttFailed,
trackSttStarted,
trackSttSucceeded,
trackVoiceInputStarted,
} = useAnalytics()
// State
const activeTranscriptionProvider = useLocalStorageManualReset('settings/hearing/active-provider', '')
@@ -378,6 +412,7 @@ export const useHearingStore = defineStore('hearing-store', () => {
const streamExecutor = resolveStreamTranscriptionExecutor(providerId)
const sttStartedAt = performance.now()
trackVoiceInputStarted({ stt_provider_id: providerId })
trackSttStarted(providerId)
function emitSucceeded(charCount: number, stream: boolean) {
@@ -389,7 +424,20 @@ export const useHearingStore = defineStore('hearing-store', () => {
})
}
function emitFailed(err: unknown) {
trackSttFailed({ provider: providerId, error_code: (errorMessageFrom(err) ?? 'unknown').slice(0, 64) })
const errorCode = transcriptionAnalyticsErrorCode(err)
trackSttFailed({ provider: providerId, error_code: errorCode })
if (errorCode === 'permission_denied') {
trackMicrophonePermissionDenied({
stt_provider_id: providerId,
error_code: errorCode,
})
}
if (errorCode === 'device_unavailable') {
trackAudioDeviceUnavailable({
stt_provider_id: providerId,
error_code: errorCode,
})
}
}
try {
@@ -515,6 +563,11 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
const hearingStore = useHearingStore()
const { activeTranscriptionProvider, activeTranscriptionModel } = storeToRefs(hearingStore)
const providersStore = useProvidersStore()
const {
trackAudioDeviceUnavailable,
trackVoiceInputCancelled,
trackVoiceInputStarted,
} = useAnalytics()
const streamingSession = shallowRef<{
audioContext: AudioContext | Record<string, never>
workletNode: AudioWorkletNode | Record<string, never>
@@ -765,6 +818,8 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
// Special handling for Web Speech API - it works directly with MediaStream
if (providerId === 'browser-web-speech-api') {
trackVoiceInputStarted({ stt_provider_id: providerId })
// Check if Web Speech API is available
const isAvailable = typeof window !== 'undefined'
&& ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)
@@ -1063,11 +1118,16 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
if (!recording) {
error.value = 'No recording captured from microphone'
trackVoiceInputCancelled({ stt_provider_id: activeTranscriptionProvider.value || 'unknown' })
return
}
if (recording.size <= 0) {
error.value = 'Recording captured from microphone is empty'
trackAudioDeviceUnavailable({
stt_provider_id: activeTranscriptionProvider.value || 'unknown',
error_code: 'device_unavailable',
})
return
}
+14 -1
View File
@@ -408,6 +408,7 @@ export const useSpeechStore = defineStore('speech', () => {
? withAiriTtsAnalytics(providerConfig, {
trigger: 'manual',
source: 'manual_preview',
voice_type: resolveVoiceType(voice),
})
: providerConfig
const response = await generateSpeech({
@@ -421,7 +422,11 @@ export const useSpeechStore = defineStore('speech', () => {
function withAiriTtsAnalytics(
providerConfig: Record<string, any>,
analytics: { trigger: 'auto' | 'manual', source: 'chat_auto_tts' | 'manual_preview' | 'settings_test' },
analytics: {
trigger: 'auto' | 'manual'
source: 'chat_auto_tts' | 'manual_preview' | 'settings_test'
voice_type?: 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack'
},
): Record<string, any> {
return {
...providerConfig,
@@ -432,6 +437,14 @@ export const useSpeechStore = defineStore('speech', () => {
}
}
/**
* Classifies the active speech voice before forwarding analytics to the server.
*/
function resolveVoiceType(voiceId: string): 'official_selected' | 'custom_configured' {
const catalogVoice = availableVoices.value[activeSpeechProvider.value]?.some(voice => voice.id === voiceId)
return activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID && catalogVoice ? 'official_selected' : 'custom_configured'
}
function generateSSML(
text: string,
voice: VoiceInfo,
+94 -1
View File
@@ -24,7 +24,7 @@ import type { ProviderOnboardingField } from '../libs/providers/types'
import type { AliyunRealtimeSpeechExtraOptions } from './providers/aliyun/stream-transcription'
import { errorMessageFrom } from '@moeru/std'
import { isCustomProvidersDisabled, isStageTamagotchi, isUrl } from '@proj-airi/stage-shared'
import { isCustomProvidersDisabled, isStageCapacitor, isStageTamagotchi, isUrl } from '@proj-airi/stage-shared'
import { getCachedWebGPUCapabilities, isWebGPUSupported } from '@proj-airi/stage-shared/webgpu'
import { computedAsync, useIntervalFn, useLocalStorage } from '@vueuse/core'
import {
@@ -55,6 +55,7 @@ import { getKokoroAdapter } from '../libs/inference/adapters/kokoro'
import { getProviderValidationIntervalMs, listProviders as listDefinedProviders, ProviderValidationCheck } from '../libs/providers'
import { resolveProviderSourceMetadata } from '../libs/providers/source-metadata'
import { getDefaultKokoroModel, KOKORO_MODELS, kokoroModelsToModelInfo } from '../workers/kokoro/constants'
import { capturePosthogEvent, ensurePosthogInitialized, isPosthogAvailableInBuild } from './analytics/posthog'
import { useAuthStore } from './auth'
import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './providers/aliyun/stream-transcription'
import { convertProviderDefinitionsToMetadata } from './providers/converters'
@@ -63,6 +64,7 @@ import { buildGoogleGeminiSpeechProvider } from './providers/google-gemini-speec
import { buildOpenAICompatibleProvider } from './providers/openai-compatible-builder'
import { buildOpenRouterAudioSpeechProvider } from './providers/openrouter/audio-speech'
import { createWebSpeechAPIProvider } from './providers/web-speech-api'
import { useSettingsAnalytics } from './settings/analytics'
const ALIYUN_NLS_REGIONS = [
'cn-shanghai',
@@ -80,6 +82,78 @@ function toListVoicesOptions<T>(provider: VoiceProviderWithExtraOptions<T>, opti
return voiceOptions
}
/**
* Classifies provider ids into bounded analytics buckets.
*/
function analyticsProviderMode(providerId: string): 'official' | 'custom' | 'unknown' {
if (!providerId)
return 'unknown'
return providerId.startsWith('official-provider') || providerId.startsWith('vision-official-provider') ? 'official' : 'custom'
}
/**
* Resolves the current app surface without importing the analytics store.
*/
function analyticsSurface(): 'web' | 'mobile' | 'electron' {
if (isStageTamagotchi())
return 'electron'
if (isStageCapacitor())
return 'mobile'
return 'web'
}
/**
* Checks analytics settings and initializes PostHog without loading build metadata.
*/
function canCaptureProviderAnalytics(): boolean {
if (!isPosthogAvailableInBuild())
return false
const settingsAnalytics = useSettingsAnalytics()
if (!settingsAnalytics.analyticsEnabled)
return false
return ensurePosthogInitialized(true)
}
/**
* Emits model-list analytics from the provider store without loading build metadata.
*/
function trackModelListLoaded(properties: {
provider_id: string
provider_mode: 'official' | 'custom' | 'unknown'
model_count: number
duration_ms: number
}) {
if (!canCaptureProviderAnalytics())
return
capturePosthogEvent('model_list_loaded', {
...properties,
surface: analyticsSurface(),
})
}
/**
* Emits model-list failure analytics from the provider store without loading build metadata.
*/
function trackModelListFailed(properties: {
provider_id: string
provider_mode: 'official' | 'custom' | 'unknown'
error_code: string
duration_ms: number
}) {
if (!canCaptureProviderAnalytics())
return
capturePosthogEvent('model_list_failed', {
...properties,
surface: analyticsSurface(),
})
}
export interface ProviderMetadata {
id: string
to?: string
@@ -2566,6 +2640,7 @@ export const useProvidersStore = defineStore('providers', () => {
// Function to fetch models for a specific provider
async function fetchModelsForProvider(providerId: string) {
const startedAt = Date.now()
const metadata = providerMetadata[providerId]
if (!metadata)
return []
@@ -2594,8 +2669,20 @@ export const useProvidersStore = defineStore('providers', () => {
deprecated: model.deprecated,
provider: providerId,
}))
trackModelListLoaded({
provider_id: providerId,
provider_mode: analyticsProviderMode(providerId),
model_count: runtimeState.models.length,
duration_ms: Date.now() - startedAt,
})
return runtimeState.models
}
trackModelListLoaded({
provider_id: providerId,
provider_mode: analyticsProviderMode(providerId),
model_count: 0,
duration_ms: Date.now() - startedAt,
})
return []
}
catch (error) {
@@ -2603,6 +2690,12 @@ export const useProvidersStore = defineStore('providers', () => {
if (runtimeState) {
runtimeState.modelLoadError = errorMessageFrom(error) ?? 'Unknown error'
}
trackModelListFailed({
provider_id: providerId,
provider_mode: analyticsProviderMode(providerId),
error_code: 'provider_error',
duration_ms: Date.now() - startedAt,
})
return []
}
finally {