signal-bot/DOCS-analytics.md
Krzysztof kuhy Rudnicki 405541be9b
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
release: 0.2.6 «Stephanus I» — dodano !analityka
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
2026-08-31 23:19:51 +02:00

7.7 KiB
Raw Blame History

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

.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.