# Command analytics `message_events` answers *who said how much*. This answers *what they asked the bot for, when, and whether it worked* — a different table, a different package, and one that did not exist before 0.2.6. ## Why it needed a new table The old record was two aggregates, and neither was a history. - `command_uses` upserts one row per (group, user, command) holding **last use only**. It is cooldown state. - `daily_uses` counts uses per day. It is daily-limit state. Both are written by `store.record_use`, which the dispatcher calls **only on success**. A gate refusal returned before it and a handler crash returned before it, so a command that failed every single time was indistinguishable from one nobody ran. There was no hour, no duration, and no idea which command a counted message had been. `command_events` takes one row per **attempt**: outcome, duration, the item served, and the hour it happened in. `upstream_calls` takes one row per outbound HTTP request made while serving it. ## The two writes, and why they may never be merged ``` store.commands.record(...) every attempt analytics store.record_use(...) successes only cooldown + daily limit ``` `CommandRefusedError`'s docstring records the bug that made the split necessary: recording a refused use slid the cooldown window forward every time somebody was told to wait, so waiting the stated time never helped. Now that the history takes refusals, unifying the two calls looks like an obvious simplification. `test_dispatcher_recording` fires a refused invocation and asserts both that the history grew **and** that `last_used` did not move. That test is the whole guard. ## What is never stored | | | |---|---| | the words after a command | `args` holds a registry-resolved pack key (`p1`, `add`, `random`) or `""` / `"+args"`. A user-typed column is the shape that put real names in this repository once. | | an exception's message | `error_class` is a class name. `str(error)` carries whatever was being processed, and here that is somebody's message. | | a URL | `upstream_calls.host` is a hostname. A URL carries identifiers, and identifiers belong in `item_id` where a decision governs them. | | a title or a caption | `item_id` is a QID, a Commons page id, a Wolne Lektury slug or a content hash. An identifier cannot carry a name; free text eventually will. | ## Three kinds of row `source` says where a row came from, and the three are not interchangeable. | `source` | group & person | hour | duration | outcome | |---|---|---|---|---| | `live` | observed | observed | observed | observed | | `journald` | observed | observed | **none** | **inferred** | | `daily_uses` | observed | **none** (`-1`) | **none** | assumed `ok` | `scope.py` is the only place that names the filters, so "is the exclusion still there?" is one grep rather than six independent remembers. Hour and weekday panels take `local_hour >= 0`; latency panels take `handler_ms IS NOT NULL`; sessions and co-occurrence take a real hour, because a `daily_uses` timestamp is built from midnight and treating those as a sequence invents a sitting of forty commands in forty milliseconds. Durations are `NULL`, never `0`. A zero reads as data and would drag a p50 down — the same lesson `stats/backfill.UNKNOWN_HOUR` already records. ## The backfill `tool/backfill_analytics.py` recovers the history that predates the table. Run it against a **copy** first. It is insert-only and re-runnable; there is no delete-then-rebuild pass, because journald has no retention configuration and a second run after the log had rotated would delete rows it could no longer replace. Two sources that overlap, which is the whole difficulty — summing them would roughly double every historical count and nothing would fail: - **journald** knows which command ran and when, but has no group and no uuid: `dispatcher.py` logs a display name, because the line was written to be read by a human. - **daily_uses** knows the group, the uuid and the day, and counts correctly, but has no times of day at all. So the join runs **over `message_events`**, which already holds the group, the uuid, the real hour and the display name of every counted command, and lacks only the command's name — which is exactly what journald has. Then `daily_uses` fills the **residual**: `max(0, uses − matched)` per (group, user, command, day). Nothing is counted twice. ### Two things that were wrong on the first attempt **The window is symmetric.** The log line is written strictly after the message arrives, so `[0, +15 s]` looks obviously right. Measured against the live database: of 299 counted commands, **194 had a journal line earlier than their own `sent_at_ms`**, by up to 3.2 seconds. The two stamps come from two different clocks — `sent_at_ms` is set on the *sender's phone*, the journal stamp is this host's — so their difference is skew and has either sign. The asymmetric window dropped 47% of the data. **Matching is a global assignment, not per-row.** Two commands from one person seconds apart put both log lines inside both windows; picking best-for-each-row in turn cannot tell them apart and drops both. Closest-pair first, globally, can: the skew is systematic per sender, so the true pairs are the two smallest distances available. On the live database that took the drops from 137 to **zero**. ### One inaccuracy, stated rather than hidden `dispatcher.py` logs `running command X` **before** the handler runs, so a journald row proves the command was *entered*, not that it finished. A handler that then crashed, or a `daily_limit`/`vote_gated` refusal — neither of which logged anything — is imported as `ok`. Measured on 2026-08-31 the gap is about 4%. Only `live` rows have outcomes that were actually observed, and the page says which is which. ## `!analityka` Text only, Polish, twelve lines as a cap rather than a quota — a line with nothing to say is dropped, not printed empty. No padding, because Signal renders a proportional font. It names at most three people, and only ones whose display name the bot has seen; everyone else is counted and left unnamed, exactly as the nightly report does. Full per-person matrices are on the management page. The trend verdict is suppressed below 14 days: the series is drawn, the direction is not claimed. ## The Analytics tab `./web.sh`, then the first tab. Seven read-only routes under `/api/analytics`, each taking an optional `?group=`. Absent means the all-groups roll-up, and it runs the *same SQL* — every query is a complete literal string with `WHERE (:group IS NULL OR group_id = :group)`, so the roll-up and the per-group answer cannot drift. That spelling is also what keeps ruff's S608 quiet without a suppression. Charts are Nivo 0.99.0, always the **fixed-size** components rather than the `Responsive*` ones: those measure their container, a jsdom container has no size, and a suite full of charts would otherwise assert against empty divs. `charts/size.ts` supplies a real measurement in a browser and a stated fallback everywhere else. Colours are `@kuhyx/web-ui` tokens, not a Nivo scheme. ## Verifying a change ```sh .venv/bin/python -m pytest -q # 100%, no omits ./scripts/e2e.sh # incl. analytics_command.yaml pnpm --dir web lint && pnpm --dir web coverage # 100% on all four metrics pre-commit run --all-files # the backfill, against a copy, never the live file cp signal_bot.sqlite3 /tmp/check.sqlite3 journalctl --user -u signal-bot --no-pager -o short-iso > /tmp/bot.log python tool/backfill_analytics.py --db /tmp/check.sqlite3 --log /tmp/bot.log --dry-run ``` CI runs `pnpm lint` and `pnpm coverage` but **never `pnpm build`**, so a bundling or CSP break is invisible to every automated gate. Building and opening the page is a required manual step, not an optional one.