Add / Change a Stats Dimension
The stats pipeline has one write implementation but several coupled registries. Missing any step below produces silent data loss or drift — none of it fails loudly. Work through the checklist in order.
1. Schema (single source of truth)
- Add the
CREATE TABLE/CREATE INDEXstatements toapps/collector/src/database/schema.ts(SCHEMA+INDEXES). Nothing else creates tables. - Existing databases only get
IF NOT EXISTSDDL from schema.ts. Column additions and index drops need an explicit migration inapps/collector/src/modules/db/db.tsinit()(see theALTER TABLE ... ADD COLUMNandDROP INDEX IF EXISTSprecedents there). - Do NOT add expression indexes on
(total_download + total_upload)— hot queries filter bybackend_idfirst, so the planner never uses them (verified with EXPLAIN QUERY PLAN); they only amplify writes.
2. Write path (exactly one)
- All traffic writes go through
batchUpdateTrafficStatsinapps/collector/src/database/repositories/traffic-writer.repository.ts.updateTrafficStatsis a thin wrapper over it — never add a second write implementation. - Add your aggregation map in the batch loop and the upsert in the appropriate transaction group. The whole flush is one outer transaction; keep it that way (a partial commit + buffer retry double-counts).
- CSV-style association columns (
ips,rules,chains,domains) must dedupe with the delimiter-aware pattern — bareINSTR(list, value)treatsa.comas present whenaa.comis:
INSTR(',' || col || ',', ',' || @value || ',') > 0
- Respect
reduceWritessemantics: detail tables are skipped when ClickHouse is the read source (shouldReduceSqliteWritesinmodules/stats/stats-write-mode.ts). Decide whether your table is "core" (always written) or "detail" (skippable) and place it in the matching branch.
3. Rule naming contract (if the dimension involves rules)
- Rule stats key on the top-level policy group (last chain hop) for multi-hop chains —
buildRuleNameinsrc/shared/utils/rule-name.tsis the only place this logic lives. The frontend matches these names against gateway rule targets (rule.proxy), so changing the key breaks the flow graph and rule matching (this regressed once in v1.3.9; seedocs/dev/deep-review-2026-07.md).
4. Retention
- Register cleanup in
apps/collector/src/modules/cleanup/cleanup.service.ts+config.repository.ts. Minute-level tables followconnectionLogsDays(default 7d); hourly tables followhourlyStatsDays(default 30d).hourly_dim_statsbacks all >6h range queries — never tie it to the minute cutoff. - Cumulative
*_statstables currently have no retention; if your table is a cross-product (rule×ip style), consider alast_seen-based cap from day one.
5. Backend deletion
- Add the table to the explicit delete list in
deleteBackendData(apps/collector/src/database/repositories/backend.repository.ts).PRAGMA foreign_keysis OFF, so the declaredON DELETE CASCADEnever fires — forgetting this leaves orphan rows forever.
6. ClickHouse parity (if the data is queryable over ranges)
- Writer:
apps/collector/src/modules/clickhouse/clickhouse.writer.ts - Reader:
apps/collector/src/modules/clickhouse/clickhouse.reader.ts— the SQLite and CH implementations of the same query must return the same shape and semantics (time format, windows). Routing lives inmodules/stats/stats.service.ts(*WithRoutingmethods).
7. Types and API
- Shared response types go in
packages/shared(both apps consume them; never fork the type locally). - Time keys are UTC ISO slices: minute
YYYY-MM-DDTHH:MM:00, hourYYYY-MM-DDTHH:00:00(toMinuteKey/toHourKeyinbase.repository.ts). Don't introduce local-time keys.
8. Tests
- Add a Vitest case in
apps/collectorexercising the batch write + read-back (seetraffic-writer.test.tsfor patterns,src/__tests__/helpers.tsfor DB setup), then run theverify-changesskill.