mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:17:02 +01:00
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
164 lines
7.7 KiB
Markdown
164 lines
7.7 KiB
Markdown
# 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.
|