Back to Airi

Metrics Catalog

apps/server/docs/ai-context/observability-metrics.md

0.11.013.6 KB
Original Source

Metrics Catalog

服务端当前所有 metric 的完整目录。按业务领域分组。

命名规则、airi.* 边界、attribute 选择请看 observability-conventions.md。本文档只做"哪些 metric 存在、怎么查"。

名字到 Prometheus 系列的换算

OTel SDK 在导出到 Prometheus 时做两件事:

  1. ._airi.billing.flux.consumedairi_billing_flux_consumed
  2. Counter 加 _total 后缀:auth.attemptsauth_attempts_total
  3. Histogram 拆三件套:http.server.request.duration
    • http_server_request_duration_seconds_bucket(含 le label)
    • http_server_request_duration_seconds_count
    • http_server_request_duration_seconds_sum
  4. UpDownCounter / ObservableGauge 不加 _totalws.connections.activews_connections_activeuser.active_sessionsuser_active_sessions
  5. 带单位的 instrument 在 SDK 导出时把单位插进名字:airi.stripe.revenue(unit minor_unit)→ airi_stripe_revenue_minor_unit_total

查询面板若拼名字时不确定后缀,先用 {__name__=~"airi_billing_flux.*"} 之类正则探一下。

HTTP(来自 instrumentation-http)

Metric类型Unit来源关键 attributes
http.server.request.durationHistograms@hono/otel httpInstrumentationMiddleware in app.tshttp.request.methodhttp.routehttp.response.status_code
http.server.active_requestsUpDownCounter同上http.request.method

入站走 @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 里通过 ignoreIncomingRequestHook: () => true 仅保留出站(LLM gateway、Stripe、Resend),那部分还是要它来跟踪。

STABLE-onlyinstrumentation.tsOTEL_SEMCONV_STABILITY_OPT_IN=http 提前注入。OLD 系列(http.server.duration in ms)不再发射。详见 observability-conventions.md 的 SemconvStability 章节

/livez/readyzapp.ts 的 @hono/otel 包装层被显式 skip,K8s 风格探针不进 metric。

Auth & Users

全部由 libs/auth.ts Better Auth hooks 触发。

Metric类型落点(hook)Labels
auth.attemptsCounterbefore hook,path 含 /sign-in/sign-upauth.method(path 末段)
auth.failuresCounterafter hook,ctx.context.returnederrorauth.method
user.registeredCounterdatabaseHooks.user.create.after
user.loginCounterdatabaseHooks.session.create.after
user.active_sessionsObservableGaugeapp.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。

Engagement

Metric类型落点Labels
chat.messagesCounterservices/domain/chats.ts pushMessages
character.createdCounterservices/domain/characters.ts
character.deletedCounter同上
character.engagementCounter同上(like/bookmark)actionlike / unlike / bookmark / unbookmark
ws.connections.activeObservableGaugeroutes/chat-ws/index.ts addCallback walks userConnections Map
ws.messages.sentCounter同上
ws.messages.receivedCounterservices/domain/chats.ts

Product Analytics

Metric类型落点Labels
airi.product.eventsCounterservices/domain/product-events.ts trackfeatureactionstatussource(可选)

Prometheus 只看事件量,不看独立用户airi.product.events 的 labels 必须保持低基数,不能加 user_idsession_id、request id、raw error message 或 prompt。要回答"每个功能有多少独立用户",查 Postgres product_eventscount(distinct user_id)

Revenue & Billing

Stripe lifecycle

Metric类型落点Labels
stripe.checkout.createdCounterroutes/stripe/index.ts /checkout POST
stripe.checkout.completedCounterwebhook checkout.session.completed
stripe.payment.failedCounterwebhook invoice.payment_failed
stripe.subscription.eventCounterwebhook customer.subscription.*event_typecreated/updated/deleted
stripe.eventsCounter任何 webhookevent_type(完整 event.type,e.g. invoice.paid
airi.stripe.revenueCounter(minor_unitwebhook checkout.session.completed + invoice.paidcurrencysourcecheckout/invoice

金额单位airi.stripe.revenue 用最小币种单位(cents 等),跨币种 sum 没有意义,永远 sum by (currency)。要换主单位(dollars 等)做 / 100 即可,前提是该币种没有不同 minor unit 比例。

Flux ledger

Metric类型落点Labels
airi.billing.flux.consumedCounterroutes/openai/v1/index.ts recordMetrics(chat / tts)gen_ai.request.modelgen_ai.operation.name/airi.gen_ai.operation.kindhttp.response.status_code
airi.billing.flux.creditedCounterservices/domain/billing/billing-service.ts 三条入账路径sourcestripe.checkout/stripe.invoice/promo/admin_grant/...)、typecredit/promo
airi.billing.flux.unbilledCounterroutes/openai/v1/index.ts streaming 路径里 consumeFluxForLLM 失败的 catchgen_ai.request.modelreasondebit_failed)、stagestreaming
flux.insufficient_balanceCounterservices/domain/billing/billing-service.ts debitFlux
airi.billing.tts.charsCounterservices/domain/billing/flux-meter.ts accumulatemetertts)、model
airi.billing.tts.preflight_rejectionsCounterflux-meter.ts assertCanAffordmeterreasoninsufficient_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。

GenAI

Metric类型Unit落点Labels
gen_ai.client.operation.durationHistogramsroutes/openai/v1/index.ts recordMetricsgen_ai.request.modelgen_ai.operation.name/airi.gen_ai.operation.kindhttp.response.status_code
gen_ai.client.operation.countCounter同上同上
gen_ai.client.token.usage.inputCounter同上同上
gen_ai.client.token.usage.outputCounter同上同上
gen_ai.client.first_token.durationHistograms流式 reader 第一个非空 chunk 抵达时gen_ai.request.modelgen_ai.operation.name
airi.gen_ai.stream.interruptedCounter流式 reader catchgen_ai.request.modelstagebefore_first_chunk/mid_stream

Email(Resend)

来源 services/adapters/email.tssend() 内部 try/catch。

Metric类型Labels
airi.email.sendCountertemplateverification/password_reset/magic_link/change_email/delete_account/unknown
airi.email.failuresCountertemplateerror_name(Resend error.nameunhandled
airi.email.durationHistogram(s)templateoutcomeok/error

Rate limiting

来源 middlewares/rate-limit.tshandler

Metric类型Labels
airi.rate_limit.blockedCounterroute(callsite 提供,e.g. auth.api / openai.completions / stripe.checkout)、key_typeuser/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 / bytes
  • nodejs.eventloop.delay.{p50,p99,mean,...} Gauge / s
  • nodejs.eventloop.utilization Gauge / ratio
  • v8js.gc.duration Histogram / s

已落地的 dashboard 行映射

airi-server-overview-cloud.jsonbuild.ts 生成(直接改 JSON 会在下次 regenerate 时被覆盖;改 build.ts),跑 pnpm -F @proj-airi/server otel:dashboards 重新生成。从上到下:

Rowviz关键 metric
Service Healthstat / gauge / heatmapuser.totalmax())、user.active_sessionsavg())、ws.connections.activesum())、http.server.request.duration_count(req/s + 5xx%)、gen_ai.client.operation.count
User Engagementstatuser.active_rolling(DAU / WAU / MAU,max()
Product Analyticsstat / gauge / bargauge / timeseriesairi.product.events(Prom-safe event volume by feature / action / status;distinct users 仍查 Postgres product_events
HTTPheatmap / bargauge / timeserieshttp.server.request.duration_count(status mix、top routes、route errors)、http.server.request.duration_bucket(P95 by route)
LLM Gatewaytimeseriesgen_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 Upstreamstimeseriesgen_ai.client.operation.count / gen_ai.client.operation.duration_bucket by providerairi.billing.tts.chars
LLM Tokens & Qualitystat / timeseriesgen_ai.client.token.usage.{input,output}airi.billing.flux.unbilledairi.gen_ai.stream.interrupted
LLM Router Healthstat / gauge / timeseriesairi.gen_ai.gateway.{key.exhausted,decrypt.failures,fallback.count,upstream.errors}
Businessstat / gaugeairi.stripe.revenue(by currency)、checkout conversion %、stripe.events 分布
Infrastructure (collapsed, by service_instance_id)timeseriesdb_client_operation_duration P95(cluster)、db_client_connection_countv8js_memory_heap_used_bytes %、nodejs_eventloop_delay_p99_seconds
LogslogsLoki,不是 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 时按那张表对照一遍。

验证 metric 是否已注册

src/scripts/otel/smoke.ts 跑一遍:

sh
pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel/smoke.ts

会打印 SDK 启动时立即 export 的所有 instrument 名字。Counter 通过 .add(0) primingotel/index.ts primeCounter)后会出现在这里 —— Histogram 不会,要等真实 .record() 才出现。

加新 metric 时的 checklist

  1. 决定命名空间:能映射到 OTel semconv 就用标准名,否则放 airi.*(不要造新顶级前缀)
  2. utils/observability.ts 加常量
  3. otel/index.ts 的对应 metric group 接口(HttpMetrics/AuthMetrics/...)加字段,并在 initOtelmeter.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.ts 确认注册
  7. 更新本文档对应章节