apps/server/docs/ai-context/observability-metrics.md
服务端当前所有 metric 的完整目录。按业务领域分组。
命名规则、
airi.*边界、attribute 选择请看observability-conventions.md。本文档只做"哪些 metric 存在、怎么查"。
OTel SDK 在导出到 Prometheus 时做两件事:
. → _:airi.billing.flux.consumed → airi_billing_flux_consumed_total 后缀:auth.attempts → auth_attempts_totalhttp.server.request.duration →
http_server_request_duration_seconds_bucket(含 le label)http_server_request_duration_seconds_counthttp_server_request_duration_seconds_sum_total:ws.connections.active → ws_connections_active、user.active_sessions → user_active_sessionsairi.stripe.revenue(unit minor_unit)→ airi_stripe_revenue_minor_unit_total查询面板若拼名字时不确定后缀,先用
{__name__=~"airi_billing_flux.*"}之类正则探一下。
| Metric | 类型 | Unit | 来源 | 关键 attributes |
|---|---|---|---|---|
http.server.request.duration | Histogram | s | @hono/otel httpInstrumentationMiddleware in app.ts | http.request.method、http.route、http.response.status_code |
http.server.active_requests | UpDownCounter | — | 同上 | http.request.method |
入站走 @hono/otel,出站走 auto HttpInstrumentation:auto instrumentation 在 Node http 层抓数据时 Hono 还没匹配路由,
http.routelabel 永远为空。@hono/otel在 Hono middleware 链里跑,能拿到匹配后的路由 pattern(/api/v1/users/:id而非具体 URL),所以入站 metric 由它产生。auto HttpInstrumentation 在 instrumentation.ts 里通过ignoreIncomingRequestHook: () => true仅保留出站(LLM gateway、Stripe、Resend),那部分还是要它来跟踪。STABLE-only:instrumentation.ts 把
OTEL_SEMCONV_STABILITY_OPT_IN=http提前注入。OLD 系列(http.server.durationin ms)不再发射。详见observability-conventions.md的 SemconvStability 章节。
/livez和/readyz在 app.ts 的 @hono/otel 包装层被显式 skip,K8s 风格探针不进 metric。
全部由 libs/auth.ts Better Auth hooks 触发。
| Metric | 类型 | 落点(hook) | Labels |
|---|---|---|---|
auth.attempts | Counter | before hook,path 含 /sign-in 或 /sign-up | auth.method(path 末段) |
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 | ObservableGauge | app.ts registerActiveSessionsGauge,scrape 时查 SELECT COUNT(*) FROM session WHERE expires_at > NOW()(10s 内存缓存) | — |
user.active_sessions是 cluster-wide gauge,dashboard 必须用max()/avg(),不能用sum()。所有副本读同一份 DB 报同一个值,sum 会乘以副本数。详见 observability-conventions.md 的 Multi-Replica 章节。历史:之前是 UpDownCounter(+1 on login, -1 on logout),但 Better Auth session TTL 过期不会调 delete hook,counter 单实例就漂;多副本下登录在 A、登出在 B 会直接撕裂正负数。所以改成 DB-backed gauge。
| Metric | 类型 | 落点 | Labels |
|---|---|---|---|
chat.messages | Counter | services/domain/chats.ts pushMessages | — |
character.created | Counter | services/domain/characters.ts | — |
character.deleted | Counter | 同上 | — |
character.engagement | Counter | 同上(like/bookmark) | action(like / unlike / bookmark / unbookmark) |
ws.connections.active | ObservableGauge | routes/chat-ws/index.ts addCallback walks userConnections Map | — |
ws.messages.sent | Counter | 同上 | — |
ws.messages.received | Counter | services/domain/chats.ts | — |
| Metric | 类型 | 落点 | Labels |
|---|---|---|---|
airi.product.events | Counter | services/domain/product-events.ts track | feature、action、status、source(可选) |
Prometheus 只看事件量,不看独立用户:
airi.product.events的 labels 必须保持低基数,不能加user_id、session_id、request id、raw error message 或 prompt。要回答"每个功能有多少独立用户",查 Postgresproduct_events:count(distinct user_id)。
| Metric | 类型 | 落点 | Labels |
|---|---|---|---|
stripe.checkout.created | Counter | routes/stripe/index.ts /checkout POST | — |
stripe.checkout.completed | Counter | webhook checkout.session.completed | — |
stripe.payment.failed | Counter | webhook invoice.payment_failed | — |
stripe.subscription.event | Counter | webhook customer.subscription.* | event_type(created/updated/deleted) |
stripe.events | Counter | 任何 webhook | event_type(完整 event.type,e.g. invoice.paid) |
airi.stripe.revenue | Counter(minor_unit) | webhook checkout.session.completed + invoice.paid | currency、source(checkout/invoice) |
金额单位:
airi.stripe.revenue用最小币种单位(cents 等),跨币种 sum 没有意义,永远sum by (currency)。要换主单位(dollars 等)做/ 100即可,前提是该币种没有不同 minor unit 比例。
| Metric | 类型 | 落点 | Labels |
|---|---|---|---|
airi.billing.flux.consumed | Counter | 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/domain/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 streaming 路径里 consumeFluxForLLM 失败的 catch | gen_ai.request.model、reason(debit_failed)、stage(streaming) |
flux.insufficient_balance | Counter | services/domain/billing/billing-service.ts debitFlux | — |
airi.billing.tts.chars | Counter | services/domain/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 告警金线:流式响应已经发给用户(HTTP 200,token 已经流出),但 post-stream debit 抛错——response 路径不会因此 5xx,DB latency 也只在 catch 那一瞬间显著。HTTP / DB 告警覆盖不到这条静默 revenue leak。推荐 alert:increase(airi_billing_flux_unbilled_total[5m]) > 0持续 > 0 立刻 page。
| Metric | 类型 | Unit | 落点 | Labels |
|---|---|---|---|---|
gen_ai.client.operation.duration | Histogram | s | routes/openai/v1/index.ts recordMetrics | gen_ai.request.model、gen_ai.operation.name/airi.gen_ai.operation.kind、http.response.status_code |
gen_ai.client.operation.count | Counter | — | 同上 | 同上 |
gen_ai.client.token.usage.input | Counter | — | 同上 | 同上 |
gen_ai.client.token.usage.output | Counter | — | 同上 | 同上 |
gen_ai.client.first_token.duration | Histogram | s | 流式 reader 第一个非空 chunk 抵达时 | gen_ai.request.model、gen_ai.operation.name |
airi.gen_ai.stream.interrupted | Counter | — | 流式 reader catch | gen_ai.request.model、stage(before_first_chunk/mid_stream) |
来源 services/adapters/email.ts 的 send() 内部 try/catch。
| Metric | 类型 | Labels |
|---|---|---|
airi.email.send | Counter | template(verification/password_reset/magic_link/change_email/delete_account/unknown) |
airi.email.failures | Counter | template、error_name(Resend error.name 或 unhandled) |
airi.email.duration | Histogram(s) | template、outcome(ok/error) |
来源 middlewares/rate-limit.ts 的 handler。
| Metric | 类型 | Labels |
|---|---|---|
airi.rate_limit.blocked | Counter | route(callsite 提供,e.g. auth.api / openai.completions / stripe.checkout)、key_type(user/ip)、limit(窗口内最大次数) |
注意:
route是 callsite 显式提供的稳定 label,不是 raw URL path —— URL path 是高 cardinality,会爆炸。新加 rate limiter 时记得传routeLabel。
来自 @opentelemetry/instrumentation-runtime-node,下面这些是 dashboard 上用到的子集(不全列):
v8js.memory.heap.{used,limit,space.physical_size,space.available_size} Gauge / bytesnodejs.eventloop.delay.{p50,p99,mean,...} Gauge / snodejs.eventloop.utilization Gauge / ratiov8js.gc.duration Histogram / sairi-server-overview-cloud.json 由 build.ts 生成(直接改 JSON 会在下次 regenerate 时被覆盖;改 build.ts),跑 pnpm -F @proj-airi/server otel:dashboards 重新生成。从上到下:
| Row | viz | 关键 metric |
|---|---|---|
| Service Health | stat / gauge / heatmap | user.total(max())、user.active_sessions(avg())、ws.connections.active(sum())、http.server.request.duration_count(req/s + 5xx%)、gen_ai.client.operation.count |
| User Engagement | stat | user.active_rolling(DAU / WAU / MAU,max()) |
| Product Analytics | stat / gauge / bargauge / timeseries | airi.product.events(Prom-safe event volume by feature / action / status;distinct users 仍查 Postgres product_events) |
| HTTP | heatmap / bargauge / timeseries | http.server.request.duration_count(status mix、top routes、route errors)、http.server.request.duration_bucket(P95 by route) |
| LLM Gateway | timeseries | gen_ai.client.operation.count(by model)、gen_ai.client.first_token.duration_bucket / gen_ai.client.operation.duration_bucket(TTFB + end-to-end P95) |
| Provider Upstreams | timeseries | gen_ai.client.operation.count / gen_ai.client.operation.duration_bucket by provider、airi.billing.tts.chars |
| LLM Tokens & Quality | stat / timeseries | gen_ai.client.token.usage.{input,output}、airi.billing.flux.unbilled、airi.gen_ai.stream.interrupted |
| LLM Router Health | stat / gauge / timeseries | airi.gen_ai.gateway.{key.exhausted,decrypt.failures,fallback.count,upstream.errors} |
| Business | stat / gauge | airi.stripe.revenue(by currency)、checkout conversion %、stripe.events 分布 |
Infrastructure (collapsed, by service_instance_id) | timeseries | db_client_operation_duration P95(cluster)、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的副本安全表选择了正确的 aggregator(Counter 用sum(rate)、cluster-wide gauge 用max()、per-process gauge 用sum()、infra 排查面板用by (service_instance_id))。加新 panel 时按那张表对照一遍。
src/scripts/otel/smoke.ts 跑一遍:
pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel/smoke.ts
会打印 SDK 启动时立即 export 的所有 instrument 名字。Counter 通过 .add(0) priming(otel/index.ts primeCounter)后会出现在这里 —— Histogram 不会,要等真实 .record() 才出现。
airi.*(不要造新顶级前缀)HttpMetrics/AuthMetrics/...)加字段,并在 initOtel 里 meter.create* 创建primeCounter 调用列表里加一行 —— 否则低流量时 panel 看起来"没数据".add() / .record()pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel/smoke.ts 确认注册