One row per command attempt, not two aggregates written only on success. command_uses holds last-use only and daily_uses counts successes; both are rate-limiter state and both are written by store.record_use, which the dispatcher calls only when a handler returns cleanly. A gate refusal and a crash returned before it, so a command that failed every time looked exactly like one nobody ran, and nothing knew which command a counted message was. command_events now takes every attempt with its outcome, its duration and the item it served; upstream_calls takes every outbound request made while serving one. The two writes stay separate on purpose -- recording a refusal as a use is what slid the cooldown window forward every time somebody was told to wait -- and test_dispatcher_outcomes asserts last_used does not move. Backfilled from journald joined against message_events, with daily_uses filling the residual so the two sources cannot double-count. Two things were wrong on the first attempt and both were found by measuring rather than by reasoning: the window has to be symmetric, because sent_at_ms comes from the sender's phone and the journal stamp from this host (194 of 299 lines landed *before* their own message, by up to 3.2 s); and matching has to be a global closest-pair assignment, because per-row greedy cannot separate two commands seconds apart. Together those took the dropped rows from 137 to zero, and recovered 418 invocations back to 29 August. Also: !analityka in the group, twelve Polish lines, naming at most three people and only ones whose display name has been seen; an Analytics tab with Nivo charts; and continuous latency measurement, which answers the open question in DOCS-latency.md -- a text send is 235 ms and an image send about 2.2 s, so the upload really is where the time goes. EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a schema bump rebuilds, and what leaving a group clears. daily_uses left the first (it is a backfill source now) and stayed in the second, and _drop_reshaped took over the repair a bump used to do for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
7.7 KiB
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_usesupserts one row per (group, user, command) holding last use only. It is cooldown state.daily_usescounts 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.pylogs 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
.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.