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

202 lines
6.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Family C: the questions that had no data at all before command_events."""
from __future__ import annotations
from typing import TYPE_CHECKING
from signal_bot.analytics import health
from signal_bot.analytics.probe import NO_RESPONSE
from signal_bot.analytics.scope import Scope
from signal_bot.analytics.uses import (
OUTCOME_COOLDOWN,
OUTCOME_DAILY,
OUTCOME_ERROR,
)
from tests.analytics_seed import GROUP, OTHER, Shape, a_call, backfilled, ran, reconstructed
if TYPE_CHECKING:
import sqlite3
from signal_bot.store import Store
DAY = "2026-08-29"
def conn_of(store: Store) -> sqlite3.Connection:
return store.commands.connection
def test_nothing_recorded_reads_as_nothing(store: Store) -> None:
conn = conn_of(store)
assert health.latencies(conn, Scope()) == []
assert health.gates(conn, Scope()) == []
assert health.errors(conn, Scope()) == []
assert health.upstreams(conn, Scope()) == []
def test_percentiles_come_from_the_rows_that_have_timings(store: Store) -> None:
for minute, ms in enumerate((100, 200, 300, 400, 500)):
ran(store, day=DAY, minute=minute, shape=Shape(handler_ms=ms, send_ms=ms // 2))
(latency,) = health.latencies(conn_of(store), Scope())
assert latency.samples == 5
assert latency.handler_p50 == 300
assert latency.handler_p95 == 500
assert latency.send_p50 == 150
def test_a_row_with_no_duration_never_reaches_a_percentile(store: Store) -> None:
"""NULL, never 0. A zero would drag a p50 down and read as a fast command.
This is the reason the duration columns are nullable rather than
defaulted -- and the reason the query says IS NOT NULL rather than
naming a source, so a future source with real timings joins in for free.
"""
ran(store, day=DAY, shape=Shape(handler_ms=400, send_ms=300))
reconstructed(store, day=DAY, hour=10)
backfilled(store, day=DAY)
(latency,) = health.latencies(conn_of(store), Scope())
assert latency.samples == 1
assert latency.handler_p50 == 400
def test_a_command_that_never_replied_has_no_send_time(store: Store) -> None:
"""Handler time without send time is a real shape, so it must not crash.
The live recorder always writes both, but a source that timed the
handler and not the send would otherwise put a NULL through int().
"""
ran(store, day=DAY, shape=Shape(handler_ms=400, send_ms=None, outcome=OUTCOME_ERROR))
(latency,) = health.latencies(conn_of(store), Scope())
assert latency.handler_p50 == 400
assert latency.send_p50 == 0
def test_an_empty_list_has_no_percentile() -> None:
assert health.percentile([], health.P50) == 0
def test_a_single_sample_is_its_own_percentile() -> None:
assert health.percentile([7], health.P95) == 7
def test_each_gate_is_counted_separately(store: Store) -> None:
""" "Refused 40% of the time" and "popular" are two readings of one count.
Which gate bit is the difference between a cooldown that is too long and
a daily limit that is too low.
"""
ran(store, day=DAY, minute=1, shape=Shape(outcome=OUTCOME_COOLDOWN))
ran(store, day=DAY, minute=2, shape=Shape(outcome=OUTCOME_COOLDOWN))
ran(store, day=DAY, minute=3, shape=Shape(outcome=OUTCOME_DAILY))
assert health.gates(conn_of(store), Scope()) == [
("cat", "refused_cooldown", 2),
("cat", "refused_daily", 1),
]
def test_errors_are_grouped_by_class_never_by_message(store: Store) -> None:
"""An exception's str() carries whatever it was processing.
Here that is somebody's message, so only the class name is kept -- and
"17 ImageFetchError" is the whole diagnostic value anyway.
"""
ran(store, day=DAY, minute=1, shape=Shape(outcome=OUTCOME_ERROR, error_class="ImageFetchError"))
ran(store, day=DAY, minute=2, shape=Shape(outcome=OUTCOME_ERROR, error_class="ImageFetchError"))
ran(store, day=DAY, minute=3, shape=Shape(outcome=OUTCOME_ERROR, error_class="TimeoutError"))
assert health.errors(conn_of(store), Scope()) == [
("cat", "ImageFetchError", 2),
("cat", "TimeoutError", 1),
]
def test_upstream_hosts_are_counted_and_timed(store: Store) -> None:
ran(
store,
day=DAY,
shape=Shape(
handler_ms=500,
calls=(a_call("api.example.test", 200, 40), a_call("cdn.example.test", 200, 90)),
),
)
ran(
store,
day=DAY,
minute=1,
shape=Shape(handler_ms=500, calls=(a_call("api.example.test", 200, 60),)),
)
hosts = {up.host: up for up in health.upstreams(conn_of(store), Scope())}
assert hosts["api.example.test"].calls == 2
# Nearest-rank of [40, 60] is the lower: ceil(0.5 * 2) - 1 == 0.
assert hosts["api.example.test"].p50_ms == 40
assert hosts["cdn.example.test"].calls == 1
def test_a_request_that_never_answered_counts_as_a_failure(store: Store) -> None:
"""Status 0 is DNS, connect or timeout: upstream never answered at all.
Distinct from a 5xx, which is upstream answering badly. Losing it would
make a service that is entirely down look merely unused.
"""
ran(
store,
day=DAY,
shape=Shape(handler_ms=10, calls=(a_call("api.example.test", NO_RESPONSE, 5000),)),
)
ran(
store,
day=DAY,
minute=1,
shape=Shape(handler_ms=10, calls=(a_call("api.example.test", 503, 20),)),
)
ran(
store,
day=DAY,
minute=2,
shape=Shape(handler_ms=10, calls=(a_call("api.example.test", 200, 20),)),
)
(host,) = health.upstreams(conn_of(store), Scope())
assert host.calls == 3
assert host.failures == 2
assert host.failure_rate == 2 / 3
def test_a_host_never_called_has_no_failure_rate() -> None:
assert health.Upstream("api.example.test", 0, 0, 0).failure_rate == 0.0
def test_upstream_time_is_attributed_to_the_command_that_spent_it(store: Store) -> None:
"""Which half of a slow command is this repository's code."""
ran(
store,
day=DAY,
command="pope",
shape=Shape(handler_ms=3000, send_ms=2400, calls=(a_call(ms=250),)),
)
(latency,) = health.latencies(conn_of(store), Scope())
assert latency.command == "pope"
assert latency.upstream_p50 == 250
assert latency.send_p50 == 2400
def test_everything_is_scoped_to_one_group(store: Store) -> None:
ran(
store,
day=DAY,
shape=Shape(group_id=GROUP, handler_ms=10, calls=(a_call("here.example.test"),)),
)
ran(
store,
day=DAY,
shape=Shape(group_id=OTHER, handler_ms=10, calls=(a_call("there.example.test"),)),
)
here = health.upstreams(conn_of(store), Scope(GROUP))
assert [host.host for host in here] == ["here.example.test"]
assert len(health.upstreams(conn_of(store), Scope())) == 2