test: restore the metrics collector global after each test - #3800
Open
Andreymi wants to merge 1 commit into
Open
test: restore the metrics collector global after each test#3800Andreymi wants to merge 1 commit into
Andreymi wants to merge 1 commit into
Conversation
Strix Security ReviewWarning This pull request has 13 commits after the last Strix review ( No security issues found. Updated for Reviewed by Strix |
The API lifespan swaps hindsight_api.metrics._metrics_collector from the
default NoOpMetricsCollector to the real one and nothing puts it back, so
under xdist every test scheduled after an app-starting test in the same
worker runs against the real collector.
That is not neutral: the no-op swallows its arguments while the real
collector compares them. A test that mocks an LLM response leaves
response.usage.prompt_tokens_details.cached_tokens a MagicMock, and
record_llm_call's 'if cached_input_tokens > 0' raises
TypeError: '>' not supported between instances of 'MagicMock' and 'int'
Which tests fail depends on the xdist schedule, so the set moves between
runs and reads as random flakiness. On v0.9.2 a full run of
'pytest tests/ -m "not hs_llm_mat and not hs_llm_core"' produced 36 such
failures, and a second run of the same tree produced a partly different set.
Every one of them passes in isolation.
Fixed the same way the neighbouring _cleanup_leaked_span_recorders fixture
guards the span-recorder registry: snapshot the global before the test and
restore it after. test_metrics.py::test_returns_noop_by_default already does
this by hand for itself; this generalises it.
Reproducer (fails before, passes after):
# tests/test_aaa_probe.py — stands in for any app-starting test
from hindsight_api.metrics import create_metrics_collector, initialize_metrics
def test_probe():
initialize_metrics(service_name='probe', service_version='0')
create_metrics_collector()
pytest tests/test_aaa_probe.py tests/test_lmstudio_tool_choice.py \
tests/test_tool_path_reasoning_effort.py -p no:randomly
Andreymi
force-pushed
the
fix/metrics-collector-test-isolation
branch
from
August 26, 2026 21:32
44ecb81 to
37b5a2f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
hindsight_api.metrics._metrics_collectoris a module-level global that defaults toNoOpMetricsCollector. The API lifespan swaps it for the real collector (create_metrics_collector()inapi/http.py) and nothing puts it back, so under xdist every test scheduled after any app-starting test in the same worker runs against the real collector.That is not a neutral difference. The no-op swallows its arguments; the real collector compares them. A test that mocks an LLM response leaves
response.usage.prompt_tokens_details.cached_tokensaMagicMock, andrecord_llm_call'sif cached_input_tokens > 0raises:Which tests fail depends on the xdist schedule, so the set moves between runs and reads as random flakiness. Every one of them passes in isolation.
Measured on v0.9.2
pytest tests/ -q -m "not hs_llm_mat and not hs_llm_core"on a cleanv0.9.2checkout produced 36 of theseTypeErrors. A second run of the same tree produced a partly different set — same code, different victims.Hit hardest:
test_lmstudio_tool_choice.py,test_tool_path_reasoning_effort.py,test_tool_choice_required_downgrade.py,test_reasoning_effort_explicit_config.py.The fix
Snapshot the global before each test, restore it after — the same guard the neighbouring
_cleanup_leaked_span_recordersfixture already applies to the span-recorder registry.test_metrics.py::test_returns_noop_by_defaultalso already does this by hand for itself; this generalises it to every test.Test-only: no shipped behaviour changes.
Reproducer
Before:
2 failed, 16 passed. After:18 passed.Notes
I checked that nothing depends on the real collector surviving a test: the only test that reads the live global is
test_llm_token_metrics.py::test_noop_collector_when_metrics_disabled, which asserts it is the no-op — it is a latent victim of the same leak, so this makes it more reliable, not less.test_base_path.py::test_base_path_metricsreads the Prometheus registry viagenerate_latest(), not this global, and is unaffected.