M6-05: Statistics in the server¶
Status: ready | Depends on: M2-22, M2-26, M3-22 | Milestone: M6
Goal¶
After this ticket the five statistics operations of openapi.yaml answer from the server's own domain tables, never from the telemetry stack (admin-statistics.md 1, R-OB-08): getUsageStats (messages by origin, new threads, active people, runs per space), getRunStats (runs by end state and duration percentiles per agent), getToolCallStats (tool calls by tool name, failures, median duration), getErrorStats (run failure rate, invocation denials, webhook failure rate, push failure rate) and getCostStats (valid cost sums per billing mode with the estimate flag, by space, agent or none). Every query is authorized with stats.read on the instance or a space, except getCostStats, which is authorized with cost.read.aggregate alone as openapi.yaml declares it, and filtered to the channels authz returns for the caller, so a space manager sees no number of a channel they cannot read; no query groups by person, and the count of active people is shown as "fewer than privacy.stats_min_group_size" below the minimum group (admin-statistics.md 3, DP-T11). Results are cached per caller scope for stats.cache_ttl, ranges are bounded by stats.max_range, and day and week buckets follow stats.time_zone. Why: the console (M6-06) and the desktop app show these numbers for the owner and the space managers, and "statistics as a surveillance tool" is a red team item of M6 (PLAN 12, T-OW-01); the numbers must follow exactly the permissions of the data they count.
Read first¶
docs/observability/admin-statistics.md: all of it (sections 1 to 7; the key figure table of section 2 is the list of sources, section 3 the privacy rules, section 4 the permissions, section 5 performance, cache, range and time zone).docs/spec/17-observability.md: 8 "Statistics in the admin console" and rule 7; 12 (stats.cache_ttl,stats.max_range,stats.time_zone).docs/spec/20-privacy.md: 9 (codetermination, row "Statistics per person"), the tests DP-T11 and DP-T12, the keysprivacy.per_person_stats_enabled,privacy.stats_min_group_size.docs/spec/25-visualization.md: 1 rules 1 to 3 (no values per person, no content), 4 (permissions table and rules 1 to 5; rows without a channel only forstats.readon the instance; costs only withcost.read.aggregate), 6.4 (the executive page reads these operations).docs/spec/05-actions.md(core contract, read only): rowsstats.readandcost.read.aggregate; 2.4 (filter mode).docs/spec/07-permissions.md: 2.6 (manage), 4.2, 7.4 (query pattern withScope).docs/spec/03-data-model.md(core contract, read only):messages(origin,author_principal_id,deleted_at),threads(created_at,done_at),runs(state,state_reason,started_at,finished_at,agent_id,channel_id),run_steps(kind,name,state, durations),cost_entries(valid rows without a successor,billing_mode,is_estimate,currency,space_id),messages.trigger_state_reason,webhook_deliveries(state),push_registrations(disabled_reason,provider),files(size_bytes,scope_channel_id,scan_status).docs/spec/10-backend-policy.md: 12 (costs, estimate and information only in modepersonal).docs/spec/02-architecture.md: 4.3 (rowstats), 4.4 (tiers), 4.5, the module table and its "depends on" column.docs/api/openapi.yaml: operationsgetUsageStats,getRunStats,getToolCallStats,getErrorStats,getCostStats; schemasStatsSeries,StatsBucket,StatsGranularity,CostSeries,CostBucket.docs/DECISIONS.md: D-W-100 and its extension to M5 and M6 (D-W-120); section 10 (format ofD-M6-05-<n>rows); T-OW-01 indocs/security/threat-model.md.- Briefs (merged names win, D-W-100):
M1-28-authz-core.md(Authorizer.Scope,Scope.ChannelIDs,InstanceWide, filter mode),M1-70-leak-tests.md(SQL visibility lint and its exception categories),M2-22-costs.md(cost booking, valid row without a successor),M2-26-webhooks-outgoing.md(webhook_deliveries),M3-22-push-registration.md(disabled_reason),M2-09-run-lifecycle.md(run end states and reasons),M3-03-upload-sessions.mdandM3-04-clamd-scan.md(files and scan state). - Code on the base branch (all under
services/server/):internal/stats(skeleton),internal/messaging,internal/runs,internal/billing,internal/chat,internal/files,internal/apps,internal/notify,internal/authz,architecture/modules.yaml,api/operations.yaml.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md,actions.yaml) is not edited by this ticket. Everything this ticket adds to the repository is English; existing text stays as it is.
Product requirements implemented here:
- Statistics read only the server's own tables through the ports of their owning modules; no query reads the telemetry backend (admin-statistics.md 1, 6).
- Permissions per scope (admin-statistics.md 4, 25 4): instance with
stats.readoninstance(owner), one space withstats.readon that space (manage);getCostStatswithcost.read.aggregateon the instance or the space instead ofstats.read(itsx-authz-action). Withoutspace_idand without owner status:403 forbidden(the operation description says "required without owner role"). An unreadablespace_idgives404(25 11). - Every number counts only channels in the caller's visible set (
Scope), intersected with the requested space; values without a channel (webhook deliveries of apps, push registrations, scans without a channel) are returned only tostats.readon the instance. - No grouping by person, no person ID, no name and no content in any response;
active_peoplebelowprivacy.stats_min_group_sizeis withheld and marked as "fewer than" (Design note 4);privacy.per_person_stats_enabledchanges nothing here (25 rule 2). - Costs in mode
personalare always marked "information only" bybilling_mode = personaland never summed withorganization; estimates keepis_estimate = true(admin-statistics.md 2, 10 12). - A range longer than
stats.max_range, a range with more thanapi.stats_max_bucketsbuckets of the requested granularity (06 configuration table), an empty range orfrom >= tois400 validation_failed; results are valid forstats.cache_ttl; day, week and month buckets start at midnight instats.time_zone.
Scope¶
All paths under services/server/ unless they start with docs/. Every new Go file starts with // SPDX-License-Identifier: Apache-2.0.
Create:
internal/stats/domain/: bucket arithmetic (granularity, time zone, range checks), the minimum group rule, percentile helpers over sorted samples (forduration_ms_p50andp95when a source returns samples), the merge of per-channel aggregates into buckets and groups (Design notes 2 to 5).internal/stats/app/queries.go: one use case per operation (UsageStats,RunStats,ToolCallStats,ErrorStats,CostStats), each: authorize, build the channel set, call the source ports, merge, cache (Design notes 1, 2, 6).internal/stats/app/cache.go: the result cache keyed by operation, parameters, caller scope fingerprint (PermissionVersionand the channel set) and expiring afterstats.cache_ttl(Design note 6).internal/stats/adapters/httpapi/: the five handlers.- Source ports in the owning modules (root packages, implemented in their
appwith sqlc queries inadapters/postgres), each taking(from, to, granularity, tz, channelIDs []ids.ID)and returning aggregates per bucket and group key, never rows:messaging.StatsSource(messages by origin, threads created and done, distinct human authors per space and requested bucket (week or month) as a count, computed insidemessagingfor exactly that bucket, because a monthly distinct count cannot be built from weekly counts, invocation denials fromtrigger_state_reason),runs.StatsSource(runs by end state and agent, durations, tool steps by name with failures and durations,billing_mode_mismatchfailures),billing.StatsSource(valid cost sums by mode, currency, estimate flag, space or agent),files.StatsSource(storage per space, scanserrorandinfectedper day),apps.StatsSource(webhook deliveriesfailedanddeadper app and day),notify.StatsSource(push registrations disabled per provider). Design note 2 lists the exact metrics. - Tests named under "Tests".
Change:
architecture/modules.yaml:stats.depends_ongainsfiles,apps,notify(for theirStatsSourceports);make register-check,make arch;docs/spec/02-architecture.mdmodule table rowstatsand its reason in the same change.api/operations.yaml: enable the five operations; regenerate.docs/api/openapi.yaml(Design notes 2 and 4):getCostStatsgains the optional query parametercurrencyand the error codecurrency_required;StatsBucketgains the optional memberbelow_min_group(array of metric names withheld by the minimum group) and descriptions; the descriptions of the five operations name the grouping keys, the permission filter and the cache;make api-lint,make api-checks,make codegen-check.internal/wire: wiring of the source ports and the use cases in rolesapiandall; configuration keysstats.cache_ttl,stats.max_range,stats.time_zone,api.stats_max_buckets,stats.cache_max_entries(new, 17 11 first),privacy.stats_min_group_size,privacy.per_person_stats_enabledenter the schema with this ticket as their first user (statusplanned (M6)becomes active;make config-reference,make config-doc-check).- Docs (all new text in English, existing text stays as it is):
docs/spec/17-observability.md11 (stats.cache_max_entries),docs/observability/admin-statistics.md(section 2 marks which source port serves each row; section 5 the cache key),internal/stats/README.mdand the READMEs of the modules that gain aStatsSource;services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M6-05-1toD-M6-05-6.
This ticket adds no table, no migration, no action, no event and no top-level Makefile target. It does not write stats_minutes (M6-13).
Out of scope¶
- The own measurement,
stats_minutes, the live frame, timeline, traffic light and backend view (M6-13 to M6-15). - The daily aggregate table
stats_dailyof admin-statistics.md 5 item 3: it is created only when a load test requires it, as an additive migration with a core contract change first; this ticket measures (Tests) and reports. - CSV export of statistics (admin-statistics.md 3 item 4): a later ticket with its own operation; not in
openapi.yamltoday. - The views (M6-06).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M6-05-stats-server; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(stats): ...,feat(runs): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. Logs of this ticket carry no person, channel or space ID. No network calls except package downloads for pinned tools and Go modules.
- Time only through
kernel/clock; tests use a fake clock. - Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M5 and M6 by D-W-120). Where the merged code of an earlier ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Authorization and channel set (25 4; 07 7.4; Ruling for docs/DECISIONS.md). Each use case first calls
authorize(p, stats.read, instance)whenspace_idis absent, elseauthorize(p, stats.read, space);getCostStatsusescost.read.aggregateon the same target instead. A space that the caller cannot read gives404(the decision ofauthorize); readable withoutmanagegives403. ThenScope(ctx, p, message.read)gives the visible channels; withspace_idkeep only the channels of that space (chatport for the channel list of a space). The channel set is passed to every source port; a source never widens it. Rows without a channel are fetched only when the target is the instance and the decision was an owner decision (Scope.InstanceWide). -
Metrics and sources (admin-statistics.md 2; the operation descriptions in
openapi.yamlare the list of names):getUsageStats, group key space ID:messages(count of messages not deleted, by origin as separate metricsmessages_human,messages_agent,messages_app,messages_importand their summessages),threads_created,threads_done,active_people(distinct authors of kind human per space and bucket, only for granularityweekormonth; forhouranddaythe metric is absent because a day of one space names almost a person),runs,storage_bytes(current sum offiles.size_bytesper space, in the last bucket only). Add the names that are not yet in the operation description to it (spec first).getRunStats, group key agent ID:runs,completed,failed,cancelled,duration_ms_p50,duration_ms_p95(fromstarted_attofinished_atof finished runs), and the top failure reasons as metricsfailed_reason_<code>for eachstate_reasoncode that occurs (codes are a closed list of 09 5.2, never free text).getToolCallStats, group key tool name (run_steps.name; names only, 25 rule 3):calls,failed,duration_ms_p50.getErrorStats, group key none (instance) or space:run_failure_rate(failed per finished runs),invocation_denials(messages whosetrigger_state_reasonisbilling_mode_personal_not_holderplus runs failed withbilling_mode_mismatch), and, only for the instance target,webhook_failure_rate(failedanddeadper finished deliveries still within retention),push_failure_rate(registrations disabled for provider reasons per active and disabled registrations in the range, by provider as separate metricspush_failure_rate_<provider>),scan_errorsandscan_infected.getCostStats:CostSerieswith one currency per response: add the optional query parametercurrency(ISO 4217) togetCostStatsinopenapi.yamlfirst; without it, a range whose cost rows have one currency answers in that currency, and a range with several currencies answers400 validation_failedwitherrors[].codecurrency_requiredand the currencies found, so no amounts of different currencies are ever mixed or dropped, buckets perbilling_mode,is_estimatetrue when any contributing row is an estimate. Percentiles are computed in SQL withpercentile_continside the owning module (no sample transfer) when possible; document the choice.
-
Buckets and time zone (admin-statistics.md 5 item 4).
hourbuckets in UTC;day,week(ISO 8601 week starting Monday) andmonthstart at local midnight ofstats.time_zoneand are returned as UTC timestamps of that instant. A bucket that crosses a clock change keeps its local start.fromandtoare aligned down and up to the granularity before the query; the response covers the aligned range. -
Minimum group (admin-statistics.md 3 item 2; DP-T11; Ruling for docs/DECISIONS.md). For
active_peoplea bucket value belowprivacy.stats_min_group_sizeis removed frommetricsand its name is added to the new memberbelow_min_group, so the client shows "fewer thanprivacy.stats_min_group_size" (M6-06); the threshold value is not sent, the client reads it from the instance limits it already has or shows the text without the number. The same rule applies if a later metric counts people. No other metric is affected, because none counts people. -
No people, no content (25 1; SEC-VZ-3). The source ports return counts and sums only; no person ID leaves a module,
active_peopleis counted withCOUNT(DISTINCT author_principal_id)insidemessaging. Group keys are space IDs, agent IDs or tool names; never thread titles, message text or file names. -
Cache (admin-statistics.md 5 item 2; Ruling for docs/DECISIONS.md). An in-memory cache per api process, keyed by operation, normalized parameters, the caller's
PermissionVersionand a hash of the channel set, value expiring afterstats.cache_ttlby the clock, bounded by the new configuration keystats.cache_max_entries(add it first to 17 11 anddocs/operations/configuration.mdwith a default and its reason; arbitrary ranges can produce unbounded distinct keys), evicting the least recently used entry when full. A permission change gives a newPermissionVersionand therefore a cache miss, so a revoked manager never sees cached numbers of their old scope. -
Visibility lint and indexes (07 7.4; M1-70). Every new query that reads a table with channel visibility takes
channel_id = ANY($n)from the passed set; where the SQL visibility lint of M1-70 flags an aggregate query, add one exception with categorysystem_aggregateonly if the query is filtered by the passed set in another way, with a reason; never widen an existing entry. Use the indexes admin-statistics.md 5 item 1 names; ifEXPLAINof a query overstats.max_rangeon the load fixture (Tests) shows a sequential scan of a large table, add the index in a migration of the owning module and record it. -
Rulings for
docs/DECISIONS.md(section 10):D-M6-05-1authorization and channel set per query (note 1);D-M6-05-2the metric names and sources (note 2);D-M6-05-3active_peopleonly for week and month (note 2);D-M6-05-4below_min_group(note 4);D-M6-05-5the cache key with the permission version (note 6);D-M6-05-6the new module edges ofstats(Scope).
Tests (write them first)¶
Test world: 07 10 and 11.1 (space Support with channels Allgemein and Eskalation; space Engineering with channel server; Sam with manage on Support, Robin with read on Support/Allgemein only; the Owner Alex; agents support-agent and dev-runner). Data written through the use cases of the owning modules where they exist (messages, runs, steps, costs), as fixture rows through pgtest otherwise (webhook deliveries, push registrations, scans), spread over two days around a clock change of Europe/Berlin for the time zone tests.
Unit, internal/stats/domain:
TestBuckets: hour, day, week, month alignment inUTCand inEurope/Berlinacross both clock changes; a range overstats.max_range, a range with more thanapi.stats_max_bucketsbuckets and an empty range fail.TestMinGroup: values below, at and aboveprivacy.stats_min_group_size.TestMerge: per-channel aggregates of two spaces merge into the right group keys; channels outside the set are dropped even if a fake source returns them (defence in depth).
Integration (pgtest, full chain through internal/wire):
TestStats_SpaceManagerSeesOnlyOwnChannels(PLAN row, VZ-T05 in spirit): Sam withspace_id = Supportgets numbers of Allgemein and Eskalation only; withspace_id = Engineering404; withoutspace_id403; a message in Engineering never changes Sam's numbers.TestStats_ReadOnlyMemberIsForbidden: Robin (readwithoutmanage) gets403on Support.TestStats_OwnerInstanceWide: Alex withoutspace_idgets all spaces and the instance-only metrics (webhook, push, scans); Sam never gets them.TestStats_MinGroup(DP-T11): withprivacy.stats_min_group_sizeread from the test configuration asn,n - 1distinct authors in a week give noactive_peopleandbelow_min_group: ["active_people"];ngive the number;privacy.per_person_stats_enabled = truechanges nothing; no response of any operation contains a person ID (scan the JSON for every principal ID of the world).TestStats_NoPersonGrouping: no operation accepts a grouping by person; an unknown query member is400(closed request schema of M1-18).TestStats_Costs:organizationandpersonalstay separate buckets; an estimate row marks the bucket; a superseded cost row (with a successor) is not counted; Sam (manager of Support, who holdscost.read.aggregatethere by 05) sees the costs of Support only; Robin gets403. The path "nocost.read.aggregate" cannot be reached by a person withstats.readtoday (05 gives both to owners and managers); prove it in a unit test of the use case with a stubbed decision.TestStats_RunsAndTools: end states, percentiles against a hand-computed fixture, failure reason metrics only for codes that occur; tool names appear, step details never.TestStats_ErrorRates: run failure rate, invocation denials from both sources, webhook and push rates for the owner only.TestStats_CacheRespectsPermissionVersion: Sam's result is cached; removing Sam'smanagegives403on the next call withinstats.cache_ttl; a new message within the TTL does not change the cached result, after the TTL it does.TestStats_TimeZone: daily buckets ofEurope/Berlinstart at local midnight and the day of the clock change has 23 or 25 hours of data.TestStats_Range:400for a range overstats.max_range, for more thanapi.stats_max_bucketsbuckets (for examplehourover many weeks) and forfrom >= to.- Load fixture (not part of the default run, behind the
perfbuild tag used bymake perf): 10 channels, 10 agents and one million messages overstats.max_range; each operation answers within the budget you measure and document in the report;EXPLAINshows index use (Design note 7).
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/stats/... ./internal/messaging/... ./internal/runs/... ./internal/billing/... ./internal/files/... ./internal/apps/... ./internal/notify/... ./internal/wire/...make codegen-check,make api-lint,make api-checks,make arch,make register-check,make config-doc-checkmake docs,make linkcheckmake check
Acceptance criteria¶
- The five operations answer from the domain tables through the source ports of the owning modules, authorized with
stats.readorcost.read.aggregateon the instance or a space. - Space managers see no numbers of other channels; values without a channel only for the owner.
-
active_peoplebelowprivacy.stats_min_group_sizeis withheld and listed inbelow_min_group; no response groups by person or names a person (DP-T11). - Costs separate the modes and mark estimates; ranges, buckets and time zone follow
stats.max_rangeandstats.time_zone; the cache followsstats.cache_ttland the permission version. -
openapi.yaml, 02, admin-statistics.md,modules.yaml, the READMEs, the CHANGELOG and the rowsD-M6-05-1toD-M6-05-6are updated. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
statsand of every module with a newStatsSourceupdated in English. - Rulings of Design note 8 recorded in
docs/DECISIONS.md. services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- The measured answer times of the load fixture and whether
stats_dailywould be needed. - What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.