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

247 lines
8.7 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""One gatherer per panel on the Analytics tab.
Split out of ``analytics_read`` so both stay under the 250-line cap. That
module owns the connection and the shapes; this one owns the six questions,
one function each, mirroring the six query modules exactly so that a panel
and its query are never more than one hop apart.
Every response carries ``window``. The bot is days old, not months, and a
month panel drawn from three days is only honest if the reader can see how
many days it is.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from signal_bot.analytics import (
adoption,
content,
demand,
health,
hours,
sessions,
trend,
usage,
)
from signal_bot.analytics.scope import window
from signal_bot.manage.analytics_read import (
Gathered,
empty,
measure,
names_in,
safely,
)
from signal_bot.stats.periods import today
if TYPE_CHECKING:
import sqlite3
from pathlib import Path
from signal_bot.analytics.scope import Scope
# How far the retention curve is drawn. Two weeks: long enough to show a
# drop-off, short enough that most of it is not empty on a young bot.
RETENTION_DAYS = 14
# The window either side of a release that "what did this change" compares.
RELEASE_SPAN_DAYS = 3
def overview(path: Path, scope: Scope) -> Gathered:
"""Family A: what is used, by whom, and what has never been touched."""
def gather(conn: sqlite3.Connection) -> Gathered:
names = names_in(conn, scope)
uses = usage.by_command(conn, scope)
return {
"window": measure(conn, scope),
"totals": usage.totals(conn, scope),
"commands": [
{
"command": use.command,
"uses": use.uses,
"people": use.people,
"ok": use.ok,
"refused": use.refused,
"errors": use.errors,
"first_day": use.first_day,
"last_day": use.last_day,
}
for use in uses
],
"people": [
{
"uuid": person.uuid,
"name": names.get(person.uuid, ""),
"uses": person.uses,
"commands": person.commands,
"favourite": person.favourite,
}
for person in usage.by_person(conn, scope)
],
"matrix": [
{"uuid": uuid, "name": names.get(uuid, ""), "command": command, "uses": count}
for uuid, command, count in usage.matrix(conn, scope)
],
}
return safely(path, gather) or {**empty("commands", "people", "matrix"), "totals": {}}
def times(path: Path, scope: Scope) -> Gathered:
"""Family B: hours, weekdays, days, months and what has gone quiet."""
def gather(conn: sqlite3.Connection) -> Gathered:
series = trend.dense_days(trend.by_day(conn, scope))
measured = window(conn, scope)
peak = hours.peak(hours.by_hour(conn, scope))
return {
"window": measure(conn, scope),
"hours": hours.by_hour(conn, scope),
"weekday_hours": hours.by_weekday_hour(conn, scope),
"by_command_hour": hours.by_command_hour(conn, scope),
"days": [{"day": day, "uses": uses} for day, uses in series],
"months": [
{"month": month, "uses": uses, "days": days}
for month, uses, days in trend.by_month(conn, scope)
],
"peak": {
"busiest": peak.busiest,
"busiest_uses": peak.busiest_uses,
"quietest": peak.quietest,
"quietest_uses": peak.quietest_uses,
},
"direction": trend.direction(series, measured),
"dying": [
{"command": one.command, "last_day": one.last_day, "idle_days": one.idle_days}
for one in trend.dying(conn, scope, today())
],
}
return safely(path, gather) or empty("hours", "days", "months", "dying")
def wellbeing(path: Path, scope: Scope) -> Gathered:
"""Family C: what is slow, what refuses people, and what upstream does."""
def gather(conn: sqlite3.Connection) -> Gathered:
return {
"window": measure(conn, scope),
"latencies": [
{
"command": one.command,
"samples": one.samples,
"handler_p50": one.handler_p50,
"handler_p95": one.handler_p95,
"send_p50": one.send_p50,
"upstream_p50": one.upstream_p50,
}
for one in health.latencies(conn, scope)
],
"gates": [
{"command": command, "gate": gate, "hits": hits}
for command, gate, hits in health.gates(conn, scope)
],
"errors": [
{"command": command, "error_class": name, "hits": hits}
for command, name, hits in health.errors(conn, scope)
],
"upstream": [
{
"host": one.host,
"calls": one.calls,
"failures": one.failures,
"p50_ms": one.p50_ms,
}
for one in health.upstreams(conn, scope)
],
}
return safely(path, gather) or empty("latencies", "gates", "errors", "upstream")
def wishes(path: Path, scope: Scope, triggers: tuple[str, ...]) -> Gathered:
"""Family D: what people nearly typed, grouped by what they nearly hit."""
def gather(conn: sqlite3.Connection) -> Gathered:
found = demand.collect(conn, scope, triggers)
return {
"window": measure(conn, scope),
"near_misses": [
{"target": miss.target, "hits": miss.hits, "variants": miss.variants}
for miss in found.near_misses
],
"unclaimed": found.unclaimed,
"unclaimed_words": found.unclaimed_words,
"filed": found.filed,
}
return safely(path, gather) or empty("near_misses")
def behaviour(path: Path, scope: Scope, releases: tuple[tuple[str, str], ...]) -> Gathered:
"""Family E: sittings, retention, releases and share of chat."""
def gather(conn: sqlite3.Connection) -> Gathered:
series = trend.dense_days(trend.by_day(conn, scope))
sitting = sessions.collect(conn, scope)
share = adoption.share_of_chat(conn, scope)
return {
"window": measure(conn, scope),
"sessions": {
"count": sitting.count,
"commands": sitting.commands,
"longest": sitting.longest,
"average_length": sitting.average_length,
},
"pairs": [
{"left": left, "right": right, "hits": hits} for left, right, hits in sitting.pairs
],
"repeats": [{"command": command, "extra": extra} for command, extra in sitting.repeats],
"retention": [
{"offset": offset, "active": active, "cohort": cohort}
for offset, active, cohort in adoption.retention(conn, scope, RETENTION_DAYS)
],
"newcomers": [
{"day": day, "people": people} for day, people in adoption.newcomers(conn, scope)
],
"releases": [
{"version": version, "day": day, "after": after, "before": before}
for version, day, after, before in adoption.around_releases(
series, releases, RELEASE_SPAN_DAYS
)
],
"share": {
"messages": share.messages,
"commands": share.commands,
"fraction": share.fraction,
},
}
return safely(path, gather) or empty("pairs", "repeats", "retention", "newcomers", "releases")
def items(path: Path, scope: Scope) -> Gathered:
"""Family F: how varied each pool actually is."""
def gather(conn: sqlite3.Connection) -> Gathered:
return {
"window": measure(conn, scope),
"pools": [
{
"command": pool.command,
"draws": pool.draws,
"distinct": pool.distinct,
"repeat_rate": pool.repeat_rate,
}
for pool in content.pools(conn, scope)
],
"repeats": [
{"command": command, "item_id": item, "draws": draws}
for command, item, draws in content.repeats(conn, scope)
],
}
return safely(path, gather) or empty("pools", "repeats")