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

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