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

131 lines
4.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Where a command's seconds actually go, recorded as it goes.
``DOCS-latency.md`` established the numbers that matter by reading three
journal lines by hand, once, and its own rule is that an upstream latency
claim gets measured before it gets designed around. These tests cover the
seam that makes the measurement continuous instead of anecdotal.
"""
from __future__ import annotations
from collections.abc import Callable
import httpx
import pytest
from signal_bot.analytics import probe
from signal_bot.http_client import ImageFetcher
IMAGE = b"\x89PNG\r\n\x1a\nfake"
MockHandler = Callable[[httpx.Request], httpx.Response]
def _fetcher(handler: MockHandler) -> ImageFetcher:
return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
def _ok(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=IMAGE)
async def test_a_fetch_records_its_host_and_status() -> None:
"""One row per outbound request, per invocation."""
fetcher = _fetcher(_ok)
with probe.probing() as notes:
await fetcher.fetch_image("https://cdn.example.test/cat.png")
(call,) = notes.calls
assert call.host == "cdn.example.test"
assert call.status == 200
assert call.ms >= 0
async def test_only_the_host_is_recorded_never_the_path() -> None:
"""A URL carries identifiers; item_id is where those are governed.
The poem URL carries a Wolne Lektury slug, which is exactly the kind of
thing that must be a deliberate column rather than a side effect of
timing a request.
"""
fetcher = _fetcher(_ok)
with probe.probing() as notes:
await fetcher.fetch_image("https://wolnelektury.test/lektura/some-slug.txt")
(call,) = notes.calls
assert call.host == "wolnelektury.test"
assert "slug" not in repr(call)
async def test_a_json_fetch_and_its_image_are_two_calls() -> None:
"""fetch_base64 is a lookup and a download, and both cost time."""
def handler(request: httpx.Request) -> httpx.Response:
if "search" in str(request.url):
return httpx.Response(200, json=[{"url": "https://cdn.example.test/cat.png"}])
return httpx.Response(200, content=IMAGE)
fetcher = _fetcher(handler)
with probe.probing() as notes:
await fetcher.fetch_base64("https://api.example.test/search", [0, "url"])
assert [call.host for call in notes.calls] == [
"api.example.test",
"cdn.example.test",
]
async def test_a_bad_status_is_recorded_and_still_raises() -> None:
"""Upstream answering badly is data; it must not become silence."""
fetcher = _fetcher(lambda _request: httpx.Response(503))
with probe.probing() as notes, pytest.raises(httpx.HTTPStatusError):
await fetcher.fetch_image("https://cdn.example.test/cat.png")
(call,) = notes.calls
assert call.status == 503
async def test_a_request_that_never_answered_is_recorded_as_zero() -> None:
"""DNS, connect and timeout failures never reach a status code.
Losing them would make an upstream that is entirely down look merely
unused, which is the opposite of what the health panel is for.
"""
refused = "connection refused"
def refuse(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError(refused, request=request)
fetcher = _fetcher(refuse)
with probe.probing() as notes, pytest.raises(httpx.ConnectError):
await fetcher.fetch_image("https://cdn.example.test/cat.png")
(call,) = notes.calls
assert call.status == probe.NO_RESPONSE
async def test_a_fetch_outside_a_probe_records_nothing() -> None:
"""The offline runs and most tests drive the fetcher with no dispatcher."""
fetcher = _fetcher(_ok)
await fetcher.fetch_image("https://cdn.example.test/cat.png")
assert probe.CURRENT.get() is None
async def test_a_send_is_timed_even_when_it_fails() -> None:
"""A failed send still cost the seconds it took to fail."""
async def boom() -> None:
async with probe.timed_send():
raise RuntimeError
with probe.probing() as notes, pytest.raises(RuntimeError):
await boom()
assert notes.send_ms >= 0
async def test_two_sends_accumulate() -> None:
"""!pułapka sends a picture and then its description."""
with probe.probing() as notes:
async with probe.timed_send():
pass
async with probe.timed_send():
pass
assert notes.send_ms >= 0