- Updated the EngagementMetrics interface to use ObservableGauge for tracking active WebSocket connections. - Added detailed comments explaining the rationale for this change, highlighting the benefits of using a pull-based gauge over a delta-based counter. - Implemented the ObservableGauge in the createChatWsHandlers function, ensuring it accurately reflects the live count of active connections. - Removed the previous UpDownCounter logic to prevent issues with connection drift during process crashes or network interruptions.
10 KiB
Metrics Catalog
服务端当前所有 metric 的完整目录。按业务领域分组。
命名规则、
airi.*边界、attribute 选择请看observability-conventions.md。本文档只做"哪些 metric 存在、怎么查"。
名字到 Prometheus 系列的换算
OTel SDK 在导出到 Prometheus 时做两件事:
.→_:airi.billing.flux.consumed→airi_billing_flux_consumed- Counter 加
_total后缀:auth.attempts→auth_attempts_total - Histogram 拆三件套:
http.server.request.duration→http_server_request_duration_seconds_bucket(含lelabel)http_server_request_duration_seconds_counthttp_server_request_duration_seconds_sum
- UpDownCounter 不加
_total:ws.connections.active→ws_connections_active - 带单位的 instrument 在 SDK 导出时把单位插进名字:
airi.stripe.revenue(unitminor_unit)→airi_stripe_revenue_minor_unit_total
查询面板若拼名字时不确定后缀,先用
{__name__=~"airi_billing_flux.*"}之类正则探一下。
HTTP(来自 instrumentation-http)
| 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 otelMiddleware |
http.request.method、http.route |
STABLE-only:instrumentation.mjs:25 把
OTEL_SEMCONV_STABILITY_OPT_IN=http提前注入。OLD 系列(http.server.durationin ms)不再发射。详见observability-conventions.md的 SemconvStability 章节。
Auth & Users
全部由 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 |
UpDownCounter | session create / delete | — |
Engagement
| Metric | 类型 | 落点 | Labels |
|---|---|---|---|
chat.messages |
Counter | services/chats.ts pushMessages |
— |
character.created |
Counter | 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 | — |
ws.messages.sent |
Counter | 同上 | — |
ws.messages.received |
Counter | services/chats.ts | — |
Revenue & Billing
Stripe lifecycle
| 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 比例。
Flux ledger
| 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/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) |
flux.insufficient_balance |
Counter | services/billing/billing-service.ts debitFlux |
— |
airi.billing.tts.chars |
Counter | 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 量"。
GenAI
| 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) |
Email(Resend)
来源 services/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) |
Rate limiting
来源 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。
Node.js Runtime
来自 @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.utilizationGauge / ratiov8js.gc.durationHistogram / s
已落地的 dashboard 行映射
airi-server-overview-cloud.json,从上到下:
| 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 |
验证 metric 是否已注册
src/scripts/otel-smoke.mjs 跑一遍:
pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel-smoke.mjs
会打印 SDK 启动时立即 export 的所有 instrument 名字。Counter 通过 .add(0) priming(libs/otel.ts primeCounter)后会出现在这里 —— Histogram 不会,要等真实 .record() 才出现。
加新 metric 时的 checklist
- 决定命名空间:能映射到 OTel semconv 就用标准名,否则放
airi.*(不要造新顶级前缀) - 在 utils/observability.ts 加常量
- 在 libs/otel.ts 的对应 metric group 接口(
HttpMetrics/AuthMetrics/...)加字段,并在initOtel里meter.create*创建 - 如果是 Counter,在
primeCounter调用列表里加一行 —— 否则低流量时 panel 看起来"没数据" - 在 callsite 通过 DI 拿到 metrics 对象后调
.add()/.record() - 跑
pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel-smoke.mjs确认注册 - 更新本文档对应章节