mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:07:19 +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
84 lines
2.6 KiB
Python
84 lines
2.6 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The ContextVar that lets a fetcher report a timing it was never asked for."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from signal_bot.analytics import probe
|
|
|
|
|
|
def test_nothing_is_probing_by_default() -> None:
|
|
"""The var's default is what every no-op branch below tests against."""
|
|
assert probe.CURRENT.get() is None
|
|
|
|
|
|
def test_notes_outside_a_probe_are_dropped() -> None:
|
|
"""The whole point: a caller never asks whether it is being measured.
|
|
|
|
``http_client`` and ``context`` call these unconditionally, and the
|
|
offline end-to-end run drives both with no dispatcher above them.
|
|
"""
|
|
probe.note_send(5)
|
|
probe.note_upstream("example.test", 200, 5)
|
|
probe.note_item("Q1")
|
|
assert probe.CURRENT.get() is None
|
|
|
|
|
|
def test_a_probe_collects_what_ran_under_it() -> None:
|
|
"""One invocation's notes, from three different layers."""
|
|
with probe.probing() as active:
|
|
probe.note_send(120)
|
|
probe.note_send(80)
|
|
probe.note_upstream("api.example.test", 200, 40)
|
|
probe.note_upstream("cdn.example.test", 500, 15)
|
|
probe.note_item("Q42")
|
|
assert active.send_ms == 200
|
|
assert active.item_id == "Q42"
|
|
assert [(call.host, call.status, call.ms) for call in active.calls] == [
|
|
("api.example.test", 200, 40),
|
|
("cdn.example.test", 500, 15),
|
|
]
|
|
|
|
|
|
def _fail() -> None:
|
|
"""A handler that raises, in its own function so the raise is not inline."""
|
|
raise RuntimeError
|
|
|
|
|
|
def test_the_probe_is_closed_even_when_the_handler_raises() -> None:
|
|
"""A command that fails is still a command whose cost we wanted."""
|
|
seen: list[probe.Probe] = []
|
|
|
|
def run() -> None:
|
|
with probe.probing() as active:
|
|
seen.append(active)
|
|
probe.note_upstream("api.example.test", probe.NO_RESPONSE, 30)
|
|
_fail()
|
|
|
|
with pytest.raises(RuntimeError):
|
|
run()
|
|
|
|
assert probe.CURRENT.get() is None
|
|
assert len(seen[0].calls) == 1
|
|
|
|
|
|
def test_probes_do_not_leak_into_each_other() -> None:
|
|
"""reset(token), not set(None) -- two invocations in a row must not merge."""
|
|
with probe.probing() as first:
|
|
probe.note_send(10)
|
|
with probe.probing() as second:
|
|
probe.note_send(20)
|
|
assert first.send_ms == 10
|
|
assert second.send_ms == 20
|
|
|
|
|
|
def test_a_nested_probe_restores_the_outer_one() -> None:
|
|
"""Nesting is not expected, but a token reset must survive it if it happens."""
|
|
with probe.probing() as outer:
|
|
with probe.probing() as inner:
|
|
probe.note_send(3)
|
|
probe.note_send(7)
|
|
assert inner.send_ms == 3
|
|
assert outer.send_ms == 7
|