feat(analytics): integrate PostHog for server-side event tracking

- Added a new PostHog client for capturing server-side business events such as Stripe webhooks and subscription state changes.
- Implemented various tracking functions for pricing funnel steps, character creation, and chat session starts.
- Enhanced the flux meter tests to handle partial charges and report unbilled flux correctly.
- Updated the CharacterDialog and Flux settings pages to track user interactions with analytics events.
- Introduced a mechanism to identify users on PostHog based on authentication state to ensure accurate funnel tracking.
- Added necessary dependencies for PostHog integration in the project.
This commit is contained in:
RainbowBird
2026-05-15 16:20:47 +08:00
parent bc7dda3d5f
commit 3984677b01
21 changed files with 1408 additions and 173 deletions
+7
View File
@@ -31,6 +31,8 @@
- traces / metrics 命名规则,标准 OTel 字段与 `airi.*` 自定义字段边界,SemconvStability 迁移、Counter priming、Dashboard 变量陷阱
- `observability-metrics.md`
- 全量 metric 目录(按域分组:HTTP / Auth / Engagement / Revenue / GenAI / Email / Rate limit / Runtime),含名字、类型、Labels、落点
- `metrics-ownership.md`
- 指标分层规则:什么走 Grafana / 什么走 PostHog / 什么是 Postgres truth;含 7 题判定 Checklist、PostHog 事件命名约定、当前指标归属总表、PostHog 接入路线图
- `auth-and-oidc.md`
- 认证与 OIDC Provider 架构、登录流程、trusted clients、踩坑记录
- `email-auth-resend.md`
@@ -45,6 +47,10 @@
- 账号注销端到端验证:what's verifiedschema/typecheck/units)和 what's pendinglive DB + Resend + Stripe trace
- `verifications/admin-flux-grants.md`
- Admin 同步发 FLUX 路径:同步 grant / dry-run / adminGuard 拒绝(架构刚从 batch 切换到同步,待重新实测)
- `verifications/flux-unbilled-exploit-fix.md`
- Unpaid-usage exploit 修补(commit `7267b0d6b`)的代码层验证 + 残余 gapTTS flux-meter 未适配 partial-debit+ follow-up 清单
- `verifications/flux-unbilled-reconciliation.md`
- 70.2K 历史漏账的取证 SQL + Loki query 模板、处理决策框架、修补后的监控建议
## 快速结论
@@ -67,6 +73,7 @@
- 改 Flux 充值价格 / 多币种 / Stripe Product/Price:先看 `stripe-pricing.md`
- 改 trace / metric attributes、OTel 命名:先看 `observability-conventions.md`
- 加新 metric / 找当前 metric 全量列表:先看 `observability-metrics.md`
- 决定新指标该走 Grafana 还是 PostHog:先看 `metrics-ownership.md`
- 改认证、OIDC、登录流程:先看 `auth-and-oidc.md`
- 改邮件 service / Better Auth 邮件 callback:先看 `email-auth-resend.md`
- 改账号注销 / 业务 service 的 `deleteAllForUser`:先看 `account-deletion.md`
@@ -0,0 +1,269 @@
# Metrics Ownership
这份文档定义 AIRI 团队的指标分层规则:什么指标该走 Grafana / PrometheusOTel server-side),什么该走 PostHog(前后端混合 product analytics),同名指标怎么处理。落地这份是为了避免后期"同一个 KPI 三处不同数"的漂移。
## 总原则
工具职责正交,**互补不互替**
| 层 | 工具 | 关键属性 |
|---|---|---|
| **System / API observability** | Grafana Cloud + Prometheus + OTel | 系统健康、延迟、错误率、SRE on-call 告警 |
| **Product analytics** | PostHog Cloud | 用户行为、漏斗、retention、cohort、A/B、feature adoption |
| **Financial truth source** | Postgres (`flux_transaction` / Stripe webhook 持久化) | 收入与扣费 ledger,任何展示都视作近似 |
| **LLM-native observability**(预留) | Langfuse / Helicone(未接入) | token cost、prompt eval、provider trace — 后续按需引入 |
**业界没有权威的判定 framework**(参见下方"参考来源"),这份文档落实成项目内的可执行规则。
## 7 题判定 Checklist
每条新增指标依次问这 7 个问题:
| # | 问题 | 偏 Grafana | 偏 PostHog |
|---|------|-----------|------------|
| 1 | 超阈值需要**分钟级 on-call 告警** | ✓ | |
| 2 | 主要读者是 **SRE / 后端工程师**,不是 PM | ✓ | |
| 3 | 需要跟 **trace / log join**(分布式 debug)? | ✓ | |
| 4 | 含义依赖**用户身份 / session**"哪个用户做了什么")? | | ✓ |
| 5 | 消费场景是**漏斗 / retention cohort / A/B test** | | ✓ |
| 6 | 会被 **CEO / PM 在周会 OKR review** 看? | | ✓ |
| 7 | 采集点在**前端页面**pricing page、onboarding)? | (拿不到) | ✓ |
**裁决规则**
- ≥4 个偏一侧 → 那一侧
- 平局 → 两边都放,但**指定唯一 truth side**(见下文)
- 如果一个指标 7 题答下来很纠结,多半是**指标定义本身没拆干净**——应该拆成两个不同的指标,分别归到两边,而不是混合归属
## Truth Side(重复指标处理)
业界没有银弹(PostHog 官方在 [issue #43633](https://github.com/posthog/posthog/issues/43633) 也承认 dual-emit 没有统一 pattern)。我们的做法:**接受两边数字差异,dashboard 上标注语义不同**。
### Truth side 指定原则
| 指标类型 | Truth side | 理由 |
|---|---|---|
| 计费 ledger(每一分钱可审计) | **Postgres** | Grafana / PostHog 都视作近似展示,争议查 SQL |
| HTTP / WS / DB / Stripe webhook **计数** | **Grafana**OTel counter | 系统事件,PostHog 看不到 |
| 用户去重 DAU / WAU / retention | **PostHog** | 需要 distinctId 去重,session table 计数不准 |
| 收入展示(MRR / ARR / churn revenue | **Postgres → 两边展示** | 真相在 PostgresGrafana 取系统侧切片(panel-30),PostHog 取用户维度切片 |
| LLM token / cost | **Grafana**(短期) | 后续若引入 Langfuse 则迁过去 |
| 用户行为漏斗各步骤 | **PostHog**(必须) | 第一步通常是前端事件,Grafana 拿不到 |
### Dashboard 标注规则
两边都展示的指标,**必须**在 Grafana panel description 和 PostHog insight description 里:
1. 注明 truth side"Truth: Postgres `flux_transaction` 表" / "Truth: PostHog 事件去重"
2. 注明本侧统计的语义差异(如 "Grafana 这里是 session 计数,不去重;PostHog 那边是 user 去重 DAU"
3. 如果两边数字差异预期 > 10%,写明合理范围
## PostHog 事件命名约定
格式:`<noun>_<verb_past_tense>`,全部 `snake_case`
| 约定 | 示例 |
|---|---|
| 名词在前,动词过去式在后 | `pricing_page_viewed``plan_selected``payment_completed` |
| 一律 past tense | `signup_completed` 不是 `complete_signup` |
| 不带产品 / 模块前缀 | `chat_session_started` 不是 `airi_chat_session_started` |
| 不带技术细节前缀 | `model_switched` 不是 `frontend_model_switched` |
| properties 用 `snake_case` | `{ plan_id, price_usd, checkout_session_id }` |
| 跟外部系统串联的 ID 用原平台命名 | `stripe_customer_id``stripe_subscription_id``checkout_session_id` |
`distinctId` 在登录后必须调 `posthog.identify(userId)`userId 用 Better Auth 的 user id(跟 server 里的 `c.get('user').id` 一致)。后端 `posthog-node` 上报支付事件时用 fallback 链 `userId` (`session.metadata.userId`) > `email` (`session.customer_email`) > `session.id`——第一项跟前端 `identify` 一致,PostHog person merge 在这里完成。前端 wiring 由 `useSharedAnalyticsStore.initialize()` 自动处理,不需要每个 caller 手动 identify。
参考来源:[PostHog: 5 events all teams should track](https://posthog.com/blog/events-you-should-track-with-posthog)。
## Grafana 指标命名约定
沿用现有 [`observability-conventions.md`](./observability-conventions.md) 不再重复,关键约束:
- OTel semconv 优先(`http_*` / `db_*` / `gen_ai_*`),匹配不上才放 `airi.*` 命名空间
- counter 一律 `_total` 后缀,histogram 一律 `_seconds_bucket` / `_bytes_bucket`
- label 基数受控(route pattern 而非 URLmodel name 而非 prompt
## 当前指标归属总表
### Grafana / Prometheus(系统侧)
来源:`apps/server/src/otel/index.ts` 全量列表见 [`observability-metrics.md`](./observability-metrics.md)。Dashboard 配置在 [`apps/server/otel/grafana/dashboards/build.ts`](../../otel/grafana/dashboards/build.ts)。
| 域 | 代表性指标 | Truth | 备注 |
|---|---|---|---|
| HTTP | `http_server_request_duration_seconds_*` | Grafana | OTel 标准 |
| WS | `ws_connections_active` / `ws_messages_*_total` | Grafana | |
| LLM | `gen_ai_client_operation_count_total` / `gen_ai_client_first_token_duration_seconds` | Grafana | |
| Billing | `airi_billing_flux_unbilled_total` | Grafana | **告警必须**`increase(airi_billing_flux_unbilled_total[5m]) > 0` |
| Auth | `user_active_sessions` | Postgres → Grafana 派生 | 集群级 gauge,用 `avg()` 不要 `sum()` |
| Stripe | `airi_stripe_revenue_minor_unit_total` / `stripe_events_total` | Postgres → 两边展示 | Grafana 是系统侧 webhook 计数 |
| Runtime | `v8js_memory_*` / `nodejs_eventloop_delay_*` | Grafana | per `service_instance_id` |
| Rate-limit | `airi_rate_limit_blocked_total` | Grafana | in-memory per replica |
### PostHog(前后端混合,产品侧)
已接入:
- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化,三个 appweb / desktop / pocket)按 `isStageTamagotchi()` 等选 project key
- 后端 `posthog-node` 通过 `apps/server/src/services/posthog.ts` + injeca provider `services:posthog`
- 前端↔后端 identity merge`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()`
已埋点:
| 域 | 事件 | 来源 | 落点 | Truth |
|---|---|---|---|---|
| 付费漏斗 | `pricing_page_viewed` / `plan_selected` / `checkout_started` | 前端 | `packages/stage-pages/src/pages/settings/flux.vue` | PostHog |
| 付费漏斗终点 | `payment_completed` | 后端 webhook | `apps/server/src/routes/stripe/index.ts` | PostHog |
| Activation / Retention | `first_model_selected` / `model_switched` | 前端(consciousness store watcher | `packages/stage-ui/src/stores/analytics/index.ts` | PostHog |
| Retention | `character_created` | 前端 | `apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue` | PostHog |
| Retention | `chat_session_started` | 前端 | `packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue` | PostHog |
| Churn | `subscription_cancelled`(带 cancellation_reason | 后端 webhook | `apps/server/src/routes/stripe/index.ts` | PostHog |
| 老事件 | `provider_card_clicked` / `first_message_sent` | 前端 | `packages/stage-ui/src/composables/use-analytics.ts` | PostHog |
待埋点(API 已在 `use-analytics.ts` 暴露但调用点未接入):
| 域 | 事件 | 状态 |
|---|---|---|
| Activation | `user_signed_up` | 等接到 auth callback 完成事件(Better Auth 的 signUp 成功 hook |
| Retention | `voice_mode_activated` | 需要先在 hearing store 加显式 `enableVoiceMode` action — 当前 hearing 没有单一"用户主动启用"那一刻的 trigger,被动监听 + 录音 action 不构成 user intent 信号 |
| Feature adoption | `flux_image_generated` | 等图片生成 feature 上线 |
### 双展示指标(同名两边都有)
| 指标 | Grafana | PostHog | Truth | 语义差异 |
|---|---|---|---|---|
| 活跃用户数 | `user_active_sessions`Postgres session 计数) | DAU = 去重 distinctId | **PostHog** | Grafana 是 active **sessions**PostHog 是 active **users** |
| Checkout 完成数 | `stripe_checkout_completed_total` | `payment_completed` event | **Postgres** | 两边都展示,Grafana 是 webhook 计数,PostHog 是漏斗终点 |
| LLM 请求 | `gen_ai_client_operation_count_total` | `chat_session_started` 等 | **Grafana**(系统计数) | PostHog 是用户维度切片,会少于 GrafanaPostHog 只覆盖 logged-in user |
## PostHog 接入路线图
落地分两步,**不要一次性埋全部事件**,否则 schema 漂移会很快出现。
### 阶段 1P0 — 付费漏斗 + activation
`apps/server`
```ts
// services/posthog.ts(新增)
import { PostHog } from 'posthog-node'
export function createPostHog(env: ServerEnv) {
return new PostHog(env.POSTHOG_KEY, { host: 'https://us.i.posthog.com' })
}
// 在 Stripe webhook handler 里
posthog.capture({
distinctId: stripeCustomerEmail,
event: 'payment_completed',
properties: { plan_id, amount_usd, stripe_customer_id, stripe_subscription_id }
})
```
`apps/stage-web`
```ts
import posthog from 'posthog-js'
posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
capture_pageview: false, // 手动 capture 控制语义
})
// 登录后
posthog.identify(user.id)
// 在 pricing.vue
posthog.capture('pricing_page_viewed', { plan_period, source })
```
`apps/stage-tamagotchi`Electron renderer):
```ts
// NOTICE: Electron CSP 下普通 import 会静默失效,必须用 full bundle。
// 参考:https://posthog.com/tutorials/electron-analytics
import posthog from 'posthog-js/dist/module.full.no-external.js'
posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
autocapture: false, // 桌面应用没有传统 URL 路由,手动控制
})
```
埋点事件清单(P0):
- 前端:`pricing_page_viewed``plan_selected``checkout_started``user_signed_up``first_message_sent``first_model_selected`
- 后端:`payment_completed`
PostHog UI 配两个 funnel
- **付费漏斗** (7d 窗口)`pricing_page_viewed → plan_selected → checkout_started → payment_completed`
- **激活漏斗** (14d 窗口)`user_signed_up → first_message_sent → first_model_selected → payment_completed`
### 阶段 2P1 — retention / feature adoption / churn
埋点事件清单:`character_created``voice_mode_activated``chat_session_started``model_switched``flux_image_generated``subscription_cancelled`
PostHog UI 配 cohort
- **D7 Retention by voice mode**:第一次 session 用了 `voice_mode_activated` 的用户 vs 没用的,看 D7/D30 retention 差异
- **Churn 14d**:过去 14d 没有 `chat_session_started` 的付费用户,作为召回 cohort
### Stripe → PostHog 集成路径
**两条路径都接**
| 路径 | 用途 |
|---|---|
| PostHog Stripe **source connector** | MRR / ARR / churn revenue dashboardPostHog 原生 Revenue analytics |
| **手动 capture** `payment_completed`(后端 webhook | 漏斗终点 event,跟前端 `checkout_started` 串联 |
不能只用 source connector:它是 data warehouse 层,**不生成 person event,做不了漏斗**。
## Grafana Alert SOP
Alert rules **不放在** `apps/server/otel/grafana/dashboards/build.ts` 里——Grafana Cloud 用 Unified Alertingrule 在 Grafana UI 或 alerting API 管理,跟 dashboard JSON 解耦。这一节维护我们应该配的 alert rule,新加 rule 时同步更新这里。
### P0 — page on-callPagerDuty / Slack on-call channel
| Alert | Query | Threshold | Notes |
|---|---|---|---|
| **Flux Unbilled leak** | `increase(airi_billing_flux_unbilled_total[5m])` | `> 0` for 5m | 收入直接漏;分 `reason` label 看是 `partial_debit_drained`(用户余额耗尽,预期)还是 `debit_failed`DB / 真异常)。后者更急 |
| **5xx Rate spike** | `100 * sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])) / sum(rate(http_server_request_duration_seconds_count[5m]))` | `> 5%` for 10m | 跟 panel-4 阈值对齐 |
| **Email Failure spike** | `100 * sum(rate(airi_email_failures_total[5m])) / clamp_min(sum(rate(airi_email_send_total[5m])) + sum(rate(airi_email_failures_total[5m])), 1)` | `> 5%` for 10m | Resend / DNS / 黑名单挂了会阻塞注册流程 |
### P1 — notify onlySlack ops channel,不分页)
| Alert | Query | Threshold | Notes |
|---|---|---|---|
| **WS Connections cliff** | `sum(ws_connections_active)` | drop to 0 for 5m | 全断说明部署 / LB 异常 |
| **DB Pool exhaustion** | `max by (service_instance_id) (db_client_connection_count)` | `>= DB_POOL_MAX - 1` for 5m | 哪个 instance 满了 |
| **Heap > 85%** | `100 * sum by (service_instance_id) (v8js_memory_heap_used_bytes) / sum by (service_instance_id) (v8js_memory_heap_limit_bytes)` | `> 85%` for 15m | 内存泄漏前兆 |
| **Stripe webhook fail** | `increase(stripe_events_total{event_type="payment_intent.payment_failed"}[1h])` | `> 10` per hour | 支付链路问题 |
### 配置入口
Grafana Cloud → Alerts & IRM → Alert rules → New alert rule。把上面 query 粘进 PromQL editorthreshold 按表设置,labels 加 `severity=p0|p1`notification policy 按 severity 路由到 PagerDuty 或 Slack。
每加一条 alert,**更新这张表**——alert 没在文档里登记 = 不知道为什么 page、不知道 owner、不知道历史阈值改动。
## 何时打破规则
这份文档定的是**默认值**,不是法律。下列情况可以打破:
- **系统指标也需要给 PM 看**(如 LLM provider 可用性影响产品决策)→ Grafana truth + 周期性 export 给 PostHog dashboard 展示
- **产品指标需要分钟级告警**(如付费转化突然归零)→ Grafana alert 监 Stripe webhook 计数,PostHog truth 不变
- **A/B test 影响系统指标**(如新 LLM router 影响延迟)→ feature flag 同时打到两边,Grafana panel 按 flag value 分线展示
打破规则的指标必须在 dashboard description 里说明,**不要静默打破**。
## 参考来源
业界没有权威 framework,下列来源是这份文档的依据:
- [PostHog Product Metrics Handbook](https://posthog.com/handbook/product/metrics) — PostHog 自己的内部分层
- [PostHog issue #43633](https://github.com/posthog/posthog/issues/43633) — dual-emit 问题的工程承认
- [Honeycomb Observability 2.0](https://www.honeycomb.io/blog/time-to-version-observability-signs-point-to-yes) — "消除工具边界"的少数派立场
- [Reforge: North Star Metrics](https://www.reforge.com/blog/north-star-metrics) — leading vs lagging 区分
- [DEV: Metrics for 500 Engineers with Linear + Grafana + PostHog](https://dev.to/johalputt/how-to-set-up-developer-metrics-for-500-engineers-using-linear-20-grafana-110-and-posthog-30-3l73) — 与我们结构最接近的公开案例
- [PostHog: Stripe payment platform](https://posthog.com/docs/revenue-analytics/payment-platforms/stripe) — Stripe 集成路径官方文档
- [PostHog: Electron analytics](https://posthog.com/tutorials/electron-analytics) — Electron renderer 接入要点
- [Google SRE Book: Monitoring Distributed Systems](https://sre.google/sre-book/monitoring-distributed-systems/) — Four Golden Signals
- [Stripe: Essential SaaS Metrics](https://stripe.com/resources/more/essential-saas-metrics) — 收入侧指标定义