Skip to content

test: restore the metrics collector global after each test - #3800

Open
Andreymi wants to merge 1 commit into
vectorize-io:mainfrom
Andreymi:fix/metrics-collector-test-isolation
Open

test: restore the metrics collector global after each test#3800
Andreymi wants to merge 1 commit into
vectorize-io:mainfrom
Andreymi:fix/metrics-collector-test-isolation

Conversation

@Andreymi

Copy link
Copy Markdown
Contributor

The problem

hindsight_api.metrics._metrics_collector is a module-level global that defaults to NoOpMetricsCollector. The API lifespan swaps it for the real collector (create_metrics_collector() in api/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_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. 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 clean v0.9.2 checkout produced 36 of these TypeErrors. 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_recorders fixture already applies to the span-recorder registry. test_metrics.py::test_returns_noop_by_default also already does this by hand for itself; this generalises it to every test.

Test-only: no shipped behaviour changes.

Reproducer

# 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

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_metrics reads the Prometheus registry via generate_latest(), not this global, and is unaffected.

@strix-security

strix-security Bot commented Aug 25, 2026

Copy link
Copy Markdown

Strix Security Review

Warning

This pull request has 13 commits after the last Strix review (44ecb81). Strix has not reviewed these changes.
Automatic review on push is off for this repository. To review the latest changes, tag @strix-security in a comment, or turn on re-review on push.

No security issues found.

Updated for 44ecb81.


Reviewed by Strix
Re-run review · Configure security review settings

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
Andreymi force-pushed the fix/metrics-collector-test-isolation branch from 44ecb81 to 37b5a2f Compare August 26, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant