feat(server): new metrics for auth

- Added a new ObservableGauge for distinct active users to track real active user count, mitigating session row inflation issues.
- Updated the Grafana dashboard to reflect changes, including the removal of redundant WS Connections panel and the addition of new metrics for active sessions and distinct users.
- Improved documentation for verification automation processes, outlining a structured approach to automate verification steps and maintain evidence of tests.
This commit is contained in:
RainbowBird
2026-05-15 18:54:05 +08:00
parent bc69df548e
commit a3a401d79d
10 changed files with 1060 additions and 60 deletions
@@ -50,6 +50,14 @@
| LLM token / cost | **Grafana**(短期) | 后续若引入 Langfuse 则迁过去 |
| 用户行为漏斗各步骤 | **PostHog**(必须) | 第一步通常是前端事件,Grafana 拿不到 |
### Better Auth session table 与活跃用户
`user_active_sessions`COUNT(\*))和 `user_distinct_active`COUNT(DISTINCT user_id))共享同一张 `session` 表:
- **Better Auth 每次 sign-in / 每次 OIDC access-token 颁发都新建一条 session row,从不主动 GC 过期 row**——因为 `oauth_access_token.session_id` FK 指向 session`apps/server/src/libs/auth.ts:513` 注释)
- 实战观察:~80K `user_active_sessions` 对应实际只有几百 distinct user。比例 5+ 就该考虑加 session GC cron 或缩短 Better Auth `expiresIn`
- 永远展示 `user_distinct_active` 给非工程师看(PM、运营);`user_active_sessions` 留给工程师 debug
### Dashboard 标注规则
两边都展示的指标,**必须**在 Grafana panel description 和 PostHog insight description 里:
@@ -95,7 +103,7 @@
| 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()` |
| Auth | `user_active_sessions` / `user_distinct_active` | Postgres → Grafana 派生 | 集群级 gauge,用 `avg()` 不要 `sum()`。两个一起看:`user_active_sessions` = `COUNT(*)`session row 数,会膨胀), `user_distinct_active` = `COUNT(DISTINCT user_id)`(真实活跃用户数)|
| 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 |
@@ -217,6 +225,30 @@ PostHog UI 配 cohort
不能只用 source connector:它是 data warehouse 层,**不生成 person event,做不了漏斗**。
## 5xx Triage 路径
Dashboard 上 follow 这条 panel 链可以从"出事了"一路 drill 到"哪个 trace 是真凶"
1. **panel-4 `5xx Rate %`**Row 1)— 数字 / gauge 颜色变红,说明出事
2. **panel-9 `Top Routes by 5xx`**Row 2 donut)— "现在哪些 route 在失败"
3. **panel-44 `5xx Rate by Route`**Row 5.5 timeseries)— "什么时候开始的、是单点还是普遍"
4. **panel-91 `5xx Error Logs`**(Row 8 上半)— 实际错误消息,里面有 `trace_id` field 可点 → Tempo 看完整 trace 回放
### Tempo / Loki derived fields 配置(一次性)
panel-91 的 `trace_id` 字段必须配 Grafana Cloud Loki datasource 的 **Derived fields** 才能跳 Tempo。这不在 dashboard JSON 范围内,是 datasource 级配置:
- **Grafana Cloud** → Connections → Data sources → 选 `grafanacloud-projairi-logs`Loki)→ Derived fields
- 添加:
- **Name**: `trace_id`
- **Type**: Regex in label or value
- **Regex**: `"trace_id":"([a-f0-9]+)"`(匹配我们 logger 的 JSON 输出)
- **URL**: 留空
- **Internal link**: ✓,datasource 选 `grafanacloud-projairi-traces`Tempo
- 同样手法可加 `req` (request id) → 配 internal link 回 Loki 自身,按 requestId filter
配置完之后日志面板里 `trace_id` 会变成蓝色可点,直接跳 Tempo waterfall。这一步配置只做一次,新加 panel 自动享有。
## 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 时同步更新这里。
@@ -0,0 +1,127 @@
# Verification: Flux Unbilled Exploit Fix
Status: **patched in commit `7267b0d6b`** (2026-05-15) for the chat-completion path. TTS flux-meter adaptation followed up in the same PR as this verification doc (see "Remaining gaps → Gap 1").
Last attempted: 2026-05-15
Owner: rbxin2003@gmail.com
## 用户路径
- **场景**:用户 `0 < balance < fallbackRate`,开 N 个并发 LLM completion 请求
- **预期(修补后)**:第一个 request 之后 pre-flight 拒绝;最多触发一次 partial debit`charged < requested`),所有剩余 request 收 402
- **实际(修补前)**N 个 request 全部走到 stream end,每个 catch `consumeFluxForLLM` 失败回滚 → balance 不变 → 用户拿到 N 次免费 LLM 响应
## Before / After 行为
### Before(修补前漏洞链)
1. Pre-flight 仅 `if (flux.flux <= 0)``apps/server/src/routes/openai/v1/index.ts`,旧版)
2. 用户余额 1 flux,但 `FLUX_PER_REQUEST` (fallback) 通常远大于 1
3. 并发 N 请求全部通过 pre-flight,上游 LLM 全部完成(响应已 stream 出去)
4. `consumeFluxForLLM``debitFlux` (`apps/server/src/services/billing/billing-service.ts:107`),旧逻辑:
```ts
if (balanceBefore < input.amount) {
throw createPaymentRequiredError('Insufficient flux')
}
```
整个 DB tx 回滚 → balance 维持 1 flux
5. catch 路径上报 `airi_billing_flux_unbilled_total{reason='debit_failed'}` += full amount
6. 用户重复同样的脚本,每次都拿免费响应
这是 Grafana panel-43 (Flux Unbilled) 累积到 **70.2K** 的根因。
### Aftercommit `7267b0d6b`
**1. Pre-flight 阈值改为 fallbackRate**`apps/server/src/routes/openai/v1/index.ts:138-142`):
```ts
const fallbackRate = await configKV.getOrThrow('FLUX_PER_REQUEST')
const fluxPer1kTokens = await configKV.get('FLUX_PER_1K_TOKENS')
const flux = await fluxService.getFlux(user.id)
if (flux.flux < fallbackRate) {
throw createPaymentRequiredError('Insufficient flux')
}
```
并发 N 请求时,pre-flight 直接 402 拒绝,不会进入上游 LLM 调用。
**2. Partial-debit 语义**`apps/server/src/services/billing/billing-service.ts:107-130`):
```ts
if (balanceBefore <= 0) {
metrics?.fluxInsufficientBalance.add(1)
throw createPaymentRequiredError('Insufficient flux')
}
const chargedAmount = Math.min(input.amount, balanceBefore)
const balanceAfter = balanceBefore - chargedAmount
const isPartial = chargedAmount < input.amount
```
- `balance > 0` 但不够 → drain 到 0ledger 记 `amount = charged`metadata 带 `requestedAmount + unbilled`
- `balance <= 0` 才 throwcatch path 上报 `reason='debit_failed'`
- Partial drain 上报 `reason='partial_debit_drained'`,跟真实 DB 错误区分
**3. Idempotency 反映原始 charged**`billing-service.ts:71-94`):
替换 request 的同 requestId 重放复用历史 `existing.amount` 作为 `charged`,避免重试时双扣 unbilled counter。
## Evidence
| Item | Reference |
|---|---|
| Fix commit | `7267b0d6b feat(server/billing): partial-debit semantics to prevent unpaid usage exploit` |
| Pre-flight gate | `apps/server/src/routes/openai/v1/index.ts:138-142` |
| Partial-debit logic | `apps/server/src/services/billing/billing-service.ts:107-148` |
| Streaming path unbilled metric | `apps/server/src/routes/openai/v1/index.ts:299-313` (label `reason='partial_debit_drained'`, `stage='streaming'`) |
| Non-streaming path unbilled metric | `apps/server/src/routes/openai/v1/index.ts:374-388` (label `stage='non_streaming'`) |
| Regression tests added | `apps/server/src/routes/openai/v1/route.test.ts` (+97 lines), `apps/server/src/services/billing/tests/billing-service.test.ts` (+89 lines) |
测试覆盖的两个核心 case
- `'rejects pre-flight when balance is below FLUX_PER_REQUEST (Issue: unpaid-usage-exploit)'` — 验证 pre-flight 在 partial-balance 用户上 rejectupstream 未被调用
- `'non-streaming completion drains partial balance and logs charged (Issue: unpaid-usage-exploit)'` — 验证 partial-debit drain 到 zero + metric 上报
## Remaining gaps
### Gap 1 — TTS flux-meter partial-debit 适配 ✅ 已修复
`apps/server/src/services/billing/flux-meter.ts:135-228` 的 `accumulate()` 现在解构 `{ charged, requested }`partial drain 时:
- 上报 `airi_billing_flux_unbilled_total{source='tts_meter', reason='partial_debit_drained', meter, gen_ai.request.model?}`
- `INCRBY` `(requested - charged) * unitsPerFlux` 回 Redis debt counter
- `AccumulateResult` 增加 `unbilledFlux` 字段供调用方观测
- 加 invariant 校验 `charged > requested` / 非整数 / 负数 → throw 而不是静默 under-restore
测试覆盖:`flux-meter.test.ts:217-260`case 名带 `Issue: unpaid-usage-exploit follow-up`。
**残余风险(已知,留 follow-up**settlement (LUA `runScript`) 和 `INCRBY` restore 之间非原子,并发请求理论上可能在窗口内读到 mid-state。窗口很小(一个 DB tx),实际命中很难触发;长期修复需要 Redis lock 或 unbilled 单独 bucket。代码里 `flux-meter.ts` 有 `// REVIEW:` 标记。
### Gap 2 — 修补前的 70.2K 历史漏账未核销
panel-43 显示的 70.2K 是 counter 累积值(`increase($__range)`),代表修补前漏出去的总量。修补**不会让 panel 自动归零**,只会让新的增量趋近 0(除真实 DB 错误)。
**建议**
- 把 dashboard 时间窗调到 commit `7267b0d6b` 部署后(2026-05-15 之后)观察增量斜率
- 加 Grafana alert`increase(airi_billing_flux_unbilled_total[5m]) > 0` → PagerDuty
- 如果业务需要核销历史 70.2K,从 `flux_transaction` ledger 反查 `metadata->>'reason' = 'debit_failed'` 的记录,配合 Loki 错误日志定位涉事 userId
## What's verified
- ✓ 代码层:pre-flight gate + partial-debit semantics 确实改了,逻辑正确
- ✓ 测试层:两个核心 regression test 覆盖 exploit 场景,case 名带 `Issue: unpaid-usage-exploit` 标识
## What's pending live verification
- ⊘ 生产 Grafana 上 panel-43 在 `2026-05-15` commit 部署后的斜率趋近 0
- ⊘ 没跑 `pnpm -F @proj-airi/server exec vitest run` 实际确认新测试 pass(建议在 push 之前跑一次)
- ⊘ TTS partial-debit 适配的 live verification(代码已修补 + 单测 14/14 pass,需要在生产观察 `airi_billing_flux_unbilled_total{source='tts_meter'}` 出现合理流量后再 close
## Recommended follow-ups
按优先级:
1. **P0 — 加 Grafana alert** `increase(airi_billing_flux_unbilled_total[5m]) > 0` → 通知
2. **P1 — 历史 70.2K 漏账处理**:查 ledger + Loki 决定核销还是补账
3. ~~**P1 — 修 TTS flux-meter 适配 partial-debit 新语义**~~ ✅ 已修复,见 Gap 1
4. **P2 — Dashboard 改造**panel-43 时间窗注解 + 区分 `reason` label 的 stack 图,把 `partial_debit_drained` 跟 `debit_failed` 分色展示)
@@ -0,0 +1,195 @@
# Verification: Flux Unbilled Historical Reconciliation
Status: **investigation framework — data gathering pending**
Owner: rbxin2003@gmail.com
Last updated: 2026-05-15
Related: [`flux-unbilled-exploit-fix.md`](./flux-unbilled-exploit-fix.md), [Grafana panel-43](../../../otel/grafana/dashboards/airi-server-overview-cloud.json)
## 用户路径
- **场景**commit `7267b0d6b` 之前累积了 ~70.2K Flux 的 unpaid usagepanel-43 `airi_billing_flux_unbilled_total` 显示值)。需要决定核销、补账、还是不处理
- **预期**:跑下面的 SQL + Loki query → 区分 partial-drain(用户已部分付款)vs debit-failedDB 错误,真零付款)→ 按 user 聚合 → 给出 reconciliation 决策
- **当前状态**:没有 prod DB 访问权限的工程师跑下面的 query。下方 SQL/queries 是**待执行的模板**,不是已采集的数据
## 两类漏账的区分
修补前 `airi_billing_flux_unbilled_total` 是单一 counter,没区分 reason。修补后(`7267b0d6b`)按 `reason` 拆成两个 label
| reason label | 触发条件 | Ledger 是否有记录 | 用户实际付款比例 |
|---|---|---|---|
| `partial_debit_drained` | `0 < balance < amount`drain 到 0 | ✓ 有(`amount = charged`metadata 带 `unbilled`) | 部分付款(drain 数额) |
| `debit_failed` | `balance <= 0` 或 DB tx 抛错 | ✗ 无(tx 回滚) | 零付款 |
**70.2K 全部发生在 5/15 之前**,那时所有失败都走 catch path → 全部记为 `reason='debit_failed'`**全部无 ledger row** → 用户实际付款为 0。
但实际不全是漏洞:少部分是真正的 DB 错误(DB outage / 唯一索引冲突)。绝大部分是 exploit。
## 取证 SQL(待执行)
> 在 Railway Postgres console 或本地 `psql $DATABASE_URL` 跑。如果 query 太重,先 `EXPLAIN ANALYZE` 看 cost`flux_transaction` 有 `flux_tx_user_id_idx` 和 `flux_tx_created_at_idx` 索引可以走
### 1. 按 type 分类的 ledger 写入分布
`reason='debit_failed'` 没 ledger row,所以这条 query **拿不到**修补前的漏洞数据——它只能 sanity check 修补后的新 row
```sql
SELECT
type,
COUNT(*) AS row_count,
SUM(amount) AS total_amount,
MIN(created_at) AS first_seen,
MAX(created_at) AS last_seen
FROM flux_transaction
WHERE created_at >= '2026-04-15' -- 4 周窗口
GROUP BY type
ORDER BY total_amount DESC;
```
### 2. Partial-drain ledger rows(修补后)
`commit 7267b0d6b` 之后才会有这种 row。修补前漏出去的 70K 在这里**看不到**:
```sql
SELECT
user_id,
COUNT(*) AS partial_debit_count,
SUM(amount) AS total_charged,
SUM((metadata->>'unbilled')::bigint) AS total_unbilled,
SUM((metadata->>'requestedAmount')::bigint) AS total_requested,
MIN(created_at) AS first_partial,
MAX(created_at) AS last_partial
FROM flux_transaction
WHERE type = 'debit'
AND metadata ? 'unbilled'
AND (metadata->>'unbilled')::bigint > 0
AND created_at >= '2026-05-15' -- 修补后窗口
GROUP BY user_id
ORDER BY total_unbilled DESC
LIMIT 50;
```
### 3. 流量最高的用户(用来定位 exploit 嫌疑)
修补前的漏账主要靠这条 + Loki 日志交叉定位涉事 user
```sql
SELECT
user_id,
COUNT(*) AS debit_count,
SUM(amount) AS total_debited,
SUM(balance_after - balance_before) AS net_balance_change,
MIN(created_at) AS first_debit,
MAX(created_at) AS last_debit
FROM flux_transaction
WHERE type = 'debit'
AND created_at BETWEEN '2026-05-01' AND '2026-05-15' -- 修补前 2 周
GROUP BY user_id
HAVING COUNT(*) > 100
ORDER BY debit_count DESC
LIMIT 20;
```
异常用户特征:`debit_count` 极高 + `net_balance_change` 接近 0 即"balance 一直被推到底但没归零"。这种是 exploit 的核心 signature——攻击者维持 balance 卡在 `0 < x < fallbackRate` 区间反复触发免费请求。注意:`net_balance_change` 在 ledger 模型下应该等于 `-SUM(amount)`;如果两者接近 0 但 `SUM(amount)` 很大,说明 balance 被人工补回去过(信用 / 充值 / promo),需要进一步交叉检查。
### 4. 当前 user_flux 余额 vs ledger 一致性 sanity
```sql
WITH ledger_balance AS (
SELECT
user_id,
SUM(CASE WHEN type = 'credit' OR type = 'initial' OR type = 'promo' THEN amount
WHEN type = 'debit' THEN -amount
ELSE 0
END) AS computed_balance
FROM flux_transaction
GROUP BY user_id
)
SELECT
uf.user_id,
uf.flux AS recorded_balance,
lb.computed_balance AS ledger_sum,
uf.flux - lb.computed_balance AS drift
FROM user_flux uf
LEFT JOIN ledger_balance lb USING (user_id)
WHERE ABS(uf.flux - COALESCE(lb.computed_balance, 0)) > 0
ORDER BY ABS(uf.flux - COALESCE(lb.computed_balance, 0)) DESC
LIMIT 50;
```
正常情况下 drift 应该是 0——任何 drift 都说明 ledger 和 user_flux 表脱钩了,是 P0 事件。
## 取证 Loki(待执行)
Grafana → Explore → Loki datasource。这是**修补前漏账数据的唯一来源**(无 ledger row):
### 漏账 error log 全量
```logql
{service_name="server"} |= "Failed to debit flux after streaming — unpaid usage"
| json
| line_format "{{.userId}} | req={{.requestId}} | flux={{.fluxConsumed}} | {{.error}}"
```
### 按 userId 聚合 unbilled 数量
```logql
sum by (userId) (
count_over_time({service_name="server"} |= "Failed to debit flux after streaming" | json [30d])
)
```
### 时间分布(找爆发时段)
```logql
sum (
rate({service_name="server"} |= "Failed to debit flux after streaming" | json [5m])
)
```
修补前若有 sustained > 0 → exploit;若是窄峰 → 真实 DB outage。
## 处理决策框架
跑完上面 query + Loki 后,按下面决策树走:
```
┌──────────────────────────────────────────┐
│ 单用户漏账 fluxConsumed > 1000? │
├──────────────────────────────────────────┤
│ YES → exploit 嫌疑 │
│ ├─ 多 IP / 短时间高频 → confirmed │
│ │ │ 不补账(用户已知道是漏洞) │
│ │ │ Ban user 或 require email │
│ │ │ verification + 强制 reauth │
│ │ │ 已修补 → 单纯历史损失 │
│ │ └─ 不需要在 Postgres 写新 ledger │
│ └─ 单 IP / 时间分散 → 可能正常 power user │
│ │ 主动联系用户,问明情况 │
│ └─ 视情况决定是否赠送 flux 补偿 │
│ │
│ NO (用户总漏账 < 1000 flux) → 真异常 │
│ │ 多半是 DB outage / 单次错误 │
│ │ 不值得逐个追账 │
│ └─ 整体核销 + 跑 sanity SQL 4 验证 │
│ user_flux ≡ ledger 仍然一致 │
└──────────────────────────────────────────┘
```
**关键判断**:修补前的漏账**不在 ledger 里**debit_failed 不写 row),所以**不需要在 DB 做任何"核销"操作**——余额是干净的,损失只是"曾经免费送出去的 LLM token 成本"。
唯一需要写 DB 操作的场景:sanity SQL #4 跑出非零 drift。那是另一个 bugledger ↔ user_flux 脱钩),跟漏账无关。
## 修补后的监控建议(持续)
1. 加 Grafana alert`increase(airi_billing_flux_unbilled_total{reason!="partial_debit_drained"}[5m]) > 0` → 立即 pagepartial drain 是合理路径,不 page
2. 加每周自动 cron job 跑 sanity SQL #4drift > 0 → 报警(注意:项目里**不允许**新加后台 worker / cron,所以这个 job 应该走外部 ops 工具,比如 Railway scheduled command 或 GitHub Action
## What's verified / What's pending
| Item | Status |
|---|---|
| 漏洞已堵(commit `7267b0d6b` | ✓ 已确认(见 `flux-unbilled-exploit-fix.md` |
| 70.2K 历史漏账的 user 分布 | ⊘ 待跑 Loki query |
| user_flux ↔ ledger drift 是否存在 | ⊘ 待跑 SQL #4 |
| Exploit 涉事 user 是否已 ban / re-auth | ⊘ 等数据出来后决定 |
| `airi_billing_flux_unbilled_total{reason!="partial_debit_drained"}` Grafana alert | ⊘ 待配(见 `metrics-ownership.md` Alert SOP |
@@ -41,8 +41,8 @@
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"expr": "avg(user_active_sessions{service_name=~\"$service\", deployment_environment=~\"$env\"})",
"legendFormat": "sessions"
"expr": "avg(user_distinct_active{service_name=~\"$service\", deployment_environment=~\"$env\"})",
"legendFormat": "users"
},
"version": "v0"
},
@@ -54,7 +54,7 @@
"transformations": []
}
},
"description": "Currently active sessions in Postgres (Better Auth `session.expires_at > now()`). Cluster-wide gauge — every replica polls the same DB on a 10s cache. We aggregate with `avg()` (not `sum()`, which would multiply by replica count; not `max()`, which biases high when one replica's cache is fresher than another's after a logout).",
"description": "COUNT(DISTINCT user_id) over the Better Auth `session` table where `expires_at > now()`. This is the *real* active-user count. The historical \"Active Users\" panel queried `user.active_sessions` (COUNT(*) on the same table), which counts session **rows** not users — Better Auth creates a new row per sign-in and per OIDC access-token issuance and never GCs expired rows, so the row count drifts up and we have seen it report ~80K on a deployment with hundreds of actual users. Compare with `panel-15` (Active Sessions) to spot session-row inflation; ratio > ~5 means it's time for a session GC cron. Cluster-wide gauge — `avg()`, not `sum()`.",
"id": 1,
"links": [],
"title": "Active Users",
@@ -106,7 +106,7 @@
}
}
},
"panel-2": {
"panel-15": {
"kind": "Panel",
"spec": {
"data": {
@@ -124,8 +124,8 @@
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"expr": "sum(ws_connections_active{service_name=~\"$service\", deployment_environment=~\"$env\"})",
"legendFormat": "connections"
"expr": "avg(user_active_sessions{service_name=~\"$service\", deployment_environment=~\"$env\"})",
"legendFormat": "sessions"
},
"version": "v0"
},
@@ -137,10 +137,10 @@
"transformations": []
}
},
"description": "Live registry size from chat-ws (ObservableGauge, scraped each export interval).",
"id": 2,
"description": "COUNT(*) over the Better Auth `session` table where `expires_at > now()`. Counts session **rows**, not users — see `panel-1` for the de-duplicated user count. Useful as a denominator to spot row inflation: divide by panel-1 to get rows-per-user, watch for sustained growth.",
"id": 15,
"links": [],
"title": "WS Connections",
"title": "Active Sessions",
"vizConfig": {
"group": "stat",
"kind": "VizConfig",
@@ -156,6 +156,10 @@
{
"color": "green",
"value": 0
},
{
"color": "yellow",
"value": 5000
}
]
},
@@ -216,7 +220,7 @@
"transformations": []
}
},
"description": "5-minute average inbound HTTP request rate. /health (Railway probe) is excluded at the @hono/otel middleware level so this reflects real user traffic.",
"description": "5-minute average inbound HTTP request rate. /health (Railway probe) is excluded at the @hono/otel middleware level so this reflects real user traffic. **Fixed 5m window — intentionally does not follow the dashboard time picker** (see row-level note). For trends, see panel-14 (HTTP Request Rate by Route).",
"id": 3,
"links": [],
"title": "Req/s (5m)",
@@ -304,7 +308,7 @@
"transformations": []
}
},
"description": "5xx responses ÷ all responses over the last 5m. Spikes correlate with deploys, upstream outages, or DB problems. >1% warns, >5% pages.",
"description": "5xx responses ÷ all responses over the last 5m. **Fixed 5m window — intentionally does not follow the dashboard time picker**: this is an on-call glance (\"is the service failing right now\"). For range-aware triage use panel-9 donut and panel-44 timeseries. Spikes correlate with deploys, upstream outages, or DB problems. >1% warns, >5% pages.",
"id": 4,
"links": [],
"title": "5xx Rate %",
@@ -392,7 +396,7 @@
"transformations": []
}
},
"description": "5-minute average LLM gateway request rate (chat + tts).",
"description": "5-minute average LLM gateway request rate (chat + tts). **Fixed 5m window — see row-level note.** For trends and per-model breakdown see panel-11.",
"id": 5,
"links": [],
"title": "LLM Req/s (5m)",
@@ -472,7 +476,7 @@
"transformations": []
}
},
"description": "Email failures ÷ total attempts over the last 5m. >5% means Resend / DNS / suppression-list problems blocking auth flows.",
"description": "Email failures ÷ total attempts over the last 5m. **Fixed 5m window — see row-level note.** >5% means Resend / DNS / suppression-list problems blocking auth flows.",
"id": 6,
"links": [],
"title": "Email Failure %",
@@ -548,7 +552,7 @@
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"expr": "topk(8, sum by (gen_ai_request_model) (increase(gen_ai_client_operation_count_total{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_request_model!=\"\"}[5m])))",
"expr": "topk(8, sum by (gen_ai_request_model) (increase(gen_ai_client_operation_count_total{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_request_model!=\"\"}[$__range])))",
"legendFormat": "{{gen_ai_request_model}}"
},
"version": "v0"
@@ -561,10 +565,10 @@
"transformations": []
}
},
"description": "Share of LLM gateway calls by model. Quickly shows which model is doing the heavy lifting.",
"description": "Share of LLM gateway calls by model, summed over the dashboard time range. Follows the time picker — pick 1h to see the last hour's model mix, pick 7d to see this week's.",
"id": 8,
"links": [],
"title": "LLM Models (last 5m)",
"title": "LLM Models (range)",
"vizConfig": {
"group": "piechart",
"kind": "VizConfig",
@@ -639,7 +643,7 @@
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"expr": "topk(10, sum by (http_route) (increase(http_server_request_duration_seconds_count{service_name=~\"$service\", deployment_environment=~\"$env\", http_request_method!=\"OPTIONS\", http_route!=\"\"}[5m])))",
"expr": "topk(10, sum by (http_route) (increase(http_server_request_duration_seconds_count{service_name=~\"$service\", deployment_environment=~\"$env\", http_request_method!=\"OPTIONS\", http_route!=\"\", http_response_status_code=~\"5..\"}[$__range])))",
"legendFormat": "{{http_route}}"
},
"version": "v0"
@@ -652,10 +656,10 @@
"transformations": []
}
},
"description": "Top 10 Hono-matched routes by request count over the last 5 minutes. Answers \"which endpoint is being hit, and how much\" — replaces the previous HTTP status-code donut whose 2xx slice dominated everything else. Cardinality is bounded because `http_route` is the matched route pattern, not the concrete URL.",
"description": "Top 10 Hono-matched routes by 5xx response count over the dashboard time range. Follows the time picker — pick 1h for \"what's failing right now\", pick 24h for \"what failed most today\". The overall 5xx% gauge (panel-4) is a fixed-5m snapshot for on-call glance; this donut respects the time picker for triage.",
"id": 9,
"links": [],
"title": "Top Routes by Requests (last 5m)",
"title": "Top Routes by 5xx (range)",
"vizConfig": {
"group": "piechart",
"kind": "VizConfig",
@@ -673,7 +677,7 @@
}
},
"unit": "short",
"noValue": "no traffic"
"noValue": "no 5xx"
},
"overrides": []
},
@@ -1947,6 +1951,122 @@
}
}
},
"panel-44": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"datasource": {
"name": "grafanacloud-projairi-prom"
},
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"expr": "topk(10, sum by (http_route) (rate(http_server_request_duration_seconds_count{service_name=~\"$service\", deployment_environment=~\"$env\", http_request_method!=\"OPTIONS\", http_route!=\"\", http_response_status_code=~\"5..\"}[$__rate_interval])))",
"legendFormat": "{{http_route}}"
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "5xx response rate split by route. Use this to confirm whether a 5xx spike in `panel-4` is concentrated on one endpoint (e.g. a broken deploy of /api/v1/openai/*) or scattered (e.g. DB outage taking down everything). Drill into the Logs row (`panel-91`) for the matching error bodies + trace ids.",
"id": 44,
"links": [],
"title": "5xx Rate by Route (top 10)",
"vizConfig": {
"group": "timeseries",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 20,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
},
"unit": "reqps"
},
"overrides": []
},
"options": {
"annotations": {
"clustering": -1,
"multiLane": false
},
"legend": {
"calcs": [
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "right",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
}
},
"version": "13.0.0-23630096546"
}
}
},
"panel-30": {
"kind": "Panel",
"spec": {
@@ -2239,7 +2359,7 @@
"transformations": []
}
},
"description": "PostgreSQL query duration P95 from PgInstrumentation. Spikes correlate with index misses, connection exhaustion, or backend lock contention.",
"description": "PostgreSQL query duration P95 from PgInstrumentation. **Fixed 5m window — does not follow the dashboard time picker** (same posture as the Service Health row stats: this is a \"right now\" glance). Spikes correlate with index misses, connection exhaustion, or backend lock contention.",
"id": 50,
"links": [],
"title": "DB Query P95 (5m)",
@@ -2708,6 +2828,71 @@
"version": "13.0.0-23630096546"
}
}
},
"panel-91": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"datasource": {
"name": "grafanacloud-projairi-logs"
},
"group": "loki",
"kind": "DataQuery",
"spec": {
"expr": "{service_name=~\"$service\", deployment_environment=~\"$env\"} | json | level=~\"warn|error\"",
"legendFormat": ""
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "Server-side error logs (level=warn|error) from Loki. Loki derived fields turn `trace_id` and `req` into clickable links — `trace_id` jumps to Tempo for full request playback (spans + child calls + DB queries), `req` filters this panel to a single request id. Use this together with panel-9 (which route) and panel-44 (when).",
"id": 91,
"links": [],
"title": "5xx Error Logs",
"vizConfig": {
"group": "logs",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"dedupStrategy": "none",
"enableInfiniteScrolling": false,
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showControls": false,
"showFieldSelector": false,
"showLabels": true,
"showLevel": true,
"showLogAttributes": true,
"showTime": true,
"sortOrder": "Descending",
"timestampResolution": "ms",
"unwrappedColumns": false,
"wrapLogMessage": true
}
},
"version": "13.0.0-23630096546"
}
}
}
},
"layout": {
@@ -2740,7 +2925,7 @@
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-2"
"name": "panel-15"
},
"height": 4,
"width": 4,
@@ -3045,6 +3230,33 @@
"title": "Errors / Quality"
}
},
{
"kind": "RowsLayoutRow",
"spec": {
"collapse": false,
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-44"
},
"height": 7,
"width": 24,
"x": 0,
"y": 0
}
}
]
}
},
"title": "5xx Triage"
}
},
{
"kind": "RowsLayoutRow",
"spec": {
@@ -3177,13 +3389,26 @@
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-90"
"name": "panel-91"
},
"height": 12,
"height": 10,
"width": 24,
"x": 0,
"y": 0
}
},
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-90"
},
"height": 10,
"width": 24,
"x": 0,
"y": 10
}
}
]
}
+104 -30
View File
@@ -355,28 +355,50 @@ function row(title: string, items: ReturnType<typeof item>[], { collapse = false
// between defined panel ids and layout references.
const elements: Record<string, unknown> = {}
// Row 1: Service Health — answers "is anything broken right now?"
// Row 1: Service Health — answers "is anything broken **right now**?"
//
// Time-window policy for this row: all rate / ratio queries use a fixed
// `[5m]` window and DO NOT follow the dashboard time picker. Reason:
// these panels are designed for on-call glance ("is the service healthy
// at this instant"), and we want the number to be stable across whatever
// time range the viewer happened to pick. If we used `$__rate_interval`,
// the same panel would show different numbers depending on whether the
// time picker is set to "last 1 hour" vs "last 7 days", which is
// confusing for an at-a-glance health board.
//
// To see trends over the time-picker range, use the Row 3 timeseries
// (HTTP / LLM / WS by-* panels) which DO follow the time picker.
// Panels titled "(range)" (Distribution donuts, Business stats) also
// follow the time picker by design.
//
// Mix of stats (absolute counts) and gauges (bounded ratios with thresholds).
elements['panel-1'] = statPanel(
1,
'Active Users',
'Currently active sessions in Postgres (Better Auth `session.expires_at > now()`). Cluster-wide gauge — every replica polls the same DB on a 10s cache. We aggregate with `avg()` (not `sum()`, which would multiply by replica count; not `max()`, which biases high when one replica\'s cache is fresher than another\'s after a logout).',
[query(`avg(user_active_sessions{${SERVICE_FILTER}})`, 'sessions')],
'COUNT(DISTINCT user_id) over the Better Auth `session` table where `expires_at > now()`. This is the *real* active-user count. The historical "Active Users" panel queried `user.active_sessions` (COUNT(*) on the same table), which counts session **rows** not users — Better Auth creates a new row per sign-in and per OIDC access-token issuance and never GCs expired rows, so the row count drifts up and we have seen it report ~80K on a deployment with hundreds of actual users. Compare with `panel-15` (Active Sessions) to spot session-row inflation; ratio > ~5 means it\'s time for a session GC cron. Cluster-wide gauge — `avg()`, not `sum()`.',
[query(`avg(user_distinct_active{${SERVICE_FILTER}})`, 'users')],
{ unit: 'short', steps: [{ color: 'green', value: 0 }, { color: 'yellow', value: 1000 }] },
)
elements['panel-2'] = statPanel(
2,
'WS Connections',
'Live registry size from chat-ws (ObservableGauge, scraped each export interval).',
[query(`sum(ws_connections_active{${SERVICE_FILTER}})`, 'connections')],
{ unit: 'short' },
elements['panel-15'] = statPanel(
15,
'Active Sessions',
'COUNT(*) over the Better Auth `session` table where `expires_at > now()`. Counts session **rows**, not users — see `panel-1` for the de-duplicated user count. Useful as a denominator to spot row inflation: divide by panel-1 to get rows-per-user, watch for sustained growth.',
[query(`avg(user_active_sessions{${SERVICE_FILTER}})`, 'sessions')],
{ unit: 'short', steps: [{ color: 'green', value: 0 }, { color: 'yellow', value: 5000 }] },
)
// WS Connections stat was removed — its sparkline duplicated the
// timeseries in Row 3 (`panel-13`), which already shows the live
// connection count over time with the same `sum(ws_connections_active)`
// query. Keeping both meant the same number rendered twice on first
// look. The Row 3 timeseries wins because it lets you actually read off
// a value at a specific timestamp instead of squinting at the sparkline.
elements['panel-3'] = statPanel(
3,
'Req/s (5m)',
'5-minute average inbound HTTP request rate. /health (Railway probe) is excluded at the @hono/otel middleware level so this reflects real user traffic.',
'5-minute average inbound HTTP request rate. /health (Railway probe) is excluded at the @hono/otel middleware level so this reflects real user traffic. **Fixed 5m window — intentionally does not follow the dashboard time picker** (see row-level note). For trends, see panel-14 (HTTP Request Rate by Route).',
[query(`sum(rate(http_server_request_duration_seconds_count{${SERVICE_FILTER}, http_request_method!="OPTIONS"}[5m]))`, 'req/s')],
{ unit: 'reqps', steps: [{ color: 'green', value: 0 }, { color: 'yellow', value: 100 }, { color: 'red', value: 500 }], decimals: 2 },
)
@@ -384,7 +406,7 @@ elements['panel-3'] = statPanel(
elements['panel-4'] = gaugePanel(
4,
'5xx Rate %',
'5xx responses ÷ all responses over the last 5m. Spikes correlate with deploys, upstream outages, or DB problems. >1% warns, >5% pages.',
'5xx responses ÷ all responses over the last 5m. **Fixed 5m window — intentionally does not follow the dashboard time picker**: this is an on-call glance ("is the service failing right now"). For range-aware triage use panel-9 donut and panel-44 timeseries. Spikes correlate with deploys, upstream outages, or DB problems. >1% warns, >5% pages.',
[query(
`100 * sum(rate(http_server_request_duration_seconds_count{${SERVICE_FILTER}, http_request_method!="OPTIONS", http_response_status_code=~"5.."}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count{${SERVICE_FILTER}, http_request_method!="OPTIONS"}[5m])), 1)`,
'fail %',
@@ -395,7 +417,7 @@ elements['panel-4'] = gaugePanel(
elements['panel-5'] = statPanel(
5,
'LLM Req/s (5m)',
'5-minute average LLM gateway request rate (chat + tts).',
'5-minute average LLM gateway request rate (chat + tts). **Fixed 5m window — see row-level note.** For trends and per-model breakdown see panel-11.',
[query(`sum(rate(gen_ai_client_operation_count_total{${SERVICE_FILTER}}[5m]))`, 'req/s')],
{ unit: 'reqps', decimals: 2 },
)
@@ -403,7 +425,7 @@ elements['panel-5'] = statPanel(
elements['panel-6'] = gaugePanel(
6,
'Email Failure %',
'Email failures ÷ total attempts over the last 5m. >5% means Resend / DNS / suppression-list problems blocking auth flows.',
'Email failures ÷ total attempts over the last 5m. **Fixed 5m window — see row-level note.** >5% means Resend / DNS / suppression-list problems blocking auth flows.',
[query(
`100 * sum(rate(airi_email_failures_total{${SERVICE_FILTER}}[5m])) / clamp_min(sum(rate(airi_email_send_total{${SERVICE_FILTER}}[5m])) + sum(rate(airi_email_failures_total{${SERVICE_FILTER}}[5m])), 1)`,
'fail %',
@@ -422,22 +444,30 @@ elements['panel-6'] = gaugePanel(
// surfaces 4xx/5xx independently.
elements['panel-8'] = piePanel(
8,
'LLM Models (last 5m)',
'Share of LLM gateway calls by model. Quickly shows which model is doing the heavy lifting.',
'LLM Models (range)',
'Share of LLM gateway calls by model, summed over the dashboard time range. Follows the time picker — pick 1h to see the last hour\'s model mix, pick 7d to see this week\'s.',
[query(
`topk(8, sum by (gen_ai_request_model) (increase(gen_ai_client_operation_count_total{${SERVICE_FILTER}, gen_ai_request_model!=""}[5m])))`,
`topk(8, sum by (gen_ai_request_model) (increase(gen_ai_client_operation_count_total{${SERVICE_FILTER}, gen_ai_request_model!=""}[$__range])))`,
'{{gen_ai_request_model}}',
)],
)
// "Top Routes by Requests" lives as a timeseries in `panel-14` (Top
// Endpoints row) — keeping a donut here too would just be a frozen
// snapshot of the timeseries. Instead, this slot answers the higher-
// value question "which routes are producing the 5xx right now?" so
// the dashboard surfaces *failing* endpoints, not just busy ones.
// Pair with panel-44 (5xx Rate by Route timeseries) for the same data
// over time.
elements['panel-9'] = piePanel(
9,
'Top Routes by Requests (last 5m)',
'Top 10 Hono-matched routes by request count over the last 5 minutes. Answers "which endpoint is being hit, and how much" — replaces the previous HTTP status-code donut whose 2xx slice dominated everything else. Cardinality is bounded because `http_route` is the matched route pattern, not the concrete URL.',
'Top Routes by 5xx (range)',
'Top 10 Hono-matched routes by 5xx response count over the dashboard time range. Follows the time picker — pick 1h for "what\'s failing right now", pick 24h for "what failed most today". The overall 5xx% gauge (panel-4) is a fixed-5m snapshot for on-call glance; this donut respects the time picker for triage.',
[query(
`topk(10, sum by (http_route) (increase(http_server_request_duration_seconds_count{${SERVICE_FILTER}, http_request_method!="OPTIONS", http_route!=""}[5m])))`,
`topk(10, sum by (http_route) (increase(http_server_request_duration_seconds_count{${SERVICE_FILTER}, http_request_method!="OPTIONS", http_route!="", http_response_status_code=~"5.."}[$__range])))`,
'{{http_route}}',
)],
{ noValue: 'no 5xx' },
)
// Row 3: Traffic Trends — same data as Row 2, but answering "how is it changing"
@@ -564,6 +594,22 @@ elements['panel-42'] = timeseriesPanel(
{ unit: 'ops' },
)
// 5xx by route over time — complements panel-9 (donut: which routes
// are failing right now) and panel-40 (4xx/5xx by status code: what
// kind of error). This is the "when did /foo start blowing up" view.
// topk(10) keeps the legend readable when one bad deploy lights up
// the whole API surface.
elements['panel-44'] = timeseriesPanel(
44,
'5xx Rate by Route (top 10)',
'5xx response rate split by route. Use this to confirm whether a 5xx spike in `panel-4` is concentrated on one endpoint (e.g. a broken deploy of /api/v1/openai/*) or scattered (e.g. DB outage taking down everything). Drill into the Logs row (`panel-91`) for the matching error bodies + trace ids.',
[query(
`topk(10, sum by (http_route) (rate(http_server_request_duration_seconds_count{${SERVICE_FILTER}, http_request_method!="OPTIONS", http_route!="", http_response_status_code=~"5.."}[$__rate_interval])))`,
'{{http_route}}',
)],
{ unit: 'reqps' },
)
// Row 6: Business — money flow
elements['panel-30'] = statPanel(
30,
@@ -602,7 +648,7 @@ elements['panel-32'] = piePanel(
elements['panel-50'] = statPanel(
50,
'DB Query P95 (5m)',
'PostgreSQL query duration P95 from PgInstrumentation. Spikes correlate with index misses, connection exhaustion, or backend lock contention.',
'PostgreSQL query duration P95 from PgInstrumentation. **Fixed 5m window — does not follow the dashboard time picker** (same posture as the Service Health row stats: this is a "right now" glance). Spikes correlate with index misses, connection exhaustion, or backend lock contention.',
[query(
`histogram_quantile(0.95, sum by (le) (rate(db_client_operation_duration_seconds_bucket{${SERVICE_FILTER}}[5m])))`,
'p95',
@@ -651,25 +697,43 @@ elements['panel-90'] = logsPanel(
`{${SERVICE_FILTER}} |= \`\``,
)
// 5xx-only log stream — paired with the 5xx by-route timeseries and
// donut so on-call goes panel-4 (something is wrong) → panel-9
// (where) → panel-44 (when) → panel-91 (actual error message + trace
// id, click trace_id → Tempo for full request playback). Filters at
// the Loki query level so Grafana doesn't ship the entire log
// firehose to the browser just to client-side filter.
elements['panel-91'] = logsPanel(
91,
'5xx Error Logs',
'Server-side error logs (level=warn|error) from Loki. Loki derived fields turn `trace_id` and `req` into clickable links — `trace_id` jumps to Tempo for full request playback (spans + child calls + DB queries), `req` filters this panel to a single request id. Use this together with panel-9 (which route) and panel-44 (when).',
`{${SERVICE_FILTER}} | json | level=~"warn|error"`,
)
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
const rows = [
// Row 1: 6 stats/gauges × 4 wide × 4 high (full width)
// Row 1: 6 stats/gauges, each 4 wide (4×6=24). Active Users (panel-1)
// and Active Sessions (panel-15) sit side-by-side so on-call can spot
// session-row inflation at a glance (panel-15 climbs while panel-1
// stays flat → Better Auth row leak, not real user growth). WS
// Connections stat is gone (duplicated by Row 3 timeseries panel-13).
row('Service Health', [
item('panel-1', 0, 0, 4, 4),
item('panel-2', 4, 0, 4, 4),
item('panel-15', 4, 0, 4, 4),
item('panel-3', 8, 0, 4, 4),
item('panel-4', 12, 0, 4, 4),
item('panel-5', 16, 0, 4, 4),
item('panel-6', 20, 0, 4, 4),
]),
// Row 2: 2 donuts × 12 wide × 7 high — current-state distribution
// Dropped HTTP-methods donut (low-cardinality, redundant with Row 3 by-method
// timeseries) and HTTP-status donut (2xx dominated, 4xx/5xx already broken
// out in Row 5). Replaced status donut with Top Routes which answers a
// higher-information question with the same visual budget.
// Row 2: 2 donuts × 12 wide × 7 high — current-state distribution.
// Left donut: LLM models (where load is going). Right donut: 5xx by
// route (where failures are concentrated). The previous "Top Routes
// by Requests" donut was replaced because its timeseries form in
// Row 3.5 carries the same data with time context; 5xx-by-route is
// the higher-value glance.
row('Distribution (now)', [
item('panel-8', 0, 0, 12, 7),
item('panel-9', 12, 0, 12, 7),
@@ -694,7 +758,7 @@ const rows = [
item('panel-20', 0, 0, 12, 8),
item('panel-21', 12, 0, 12, 8),
]),
// Row 5: 1 stacked area + 2 stats + 1 timeseries × 7 high
// Row 5: 1 stacked area + 2 stats + 1 timeseries × 7 high.
// Stream Interruptions and ⚠ Flux Unbilled sit next to the 4xx/5xx trend
// so revenue-leak signal (which doesn't show up in 5xx) gets the same
// glance-weight as transport-layer errors.
@@ -704,6 +768,13 @@ const rows = [
item('panel-43', 14, 0, 4, 7),
item('panel-42', 18, 0, 6, 7),
]),
// Row 5.5: 5xx by-route trend full width. Triage path: panel-4
// (something wrong) → panel-9 donut (which route now) → here (when
// it started + per-route rates over time) → panel-91 (actual error
// log lines + clickable trace_id for full request replay in Tempo).
row('5xx Triage', [
item('panel-44', 0, 0, 24, 7),
]),
// Row 6: 1 stat + 1 gauge + 1 donut × 8 wide × 7 high
row('Business', [
item('panel-30', 0, 0, 8, 7),
@@ -719,9 +790,12 @@ const rows = [
item('panel-52', 12, 0, 6, 6),
item('panel-53', 18, 0, 6, 6),
], { collapse: true }),
// Row 8: full-width logs
// Row 8: full-width logs. Two panels stacked: errors-only on top
// (default focus for triage) and the full firehose below (manual
// filter when you need broader context).
row('Logs', [
item('panel-90', 0, 0, 24, 12),
item('panel-91', 0, 0, 24, 10),
item('panel-90', 0, 10, 24, 10),
]),
]
+13 -4
View File
@@ -42,6 +42,7 @@ import { resolveRequestAuth } from './libs/request-auth'
import { sessionMiddleware } from './middlewares/auth'
import { emitOtelLog, initOtel } from './otel'
import { registerActiveSessionsGauge } from './otel/gauges/active-sessions'
import { registerDistinctActiveUsersGauge } from './otel/gauges/distinct-active-users'
import { createAdminFluxGrantsRoutes } from './routes/admin/flux-grants'
import { createAuthRoutes } from './routes/auth'
import { createCharacterRoutes } from './routes/characters'
@@ -527,11 +528,19 @@ export async function createApp() {
userDeletionService,
posthog,
})
// Register the cluster-wide ObservableGauge for active sessions. Each
// replica polls the same DB (cached 10s, in-flight coalesced) and the
// dashboard aggregates with avg(), not sum(). See observability-conventions.md.
if (resolved.otel)
// Register the cluster-wide ObservableGauges for sessions / users. Each
// replica polls the same DB (cached 10s, in-flight coalesced); dashboards
// aggregate with avg(), not sum(). See observability-conventions.md.
//
// Both gauges share the same `session` table: `user.active_sessions` is
// `COUNT(*)` (row inflation prone), `user.distinct_active` is
// `COUNT(DISTINCT user_id)` (real active-user count). Comparing the two
// surfaces session-row leakage from missing GC + per-OIDC-token row
// creation.
if (resolved.otel) {
registerActiveSessionsGauge(resolved.otel.auth.activeSessions, resolved.db, resolved.otel.observability.metricReadErrors)
registerDistinctActiveUsersGauge(resolved.otel.auth.distinctActiveUsers, resolved.db, resolved.otel.observability.metricReadErrors)
}
const { app, injectWebSocket } = await buildApp({
auth: resolved.auth,
@@ -0,0 +1,94 @@
import type { AuthMetrics, ObservabilityMetrics } from '..'
import type { Database } from '../../libs/db'
import { useLogger } from '@guiiai/logg'
import { countDistinct, gt } from 'drizzle-orm'
import { session as sessionTable } from '../../schemas/accounts'
/**
* Wire the `user.distinct_active` ObservableGauge to a Postgres
* `COUNT(DISTINCT user_id)` over the Better Auth session table.
*
* Use when:
* - Assembling DI in `createApp()`, exactly once per process.
*
* Why this exists alongside `registerActiveSessionsGauge`:
* - `user.active_sessions` is `COUNT(*)` — counts session **rows**. Better
* Auth creates a new row per sign-in and per OIDC access-token issuance
* (the `oauth_access_token` table has a FK to `session.id`) and never GCs
* expired rows, so the row-count drifts up over time independently of
* the real user base. We've seen this metric show ~80K on a small
* deployment where the real distinct-user count is ~hundreds.
* - `user.distinct_active` is `COUNT(DISTINCT user_id)` — the actual
* active-user gauge. Pair with `user.active_sessions` to spot session
* inflation: if rows / users ratio climbs past ~5 it's probably time
* to add a session-GC cron or shorten Better Auth's `expiresIn`.
*
* Multi-replica note:
* - Cluster-wide gauge — every replica reads the same DB and reports the
* same value. Dashboards MUST aggregate with `avg()`, NOT `sum()`. See
* observability-conventions.md.
*
* Concurrency:
* - Same in-flight promise lock pattern as `registerActiveSessionsGauge`,
* so concurrent OTel collection cycles fold into one DB query.
*
* Failure mode:
* - DB error → increment `airi.observability.read_errors{metric}` and skip
* `result.observe(...)`. Prometheus staleness exposes the outage instead
* of pinning a stale cached value forever.
*/
export function registerDistinctActiveUsersGauge(
gauge: AuthMetrics['distinctActiveUsers'],
db: Database,
metricReadErrors: ObservabilityMetrics['metricReadErrors'],
) {
const log = useLogger('distinct-active-users-gauge').useGlobalConfig()
const CACHE_TTL_MS = 10_000
let cachedAt = 0
let cachedCount = 0
let refreshInFlight: Promise<boolean> | null = null
async function refresh(): Promise<boolean> {
try {
// Use the app clock (`new Date()`) for the same reason as
// `registerActiveSessionsGauge`: agree with Better Auth's own
// session-validity check, which uses `new Date()` rather than
// `NOW()`.
const rows = await db
.select({ count: countDistinct(sessionTable.userId) })
.from(sessionTable)
.where(gt(sessionTable.expiresAt, new Date()))
cachedCount = Number(rows[0]?.count ?? 0)
cachedAt = Date.now()
return true
}
catch (err) {
log.withError(err).warn('Failed to read distinct active users for gauge')
metricReadErrors.add(1, { metric: 'user.distinct_active' })
return false
}
}
gauge.addCallback(async (result) => {
const now = Date.now()
if (cachedAt !== 0 && now - cachedAt < CACHE_TTL_MS) {
result.observe(cachedCount)
return
}
if (!refreshInFlight) {
refreshInFlight = refresh().finally(() => {
refreshInFlight = null
})
}
const ok = await refreshInFlight
if (ok) {
result.observe(cachedCount)
}
})
}
+20 -1
View File
@@ -44,6 +44,7 @@ import {
METRIC_STRIPE_PAYMENT_FAILED,
METRIC_STRIPE_SUBSCRIPTION_EVENT,
METRIC_USER_ACTIVE_SESSIONS,
METRIC_USER_DISTINCT_ACTIVE,
METRIC_USER_LOGIN,
METRIC_USER_REGISTERED,
METRIC_WS_CONNECTIONS_ACTIVE,
@@ -77,6 +78,21 @@ export interface AuthMetrics {
* "Multi-Replica Considerations".
*/
activeSessions: ObservableGauge
/**
* Pull-based gauge for distinct users with ≥1 non-expired session.
*
* Use when:
* - Querying real "active users" — not session rows. Better Auth creates a
* new `session` row per sign-in and per OIDC token refresh, and never
* GCs expired rows, so {@link AuthMetrics.activeSessions} drifts up
* over time even when the actual user base is small.
*
* Expects:
* - Backed by `SELECT COUNT(DISTINCT user_id) FROM session WHERE expires_at > now()`.
* Same cluster-wide truth as `activeSessions`; dashboards MUST aggregate
* with `avg()`, not `sum()` — see observability-conventions.md.
*/
distinctActiveUsers: ObservableGauge
}
export interface EngagementMetrics {
@@ -225,7 +241,10 @@ export function initOtel(env: Env): OtelInstance | null {
description: 'Number of user sign-ins',
}),
activeSessions: meter.createObservableGauge(METRIC_USER_ACTIVE_SESSIONS, {
description: 'Active user sessions sourced from Postgres (cluster-wide; dashboard must use max(), not sum())',
description: 'Active user sessions sourced from Postgres (cluster-wide; dashboard must use avg(), not sum())',
}),
distinctActiveUsers: meter.createObservableGauge(METRIC_USER_DISTINCT_ACTIVE, {
description: 'Distinct users with ≥1 non-expired session — true active-user count, immune to per-row session inflation (cluster-wide; dashboard must use avg(), not sum())',
}),
}
+5
View File
@@ -37,6 +37,11 @@ export const METRIC_AUTH_FAILURES = 'auth.failures'
export const METRIC_USER_REGISTERED = 'user.registered'
export const METRIC_USER_LOGIN = 'user.login'
export const METRIC_USER_ACTIVE_SESSIONS = 'user.active_sessions'
// Distinct users with at least one non-expired session row. Pair with
// USER_ACTIVE_SESSIONS to detect "session row inflation" (Better Auth
// creates a new row per sign-in / per OIDC token refresh and never GCs)
// vs real user growth.
export const METRIC_USER_DISTINCT_ACTIVE = 'user.distinct_active'
// Engagement (AIRI custom)
export const METRIC_CHAT_MESSAGES = 'chat.messages'
+220
View File
@@ -0,0 +1,220 @@
# Verification 自动化方案
设计稿,未实施。落到这里是为了让 verification 流程从「人工跑命令贴输出」走向「机器跑断言贴 evidence」,同时保留 AGENTS.md 里 Iron Law 的语义。
## TL;DR
1. **原因**:现有 5 份 verification 文档结构清晰,但执行步骤需要人工跑命令、人工贴输出、人工记录「最后验证」日期。一旦超过 30 天,AGENTS.md 规定默认 unverified,没有机制能识别这种过期。
2. **猜想**verification 文档继续作为 single source of truth,每份文档关联一份可执行 artifactartifact 跑通就是 evidence,跑通时间就是「最后验证」。
3. **决策**:分三层实施,集成测试覆盖 in-repo 可重现路径,live verifier 覆盖只能在已部署环境验证的路径,CI 守护过期时间。
## 背景
`apps/server/docs/ai-context/verifications/` 下 5 份文档,结构基本统一:
- `场景 / 用户路径`:写明用户敲 X,预期得到 Y
- `命令 / 步骤`:手工敲的 curl、SQL、UI 操作
- `预期 / 实际输出`:贴 response body、log 节选、screenshot 路径
- `Evidence`:commit SHA、行号引用、测试文件路径
- `Status``最后验证`:人工维护
其中 3 份文档(`flux-unbilled-exploit-fix``flux-unbilled-reconciliation``admin-flux-grants`)引用了已落库的 vitest 单测,剩下 2 份(`email-auth``account-deletion`)以手工 curl + 真实 Resend / 真实数据库为主。
## 拆解现状
把 5 份文档里的步骤按「证据来源」拆开,能看到三类:
1. **纯代码路径**,例如 partial-debit 的数值逻辑、ledger 行写入。这类已经被 vitest 单测覆盖,证据来源是 `expect()` 断言。
2. **跨外部边界的用户路径**,例如「N 个并发 LLM completion 触发 pre-flight 拒绝 + ledger 写入 + metric 上报」。这类需要 pg、redis、Hono app、Prometheus `/metrics` 端点同时在场,目前没有自动化覆盖。
3. **依赖部署环境的路径**,例如 Resend 真实投递、Stripe webhook 回调、Grafana panel 斜率、Better Auth 跨域 OIDC handoff。这类无论在 PR CI 还是本地都无法完整跑通,必须在 staging 或 prod 上验证。
第 1 类已经自动化,第 2、3 类是空缺。
## 提出猜想
verification 文档的「用户路径」描述天然适合作为测试用例标题。如果给每份文档加一份配套 artifact,artifact 类型按上面三类分发:
- 纯代码路径,归到 `*.test.ts`,已经这样做
- 跨边界的用户路径,归到 `*.integration.test.ts`testcontainers 起依赖
- 依赖部署环境的路径,归到 `*.verifier.ts`,针对 staging URL 跑,post-deploy 触发
每份文档头部加一段 frontmatter,机器读取后能回答三个问题:
1. 这份文档对应的 feature 是什么
2. 自动化 artifact 在哪里
3. 上次自动化跑通是什么时候
## 分节解答
### 一、frontmatter schema
```yaml
---
feature: flux-unbilled-exploit-fix
owner: rbxin2003@gmail.com
automated_by:
- kind: unit
path: apps/server/src/services/billing/tests/billing-service.test.ts
cases:
- 'rejects pre-flight when balance is below FLUX_PER_REQUEST'
- 'non-streaming completion drains partial balance and logs charged'
- kind: integration
path: apps/server/tests/verifications/flux-unbilled.integration.test.ts
- kind: live
path: apps/server/tests/verifications/flux-unbilled.verifier.ts
schedule: post-deploy
last_verified:
unit: 2026-05-15
integration: 2026-05-15
live: 2026-05-14
expires_after_days: 30
---
```
字段语义钉死:
- `feature`:文档 slug,与文件名同名
- `automated_by[].kind``unit` / `integration` / `live`,三选一
- `automated_by[].path`:可执行文件路径,CI 跑通后能写回 `last_verified`
- `last_verified.<kind>`YYYY-MM-DD,由 CI 自动写回,人不手动改
- `expires_after_days`:默认 30,与 AGENTS.md 一致
### 二、集成测试 harness
放在每个 app 下的 `tests/verifications/` 目录,例如 `apps/server/tests/verifications/`。harness 提供:
1. testcontainers 起 Postgres 16 + Redis 7,注入与 `.env.example` 同 schema 的环境变量
2. `createApp()` 直接 mount,不走真实端口,调用 `app.request(...)`
3. 三种断言入口:
- HTTP 响应,按现有 `app.test.ts` 范式
- DB 状态,通过 drizzle 查 `flux_transaction` / `user_flux`
- Metric 状态,scrape `/metrics` 文本,匹配 `airi_billing_flux_unbilled_total{...} <value>`
最小测试骨架:
```ts
describe('verification: flux-unbilled-exploit-fix', () => {
let ctx: VerificationContext
beforeAll(async () => {
ctx = await startVerificationContext()
})
afterAll(async () => {
await ctx.stop()
})
it('concurrent partial-balance requests yield one partial debit and N-1 pre-flight 402', async () => {
await ctx.seedUser({ id: 'u1', balance: 5 })
await ctx.setConfig({ FLUX_PER_REQUEST: 100 })
const responses = await Promise.all(
Array.from({ length: 5 }, () => ctx.app.request('/api/v1/openai/...')),
)
expect(responses.filter(r => r.status === 402)).toHaveLength(5)
const ledger = await ctx.db.query.fluxTransaction.findMany({ where: ... })
expect(ledger).toHaveLength(0)
const metrics = await ctx.scrapeMetrics()
expect(metrics).toMatchMetric('airi_billing_flux_unbilled_total', {
labels: { reason: 'partial_debit_drained' },
delta: 0,
})
})
})
```
`MatchMetric``scrapeMetrics` 这两个 helper 放在 `packages/server-runtime``apps/server/src/testing/`,由集成测试和 live verifier 共用。
### 三、live verifier
针对 staging / prod。形态选 vitest 也可以,选独立 CLI 也可以,差别在「是否需要被 CI 用 `--include` pattern 隔离」。建议直接沿用 vitest,给文件后缀 `.verifier.ts`,配 `vitest.config.ts``include` / `exclude` 把它们与 unit / integration 隔离。
live verifier 的断言对象不再是「mount 的 Hono app」,是「真实 URL」:
```ts
describe('live verifier: flux-unbilled-exploit-fix', () => {
it('panel-43 slope is below alert threshold over the last 5 minutes', async () => {
const slope = await prometheusQuery(
'increase(airi_billing_flux_unbilled_total[5m])',
{ url: process.env.PROM_URL! },
)
expect(slope).toBeLessThan(0.5)
})
})
```
需要凭据的项目(Prometheus、Resend、Stripe)通过 env 注入,与 `secrets-management` 规则一致,不写进文件。
### 四、CI 编排
三条 GitHub Actions workflow
1. **`verification-unit.yml`**PR 触发,跑全部 `*.test.ts`。现状已有,作为 baseline。
2. **`verification-integration.yml`**PR 触发,跑全部 `*.integration.test.ts`。预计单跑 60 至 180 秒(testcontainers 启动),用 matrix 拆分到多个 worker。仅在改动触及 `apps/server/**``packages/server-*/**` 时跑,其他改动 skip。
3. **`verification-live.yml`**post-deploy 触发(Railway deploy hook → GitHub repository_dispatch),针对 staging URL 跑全部 `*.verifier.ts`。跑通后自动 PR 一份更新 `last_verified.live` 的提交,或者直接 commit 回 main(按团队偏好选)。
第 2 类必要的 secrettestcontainers 自身不需要 secret,只需要 docker daemonGitHub Actions runner 默认带。第 3 类需要 `PROM_URL``PROM_TOKEN``STRIPE_TEST_KEY``RESEND_API_KEY` 等,放到 GitHub Actions secrets。
### 五、过期守护
新增 `scripts/verification-doctor.ts`,在 `verification-unit.yml` 末尾跑:
```ts
// 遍历所有 verification 文档
// 读 frontmatter.last_verified
// 与 frontmatter.expires_after_days 比较
// 超期 -> stderr 报告 + exit 1
```
CI 失败时输出形如:
```
✗ flux-unbilled-reconciliation: last_verified.integration = 2025-12-01 (expired 165 days)
✗ email-auth: last_verified.live = (none)
```
主分支跑过期检查也跑,跑失败不阻塞 main,只发到 Slack / Lark 通知频道,避免老文档过期把全员卡住。
## 回指前文
回到 TL;DR 的三条决策:
1. 「集成测试覆盖 in-repo 可重现路径」对应第二节,testcontainers + drizzle + metric scrape 是这一层的最小工具集。
2. 「live verifier 覆盖只能在已部署环境验证的路径」对应第三节,针对真实 URL 跑 Prometheus query、Stripe test mode、Resend dashboard API。
3. 「CI 守护过期时间」对应第五节,frontmatter 的 `last_verified` 由 CI 写回,doctor 脚本扫超期。
三层加起来,verification 文档从「人工 claim」变成「机器 claim + 人工 narrative」。
## 影响面
| 维度 | 影响 |
|---|---|
| 单测时间 | 不变 |
| PR CI 时间 | 新增 60 至 180 秒(取决于 testcontainers 并发 + matrix 拆分) |
| 本地开发 | 默认 `pnpm exec vitest run` 不跑 integration,要显式跑 `pnpm verify:integration` |
| docker 依赖 | 本地跑 integration 需要 docker daemon,已有 `docker-compose.otel.yml` 范式 |
| Secret 管理 | live verifier 需要 4 至 6 个 staging secret,放 GitHub Actions secrets |
| 文档维护 | verification 文档新增 frontmatter,原有 markdown 正文不变 |
| AGENTS.md | 加一段「如何写 verification artifact」,引用本文 |
## 可观测性 / eval
实施后用三个指标判断方案有效:
1. **集成测试覆盖率**5 份文档里有几份对应有 `*.integration.test.ts`,目标 100%
2. **live verifier 触发频率**post-deploy 一次必跑,跑失败的次数与生产 incident 的相关性
3. **doctor 报告超期数**:每周扫一次,超期数应当趋近 0
第 3 个指标如果长期不为 0,说明 verification 流程仍需要人工介入太多,要回头看 frontmatter 设计是否合适。
## 收束
这份方案保留 verification 文档的人工 narrativeroot cause、why、tradeoff),把可执行部分挪到代码,把过期检测交给 CI。实施分三步:
1. 先做 frontmatter schema 与 doctor 脚本,零代码改动,立即能识别已有 5 份文档的过期状态。
2. 再做 `flux-unbilled-exploit-fix` 的集成测试样板,跑通一个 case 形成模板。
3. 最后逐份补齐 integration 与 live verifier。
如果某一份文档(例如 `email-auth`)的 live 验证依赖 Resend 真实投递,确认收件状态需要轮询 Resend `/emails` API,这部分实现成本较高,可以推到第三步的尾巴上单独立项。