signal-bot/tests/test_analytics_journal.py
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

92 lines
3.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Reading the bot's own journal back, without copying names out of it."""
from __future__ import annotations
from signal_bot.analytics.journal import parse
PREFIX = "2026-08-31T21:28:26+02:00 archlinux run.sh[101862]:"
STAMP = "2026-08-31 21:28:26,217"
def running(command: str, who: str = "ala") -> str:
"""One real journal line, in the shape journalctl -o short-iso emits."""
return f"{PREFIX} {STAMP} INFO signal_bot.dispatcher: running command {command} for {who}"
def test_a_running_line_is_read() -> None:
(line,) = parse([running("cat")])
assert (line.command, line.display_name, line.refused) == ("cat", "ala", False)
def test_the_timestamp_comes_from_the_journal_not_the_bot() -> None:
"""The journal's stamp carries an offset; the bot's asctime does not.
A naive stamp read as UTC lands the whole import two hours out, which is
exactly what the hour columns exist to get right.
"""
(line,) = parse([running("cat")])
# 2026-08-31T21:28:26+02:00 is 19:28:26 UTC.
assert line.at_ms == 1_788_204_506_000
def test_an_album_command_keeps_its_pseudonym_key() -> None:
"""`pack:p1`, not `pack`. A \\w+ pattern silently truncates at the colon.
That truncation would collapse thirteen different album commands into
one, which is the difference between "who is asked for most" and a
single meaningless total.
"""
(line,) = parse([running("pack:p1")])
assert line.command == "pack:p1"
def test_a_cooldown_refusal_is_read_as_one() -> None:
text = (
f"{PREFIX} {STAMP} INFO signal_bot.decorators: "
"cooldown refused cat for ala: told them 8 sekund"
)
(line,) = parse([text])
assert (line.command, line.refused) == ("cat", True)
def test_a_display_name_with_spaces_survives() -> None:
"""The pattern runs to end of line, because a display name is free text."""
(line,) = parse([running("cat", "ala z basia")])
assert line.display_name == "ala z basia"
def test_lines_that_are_not_about_commands_are_skipped() -> None:
"""Startup banners, tracebacks, envelope shapes, other units."""
noise = [
"2026-08-31T21:00:00+02:00 archlinux systemd[1474]: Started Signal group bot.",
f"{PREFIX} {STAMP} DEBUG signal_bot.dispatcher: envelope shape: {{'a': 'str'}}",
"not a journal line at all",
"",
]
assert list(parse(noise)) == []
def test_a_line_whose_stamp_is_not_a_timestamp_is_skipped() -> None:
"""Never guess a time. A wrong hour is worse than a missing row."""
assert list(parse(["wednesday archlinux run.sh[1]: running command cat for ala"])) == []
def test_a_naive_stamp_is_refused() -> None:
"""Without an offset there is no way to know which hour this was."""
text = "2026-08-31T21:28:26 archlinux run.sh[1]: running command cat for ala"
assert list(parse([text])) == []
def test_a_dst_boundary_is_read_from_the_offset() -> None:
"""Warsaw is +02:00 in summer and +01:00 in winter, and both occur.
Reading the wall clock and assuming one offset puts half the year's
rows an hour out, in a column whose whole purpose is the hour.
"""
summer = "2026-10-25T02:30:00+02:00 archlinux run.sh[1]: running command cat for ala"
winter = "2026-10-25T02:30:00+01:00 archlinux run.sh[1]: running command cat for ala"
(early,) = parse([summer])
(late,) = parse([winter])
assert late.at_ms - early.at_ms == 3_600_000