mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
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
202 lines
6.8 KiB
Python
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
|