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

145 lines
3.9 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Building a command history to ask questions of.
Everything is arithmetic -- no randomness, no clock -- so a failure names a
row rather than a seed. The people are invented (ala, basia, celina, darek)
and so are the group ids: ``check_personal_data`` learns real names from the
live database, so a real one here would fail only on the maintainer's
machine and pass in CI.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from signal_bot.analytics.backfill import midnight_ms
from signal_bot.analytics.probe import UpstreamCall
from signal_bot.analytics.uses import (
OUTCOME_OK,
SOURCE_DAILY,
SOURCE_JOURNAL,
SOURCE_LIVE,
CommandEvent,
)
from signal_bot.stats.backfill import UNKNOWN_HOUR
from signal_bot.stats.periods import weekday_of
if TYPE_CHECKING:
from collections.abc import Sequence
from signal_bot.store import Store
GROUP = "group-a=="
OTHER = "group-b=="
ALA = "uuid-ala"
BASIA = "uuid-basia"
CELINA = "uuid-celina"
HOUR_MS = 3_600_000
def at(day: str, hour: int, minute: int = 0) -> int:
"""A Warsaw wall-clock moment on one day, in epoch milliseconds."""
return midnight_ms(day) + hour * HOUR_MS + minute * 60_000
@dataclass(frozen=True, slots=True)
class Shape:
"""Everything about one attempt except who ran what, and when.
Grouped rather than passed as arguments, for the reason the repository
caps arguments at seven: these are the columns a given test usually
leaves entirely alone, and a call site that names three of them reads
better than one that names all eleven.
"""
group_id: str = GROUP
outcome: str = OUTCOME_OK
source: str = SOURCE_LIVE
item_id: str | None = None
handler_ms: int | None = None
send_ms: int | None = None
error_class: str = ""
args: str = ""
calls: Sequence[UpstreamCall] = ()
# A module-level singleton, because Shape is frozen and a default argument
# that is a call is evaluated once anyway -- naming it says so.
PLAIN = Shape()
def ran(
store: Store,
*,
day: str,
hour: int = 12,
minute: int = 0,
who: str = ALA,
command: str = "cat",
shape: Shape = PLAIN,
) -> int:
"""Record one attempt and return the timestamp it was keyed on."""
invoked_at_ms = at(day, hour, minute)
store.commands.record(
CommandEvent(
group_id=shape.group_id,
user_uuid=who,
command=command,
args=shape.args,
item_id=shape.item_id,
outcome=shape.outcome,
error_class=shape.error_class,
invoked_at_ms=invoked_at_ms,
day=day,
local_hour=UNKNOWN_HOUR if shape.source == SOURCE_DAILY else hour,
weekday=weekday_of(day),
handler_ms=shape.handler_ms,
send_ms=shape.send_ms,
source=shape.source,
calls=shape.calls,
)
)
return invoked_at_ms
def backfilled(
store: Store, *, day: str, command: str = "cat", who: str = ALA, group_id: str = GROUP
) -> None:
"""One row from the source that knows a day and nothing finer."""
ran(
store,
day=day,
command=command,
who=who,
hour=0,
shape=Shape(group_id=group_id, source=SOURCE_DAILY),
)
def reconstructed(
store: Store,
*,
day: str,
hour: int,
command: str = "cat",
who: str = ALA,
group_id: str = GROUP,
) -> None:
"""One row recovered from the journal: a real hour, but no durations."""
ran(
store,
day=day,
hour=hour,
command=command,
who=who,
shape=Shape(group_id=group_id, source=SOURCE_JOURNAL),
)
def a_call(host: str = "api.example.test", status: int = 200, ms: int = 40) -> UpstreamCall:
"""One outbound request made while a command was running."""
return UpstreamCall(host=host, status=status, ms=ms)