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

147 lines
5.0 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Family E, the adoption half: who came back, and what a release did.
Three questions that are about the bot rather than about any one command.
* **Retention** -- of the people who used it on their first day, how many
were still using it N days later. On a history this short this is one or
two points, and the panel says so rather than drawing a curve.
* **Novelty** -- what a release actually did. ``DOCS-changelog.md`` is the
only record of when each version shipped and it is the one the maintainer
actually keeps, so the dates come from there rather than from git tags.
* **Share of chat** -- what fraction of everything said in the group was a
command. This is the only query in the package that reads
``message_events``, and it is the number that says whether the bot is a
fixture or a curiosity.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
from signal_bot.stats.periods import shift
if TYPE_CHECKING:
import sqlite3
from collections.abc import Sequence
from signal_bot.analytics.scope import Scope
FIRST_DAY_SQL: Final = """
SELECT user_uuid, MIN(day) AS first_day
FROM command_events
WHERE (:group IS NULL OR group_id = :group)
AND outcome = 'ok'
GROUP BY user_uuid
"""
ACTIVE_DAYS_SQL: Final = """
SELECT DISTINCT user_uuid, day
FROM command_events
WHERE (:group IS NULL OR group_id = :group)
AND outcome = 'ok'
"""
SHARE_SQL: Final = """
SELECT COUNT(*) AS messages, SUM(is_command) AS commands
FROM message_events
WHERE (:group IS NULL OR group_id = :group)
AND local_hour >= 0
"""
NEW_PEOPLE_SQL: Final = """
SELECT day, COUNT(*) AS people
FROM (
SELECT user_uuid, MIN(day) AS day
FROM command_events
WHERE (:group IS NULL OR group_id = :group)
AND outcome = 'ok'
GROUP BY user_uuid
)
GROUP BY day
ORDER BY day
"""
@dataclass(frozen=True, slots=True)
class Share:
"""How much of the group's talking is people talking to the bot."""
messages: int = 0
commands: int = 0
@property
def fraction(self) -> float:
"""Commands as a share of every counted message."""
return self.commands / self.messages if self.messages else 0.0
def retention(conn: sqlite3.Connection, scope: Scope, horizon: int) -> list[tuple[int, int, int]]:
"""(day offset, still active, cohort size) for offsets 0..horizon.
Days are stepped as dates, never as 86_400_000 milliseconds: Warsaw days
are not all the same length and the arithmetic version drifts an hour
twice a year, which would move somebody between buckets.
"""
firsts = {
str(row["user_uuid"]): str(row["first_day"])
for row in conn.execute(FIRST_DAY_SQL, scope.params())
}
if not firsts:
return []
active: set[tuple[str, str]] = {
(str(row["user_uuid"]), str(row["day"]))
for row in conn.execute(ACTIVE_DAYS_SQL, scope.params())
}
cohort = len(firsts)
return [
(
offset,
sum(1 for uuid, first in firsts.items() if (uuid, shift(first, offset)) in active),
cohort,
)
for offset in range(horizon + 1)
]
def newcomers(conn: sqlite3.Connection, scope: Scope) -> list[tuple[str, int]]:
"""(day, how many people used the bot for the first time)."""
return [
(str(row["day"]), int(row["people"]))
for row in conn.execute(NEW_PEOPLE_SQL, scope.params())
]
def share_of_chat(conn: sqlite3.Connection, scope: Scope) -> Share:
"""What fraction of the group's messages were commands.
Excludes the synthetic rows seeded from the pre-0.1.0 counter, which
carry UNKNOWN_HOUR and no is_command flag -- counting them would put a
whole day's messages in the denominator against no commands at all.
"""
# An aggregate SELECT returns one row even against an empty table, with
# NULLs in it -- so there is no None to guard against, only the NULLs.
row = conn.execute(SHARE_SQL, scope.params()).fetchone()
return Share(messages=int(row["messages"] or 0), commands=int(row["commands"] or 0))
def around_releases(
series: Sequence[tuple[str, int]], releases: Sequence[tuple[str, str]], span: int
) -> list[tuple[str, str, int, int]]:
"""(version, day, uses just after, uses just before) for each release.
"Just after" is the release day and the ``span`` days following it;
"just before" is the same number of days ending the day before. Equal
windows, so the comparison means something even at the edges of a short
history -- a release on the first recorded day gets a before-window of
zero and reads as such rather than as a spike.
"""
counts = dict(series)
out: list[tuple[str, str, int, int]] = []
for version, day in releases:
after = sum(counts.get(shift(day, offset), 0) for offset in range(span + 1))
before = sum(counts.get(shift(day, -offset), 0) for offset in range(1, span + 1))
out.append((version, day, after, before))
return out