feat(server/otel): restructure observability metrics and add active sessions gauge

- Moved RateLimitMetrics import path to a more centralized location.
- Introduced a new file for active sessions gauge to track user sessions in the database.
- Updated index.ts to include new metrics and ensure proper initialization of observability metrics.
- Modified various routes and services to utilize the new observability structure.
- Added smoke tests for HTTP and WebSocket metrics to ensure proper metric registration and functionality.
- Enhanced error handling for metrics reading failures to improve observability.
This commit is contained in:
RainbowBird
2026-05-12 23:10:13 +08:00
parent 84bff1f757
commit 272cdae03b
27 changed files with 785 additions and 239 deletions
@@ -76,21 +76,13 @@ Redis 相关优先复用 instrumentation 自动产生的标准属性,不要重
## Metric Name 策略
当前 `apps/server` 仍保留以下 metric name
`apps/server` 的 LLM gateway metric 现在全部用标准 `gen_ai.client.*` semconv 名 + AIRI `airi.billing.*` 计费名。旧的 `llm.request.*` / `llm.tokens.*` / `flux.consumed` 字面名都已经迁移完,请**不要在新代码或 reviewer 建议里复活**它们 —— 代码里 const 命名(如 `METRIC_FLUX_CONSUMED`)保留是历史 identifier,对应的字面值已经是 `airi.billing.flux.consumed`,以字面值为准。
- `llm.request.duration`
- `llm.request.count`
- `llm.tokens.prompt`
- `llm.tokens.completion`
- `flux.consumed`
新增或重命名 metric 时遵守:
这是有意为之,不是遗漏
原因:
- metric name 改动比 attribute 改动更容易破坏现有 Prometheus 查询、Grafana 面板和告警。
- 目前更高价值的是先统一 metric attributes,使查询维度稳定。
- 如需迁移 metric name,应该走兼容迁移方案,而不是在普通功能改动里直接重命名。
- metric name 改动比 attribute 改动更容易破坏现有 Prometheus 查询、Grafana 面板和告警。**先确认 dashboard / alerts 是否在跑这条 series**,再决定是否重命名
- 重命名一定要走兼容迁移:先双发新旧两条 series,留出窗口给消费方切换,再删旧的;不要在普通功能改动里直接重命名。
- 完整 metric 清单(含 Prometheus 系列名)维护在 [`observability-metrics.md`](./observability-metrics.md)。新增任何 metric 都要同步更新那份文档。
## Grafana / Prometheus 查询策略
@@ -162,13 +154,13 @@ span name 目前允许保留业务可读格式,例如:
- [packages/server-shared/src/observability.ts](/packages/server-shared/src/observability.ts)
- [apps/server/src/routes/v1completions.ts](/apps/server/src/routes/v1completions.ts)
- [apps/server/src/libs/otel.ts](/apps/server/src/libs/otel.ts)
- [apps/server/src/otel/index.ts](/apps/server/src/otel/index.ts)
- [services/telegram-bot/src/llm/actions.ts](/services/telegram-bot/src/llm/actions.ts)
- [services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts](/services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts)
## SemconvStability 迁移说明
`@opentelemetry/instrumentation-http` 0.215+ 默认 OLD semconv`http.server.duration` in ms),不是 STABLE 名。AIRI 在 [apps/server/instrumentation.mjs](/apps/server/instrumentation.mjs) 顶部强制 `OTEL_SEMCONV_STABILITY_OPT_IN=http`(仅 STABLE)。
`@opentelemetry/instrumentation-http` 0.215+ 默认 OLD semconv`http.server.duration` in ms),不是 STABLE 名。AIRI 在 [apps/server/instrumentation.ts](/apps/server/instrumentation.ts) 顶部强制 `OTEL_SEMCONV_STABILITY_OPT_IN=http`(仅 STABLE)。
| Semconv 模式 | 发哪些 series | 我们用 |
|---|---|---|
@@ -185,20 +177,80 @@ span name 目前允许保留业务可读格式,例如:
**何时切回 `dup`**:将来如果有别的 service 主动 scrape 本 server 的 OLD-name 系列,临时切几周完成迁移即可。
## Multi-Replica 注意事项
服务跑在 Railway 上有 ≥2 个副本(见 [workers-and-runtime.md](./workers-and-runtime.md)),所有 metric 设计必须显式考虑跨副本聚合。
### `service.instance.id` 必须设
[apps/server/instrumentation.ts](/apps/server/instrumentation.ts) 在 resource 上注入 `service.instance.id`,按优先级取 `RAILWAY_REPLICA_ID``SERVER_INSTANCE_ID``randomUUID()`(带 warn 日志,提示 ops 系列会随重启 churn)。`HOSTNAME` 曾经在 fallback 链里但 Railway 没文档化它是否 per-replica 唯一,所以踢出去了;需要跨重启稳定时显式设 `SERVER_INSTANCE_ID`
**没设的后果**:两个副本的所有 metric series label tuple 完全一致(`service_name` + `deployment_environment` 一样),Prometheus 收到时按规则丢一条 / collapse 系列,结果一个副本完全消失。
加新 metric 时不用做任何事——只要从 `meter` 创建出来,instance id 自动随 resource 一起带上。
### 按 instrument 类型的副本安全表
| 类型 | 副本安全? | 聚合方式 | 备注 |
|---|---|---|---|
| `Counter` | ✅ | `sum(rate(x[5m]))` | 每副本本地累加,`rate()` 自动处理重启 |
| `Histogram` | ✅ | `histogram_quantile(0.95, sum by (le, ...) (rate(x_bucket[5m])))` | 每副本本地 bucket`sum by (le)` 合并 |
| `ObservableGauge`**per-replica 状态**,如 `ws.connections.active` | ✅ | `sum(x)` | callback 读本地 registry,所有副本求和 = 集群总量 |
| `ObservableGauge`**cluster-wide 状态**,如 `user.active_sessions` | ⚠️ | `max(x)``avg(x)` | 所有副本读同一份外部状态(DB),sum 会乘以副本数 |
| `UpDownCounter` | ⚠️ | 看场景 | 必须保证 `+1``-1` 在**同一副本**触发;否则单副本永久 +N 另一副本永久 -N |
### `UpDownCounter` 红线
只在以下情况用:
- `+1` 和对应的 `-1` 都在**同一请求生命周期**或**同一进程的局部状态机**里发生(典型:`http.server.active_requests` —— 请求开始 +1,结束 -1,必在同一副本)
- 不依赖任何外部 TTL / GC / 异步过期
如果存在「TTL 自然过期」「跨实例资源转移」「依赖 webhook 异步触发 -1」之类的情况,**不要用 UpDownCounter**。改用:
- `ObservableGauge` 从权威存储(DB / Redis)按 callback 读真实值,dashboard 用 `max()` / `avg()` 聚合
- 或者只保留对应的 `Counter`"created" + "deleted"),让 dashboard 自己算差值
历史教训:`user.active_sessions` 最早是 UpDownCounter,登录 +1 / 登出 -1。但 Better Auth 的 session TTL 过期不会调 delete hookcounter 单实例就漂;多副本登录在 A、登出在 B 直接撕裂。改成 `ObservableGauge` 后由 [apps/server/src/app.ts](/apps/server/src/app.ts) 的 `registerActiveSessionsGauge` 通过 `SELECT COUNT(*) FROM session WHERE expires_at > NOW()` 在 scrape 时按需查 DB,带 10s 内存缓存避免 hammer。
### Dashboard 查询模板
加新 panel 时按这个清单核对:
| 数据语义 | PromQL 模板 |
|---|---|
| 业务事件速率(Counter | `sum(rate(x_total{...}[$__rate_interval]))` |
| 按 label 切分速率 | `sum by (<label>) (rate(x_total{...}[$__rate_interval]))` |
| 时延分位(Histogram | `histogram_quantile(0.95, sum by (le, <label>) (rate(x_bucket{...}[$__rate_interval])))` |
| 集群总量(per-replica gauge | `sum(x{...})` |
| 集群唯一值(cluster-wide gauge | `max(x{...})``avg(x{...})` |
| 按副本拆分调试 | `<agg> by (service_instance_id) (x{...})` |
| 错误率 | `100 * sum(rate(x_total{...,status_code=~"5.."}[5m])) / clamp_min(sum(rate(x_total{...}[5m])), 1)` |
红线:**任何 cumulative counter 都不能直接 `sum()` 不 wrap rate/increase**。Counter 在副本重启时归零,没有 rate() 包裹 Prometheus 会跳变;用 `increase($__range)` 看「时间窗口内总量」,用 `rate([interval])` 看「当前速率」。
### 「按副本拆分」何时加
默认 panel 都聚合到集群层面。但以下场景应该加 `by (service_instance_id)` 拆分图:
- 进程级资源(heap、event loop、DB pool)——一个副本泄漏 / pin CPU 别的副本掩盖不掉
- WS 连接 ——可以看出来是不是单个副本不均衡
- 自定义的 ObservableGauge 排查
Dashboard 当前 Infrastructure 行已经是 by instance 的(Heap、Event Loop、DB Pool)。
## Counter priming 注意事项
OTel SDK 的 Counter / UpDownCounter 在第一次 `.add()` 之前**完全不出现在 Prometheus 抓取里**。Histogram 同理(要等第一次 `.record()`)。
后果:低流量 metric 在 dashboard 上看起来像「埋点丢了」,告警里 `absent()` 也无法工作。
[apps/server/src/libs/otel.ts](/apps/server/src/libs/otel.ts) 的 `primeCounter` 在 SDK 启动后给每个 Counter 调一次 `.add(0)`,把 series 注册出来;`0` 不影响 rate / sum 计算。
[apps/server/src/otel/index.ts](/apps/server/src/otel/index.ts) 的 `primeCounter` 在 SDK 启动后给每个 Counter 调一次 `.add(0)`,把 series 注册出来;`0` 不影响 rate / sum 计算。
加新 Counter 时**记得加进 prime 列表**,否则未触发的指标在 Grafana 里就是空的。
验证脚本:[apps/server/src/scripts/otel-smoke.mjs](/apps/server/src/scripts/otel-smoke.mjs)
验证脚本:[apps/server/src/scripts/otel/smoke.ts](/apps/server/src/scripts/otel/smoke.ts)
```sh
pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel-smoke.mjs
pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel/smoke.ts
```
打印 SDK 启动后立即可见的所有 instrument 名字。
@@ -14,7 +14,7 @@ OTel SDK 在导出到 Prometheus 时做两件事:
- `http_server_request_duration_seconds_bucket`(含 `le` label
- `http_server_request_duration_seconds_count`
- `http_server_request_duration_seconds_sum`
4. UpDownCounter 不加 `_total``ws.connections.active``ws_connections_active`
4. UpDownCounter / ObservableGauge 不加 `_total``ws.connections.active``ws_connections_active``user.active_sessions``user_active_sessions`
5. 带单位的 instrument 在 SDK 导出时把单位插进名字:`airi.stripe.revenue`unit `minor_unit`)→ `airi_stripe_revenue_minor_unit_total`
> 查询面板若拼名字时不确定后缀,先用 `{__name__=~"airi_billing_flux.*"}` 之类正则探一下。
@@ -23,10 +23,14 @@ OTel SDK 在导出到 Prometheus 时做两件事:
| Metric | 类型 | Unit | 来源 | 关键 attributes |
|---|---|---|---|---|
| `http.server.request.duration` | Histogram | s | `instrumentation-http`STABLE semconv | `http.request.method``http.route``http.response.status_code` |
| `http.server.active_requests` | UpDownCounter | — | [middlewares/otel.ts](../../src/middlewares/otel.ts) `otelMiddleware` | `http.request.method``http.route` |
| `http.server.request.duration` | Histogram | s | [`@hono/otel`](https://www.npmjs.com/package/@hono/otel) `httpInstrumentationMiddleware` in [app.ts](../../src/app.ts) | `http.request.method``http.route``http.response.status_code` |
| `http.server.active_requests` | UpDownCounter | — | 同上 | `http.request.method` |
> **STABLE-only**[instrumentation.mjs:25](../../instrumentation.mjs) 把 `OTEL_SEMCONV_STABILITY_OPT_IN=http` 提前注入。OLD 系列(`http.server.duration` in ms)不再发射。详见 [`observability-conventions.md` 的 SemconvStability 章节](./observability-conventions.md#semconvstability-迁移说明)
> **入站走 @hono/otel,出站走 auto HttpInstrumentation**auto instrumentation 在 Node http 层抓数据时 Hono 还没匹配路由,`http.route` label 永远为空。`@hono/otel` 在 Hono middleware 链里跑,能拿到匹配后的路由 pattern`/api/v1/users/:id` 而非具体 URL),所以入站 metric 由它产生。auto HttpInstrumentation 在 [instrumentation.ts](../../instrumentation.ts) 里通过 `ignoreIncomingRequestHook: () => true` 仅保留**出站**LLM gateway、Stripe、Resend),那部分还是要它来跟踪
>
> **STABLE-only**[instrumentation.ts](../../instrumentation.ts) 把 `OTEL_SEMCONV_STABILITY_OPT_IN=http` 提前注入。OLD 系列(`http.server.duration` in ms)不再发射。详见 [`observability-conventions.md` 的 SemconvStability 章节](./observability-conventions.md#semconvstability-迁移说明)。
>
> `/health` 路径在 [app.ts](../../src/app.ts) 的 @hono/otel 包装层被显式 skipRailway 健康检查不进 metric。
## Auth & Users
@@ -38,7 +42,11 @@ OTel SDK 在导出到 Prometheus 时做两件事:
| `auth.failures` | Counter | `after` hook`ctx.context.returned``error` | `auth.method` |
| `user.registered` | Counter | `databaseHooks.user.create.after` | — |
| `user.login` | Counter | `databaseHooks.session.create.after` | — |
| `user.active_sessions` | UpDownCounter | session create / delete | — |
| `user.active_sessions` | ObservableGauge | [app.ts](../../src/app.ts) `registerActiveSessionsGauge`scrape 时查 `SELECT COUNT(*) FROM session WHERE expires_at > NOW()`10s 内存缓存) | — |
> **`user.active_sessions` 是 cluster-wide gaugedashboard 必须用 `max()` / `avg()`,不能用 `sum()`**。所有副本读同一份 DB 报同一个值,sum 会乘以副本数。详见 [observability-conventions.md 的 Multi-Replica 章节](./observability-conventions.md#multi-replica-注意事项)。
>
> 历史:之前是 UpDownCounter+1 on login, -1 on logout),但 Better Auth session TTL 过期不会调 delete hookcounter 单实例就漂;多副本下登录在 A、登出在 B 会直接撕裂正负数。所以改成 DB-backed gauge。
## Engagement
@@ -48,7 +56,7 @@ OTel SDK 在导出到 Prometheus 时做两件事:
| `character.created` | Counter | [services/characters.ts](../../src/services/characters.ts) | — |
| `character.deleted` | Counter | 同上 | — |
| `character.engagement` | Counter | 同上(like/bookmark | `action``like` / `unlike` / `bookmark` / `unbookmark` |
| `ws.connections.active` | UpDownCounter | [routes/chat-ws/index.ts](../../src/routes/chat-ws/index.ts) | — |
| `ws.connections.active` | ObservableGauge | [routes/chat-ws/index.ts](../../src/routes/chat-ws/index.ts) `addCallback` walks `userConnections` Map | — |
| `ws.messages.sent` | Counter | 同上 | — |
| `ws.messages.received` | Counter | [services/chats.ts](../../src/services/chats.ts) | — |
@@ -73,12 +81,12 @@ OTel SDK 在导出到 Prometheus 时做两件事:
|---|---|---|---|
| `airi.billing.flux.consumed` | Counter | [routes/openai/v1/index.ts](../../src/routes/openai/v1/index.ts) `recordMetrics`chat / tts | `gen_ai.request.model``gen_ai.operation.name`/`airi.gen_ai.operation.kind``http.response.status_code` |
| `airi.billing.flux.credited` | Counter | [services/billing/billing-service.ts](../../src/services/billing/billing-service.ts) 三条入账路径 | `source``stripe.checkout`/`stripe.invoice`/`promo`/`admin_grant`/...)、`type``credit`/`promo` |
| `airi.billing.flux.unbilled` | Counter | `routes/openai/v1/index.ts` 流式 debit 失败 catch | `gen_ai.request.model``reason``debit_failed`)、`stage``streaming` |
| `airi.billing.flux.unbilled` | Counter | [routes/openai/v1/index.ts](../../src/routes/openai/v1/index.ts) streaming 路径里 `consumeFluxForLLM` 失败 catch | `gen_ai.request.model``reason``debit_failed`)、`stage``streaming` |
| `flux.insufficient_balance` | Counter | [services/billing/billing-service.ts](../../src/services/billing/billing-service.ts) `debitFlux` | — |
| `airi.billing.tts.chars` | Counter | [services/billing/flux-meter.ts](../../src/services/billing/flux-meter.ts) `accumulate` | `meter``tts`)、`model` |
| `airi.billing.tts.preflight_rejections` | Counter | `flux-meter.ts` `assertCanAfford` | `meter``reason``insufficient_balance` |
> **`airi.billing.flux.unbilled` 是 P0 告警金线**任何持续 > 0 都意味着真实收入泄漏,应当 page。语义上等于"流式响应已经发给用户但 DB debit 失败的 Flux 量"
> **`airi.billing.flux.unbilled` 是 P0 告警金线**流式响应已经发给用户(HTTP 200,token 已经流出),但 post-stream debit 抛错——response 路径不会因此 5xxDB latency 也只在 catch 那一瞬间显著。HTTP / DB 告警**覆盖不到**这条静默 revenue leak。推荐 alert`increase(airi_billing_flux_unbilled_total[5m]) > 0` 持续 > 0 立刻 page
## GenAI
@@ -122,36 +130,37 @@ OTel SDK 在导出到 Prometheus 时做两件事:
## 已落地的 dashboard 行映射
[airi-server-overview-cloud.json](../../otel/grafana/dashboards/airi-server-overview-cloud.json)从上到下:
[airi-server-overview-cloud.json](../../otel/grafana/dashboards/airi-server-overview-cloud.json) 由 [`build.ts`](../../otel/grafana/dashboards/build.ts) 生成(**直接改 JSON 会在下次 regenerate 时被覆盖;改 build.ts**),跑 `pnpm -F @proj-airi/server otel:dashboards` 重新生成。从上到下:
| Row | 关键 metric |
|---|---|
| HTTP Overview | `http.server.request.duration`rate / P95 / by route / 5xx 率) |
| Auth & Users | `auth.attempts` / `auth.failures` / `user.{login,registered,active_sessions}` + 失败率 |
| Engagement | `ws.connections.active` / `ws.messages.{sent,received}` / `chat.messages` / `character.{created,deleted,engagement}` |
| Business Metrics | `airi.billing.flux.consumed` / `flux.insufficient_balance` / `gen_ai.client.token.usage.*` / `stripe.checkout.completed` / `airi.billing.flux.credited` |
| Stripe Detail | `stripe.{events,subscription.event,payment.failed}` / checkout funnel / `airi.stripe.revenue` |
| Node.js Runtime | runtime instrumentation 那一批 |
| LLM Gateway | `gen_ai.client.{operation.count,operation.duration,token.usage.*,first_token.duration}` / `airi.billing.flux.consumed` / `airi.billing.flux.unbilled` / `airi.gen_ai.stream.interrupted` / `airi.billing.tts.chars` |
| Reliability | `airi.email.{send,failures}` 失败率 / `airi.rate_limit.blocked` / `airi.billing.tts.preflight_rejections` |
| Application Logs | Loki,不是 Prometheus |
| Row | viz | 关键 metric |
|---|---|---|
| Service Health | stat / gauge | `user.active_sessions``max()`)、`ws.connections.active``sum()`)、`http.server.request.duration_count`req/s + 5xx%)、`gen_ai.client.operation.count``airi.email.{send,failures}` 失败率 |
| Distribution (now) | donut | HTTP methods / LLM models / HTTP status codes — `increase([5m])` |
| Traffic Trends | timeseries | 同 distribution 的数据 over time |
| Latency | timeseries | `http.server.request.duration_bucket`P95 by route)、`gen_ai.client.first_token.duration_bucket`P95 by model |
| Errors / Quality | mix | 4xx/5xx stacked area、`airi.gen_ai.stream.interrupted``airi.rate_limit.blocked` |
| Business | stat / gauge / donut | `airi.stripe.revenue`by currency)、checkout conversion %、`stripe.events` 分布 |
| Infrastructure (collapsed, **by `service_instance_id`**) | timeseries | `db_client_operation_duration` P95cluster)、`db_client_connection_count``v8js_memory_heap_used_bytes` %、`nodejs_eventloop_delay_p99_seconds` |
| Logs | logs | Loki,不是 Prometheus |
> **Multi-replica 聚合方式**:所有 panel 在 `build.ts` 里都已经按 `observability-conventions.md` 的副本安全表选择了正确的 aggregatorCounter 用 `sum(rate)`、cluster-wide gauge 用 `max()`、per-process gauge 用 `sum()`、infra 排查面板用 `by (service_instance_id)`)。加新 panel 时按那张表对照一遍。
## 验证 metric 是否已注册
[`src/scripts/otel-smoke.mjs`](../../src/scripts/otel-smoke.mjs) 跑一遍:
[`src/scripts/otel/smoke.ts`](../../src/scripts/otel/smoke.ts) 跑一遍:
```sh
pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel-smoke.mjs
pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel/smoke.ts
```
会打印 SDK 启动时立即 export 的所有 instrument 名字。**Counter 通过 `.add(0)` priming**[libs/otel.ts](../../src/libs/otel.ts) `primeCounter`)后会出现在这里 —— Histogram 不会,要等真实 `.record()` 才出现。
会打印 SDK 启动时立即 export 的所有 instrument 名字。**Counter 通过 `.add(0)` priming**[otel/index.ts](../../src/otel/index.ts) `primeCounter`)后会出现在这里 —— Histogram 不会,要等真实 `.record()` 才出现。
## 加新 metric 时的 checklist
1. 决定命名空间:能映射到 OTel semconv 就用标准名,否则放 `airi.*`(不要造新顶级前缀)
2. 在 [utils/observability.ts](../../src/utils/observability.ts) 加常量
3. 在 [libs/otel.ts](../../src/libs/otel.ts) 的对应 metric group 接口(`HttpMetrics`/`AuthMetrics`/...)加字段,并在 `initOtel``meter.create*` 创建
3. 在 [otel/index.ts](../../src/otel/index.ts) 的对应 metric group 接口(`HttpMetrics`/`AuthMetrics`/...)加字段,并在 `initOtel``meter.create*` 创建
4. **如果是 Counter,在 `primeCounter` 调用列表里加一行** —— 否则低流量时 panel 看起来"没数据"
5. 在 callsite 通过 DI 拿到 metrics 对象后调 `.add()` / `.record()`
6.`pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel-smoke.mjs` 确认注册
6.`pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel/smoke.ts` 确认注册
7. 更新本文档对应章节
@@ -87,7 +87,7 @@
## OpenTelemetry
初始化在 `src/libs/otel.ts`
初始化在 `instrumentation.ts`NodeSDK lifecycle+ `src/otel/index.ts`metric handles+ `src/otel/gauges/*.ts`DB-backed ObservableGauge callbacks,例如 `gauges/active-sessions.ts`
启用条件: