signal-bot/signal_bot/analytics_command.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

97 lines
3.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""!analityka -- what the group has actually been asking the bot for.
Registered here rather than in ``stats/commands.py``, which is a few lines
under the 250 cap already, and because this reads a different table:
``stats`` is the *message* history, this is the *command* history.
The reply is text only, in Polish, capped at twelve lines. The charts live
on the management page; a rendered image in the chat would mean a new
dependency and, per ``DOCS-latency.md``, would turn a 250 ms text reply into
a three-second one for a picture nobody can zoom into.
Like ``!pomoc``, the registry is read when the command *runs* rather than
when it is registered -- at registration time the album's commands do not
exist yet, and those are exactly the triggers a near miss might be against.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from signal_bot.analytics import adoption, demand, hours, render, trend, usage
from signal_bot.analytics.scope import Scope, window
from signal_bot.animals import load_aliases
if TYPE_CHECKING:
from collections.abc import Sequence
from signal_bot.context import CommandContext
from signal_bot.decorators import Decorator
from signal_bot.registry import Command, CommandRegistry, Handler
COMMAND_NAME: Final = "command_stats"
DESCRIPTION: Final = "kto ile używa bota i które komendy żyją"
def triggers_for(commands: Sequence[Command], group_id: str) -> tuple[str, ...]:
"""Every trigger this group could plausibly have meant.
Scoped to the group, and that scoping is the point: the album's triggers
are real people's names and exist only where the album does. Reporting a
near miss against one outside its group would announce that the album
exists, which is the same leak the dispatcher refuses at resolution.
"""
return tuple(
trigger
for command in commands
if command.allowed_in(group_id)
for trigger in command.triggers
)
def summarise(ctx: CommandContext, commands: Sequence[Command]) -> render.Summary:
"""Gather every number the reply needs, from the group that asked.
Scoped to one group like every other statistic here: a test group's
numbers must never turn up in the production group's answer.
"""
conn = ctx.store.commands.connection
scope = Scope(ctx.group_id)
measured = window(conn, scope)
series = trend.dense_days(trend.by_day(conn, scope))
share = adoption.share_of_chat(conn, scope)
return render.Summary(
measured=measured,
uses=usage.by_command(conn, scope),
people=usage.by_person(conn, scope),
# Only people the bot has actually seen a name for. Everyone else is
# counted and left unnamed: the roster knows them as a raw uuid or a
# phone number, and printing either into a chat is a leak.
names={
person.uuid: person.display_name for person in ctx.store.events.people(ctx.group_id)
},
peak=hours.peak(hours.by_hour(conn, scope)),
direction=trend.direction(series, measured),
demand=demand.collect(conn, scope, triggers_for(commands, ctx.group_id)),
asker=ctx.message.source_uuid,
commands=share.commands,
messages=share.messages,
)
def register_analytics_command(
registry: CommandRegistry,
*,
decorate: Decorator | None = None,
aliases: dict[str, list[str]] | None = None,
) -> None:
"""Register !analityka under every trigger aliases.json gives it."""
table = load_aliases() if aliases is None else aliases
async def handler(ctx: CommandContext) -> None:
await ctx.reply(render.render(summarise(ctx, registry.commands)))
wrapped: Handler = decorate(handler) if decorate else handler
registry.register(COMMAND_NAME, table[COMMAND_NAME], wrapped, DESCRIPTION)