From e0b43a393aac71e9369ba5771120606e5bdd40ef Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 15 May 2026 00:02:15 +0500 Subject: [PATCH 1/5] feat(telemetry): report export status, duration, and phase timings Track per-phase timings (compile/build/zip) and success/failure for `reflex export`, and stop mutating the cached event defaults so kwargs from one event can't leak into the next. --- reflex/utils/export.py | 62 +++++++++---- reflex/utils/telemetry.py | 23 +++-- tests/units/test_telemetry.py | 152 ++++++++++++++++++++++++++----- tests/units/utils/test_export.py | 136 +++++++++++++++++++++++++++ 4 files changed, 324 insertions(+), 49 deletions(-) create mode 100644 tests/units/utils/test_export.py diff --git a/reflex/utils/export.py b/reflex/utils/export.py index 8b55fc42a68..0f7fa34c80c 100644 --- a/reflex/utils/export.py +++ b/reflex/utils/export.py @@ -1,5 +1,8 @@ """Export utilities.""" +import time +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path from reflex_base import constants @@ -60,25 +63,46 @@ def export( # Compile the app in production mode and export it. console.rule("[bold]Compiling production app and preparing for export.") - if frontend: - # Ensure module can be imported and app.compile() is called. - prerequisites.get_compiled_app(prerender_routes=prerender_routes) - # Set up .web directory and install frontend dependencies. - build.setup_frontend(Path.cwd()) + start = time.monotonic() + phase_durations: dict[str, float] = {} + status = "success" + detail: str | None = None - # Build the static app. - if frontend: - build.build() + @contextmanager + def _time_phase(name: str) -> Iterator[None]: + t0 = time.monotonic() + try: + yield + finally: + phase_durations[name] = time.monotonic() - t0 - # Zip up the app. - if zipping: - build.zip_app( - frontend=frontend, - backend=backend, - zip_dest_dir=zip_dest_dir, - include_db_file=upload_db_file, - backend_excluded_dirs=backend_excluded_dirs, + try: + if frontend: + with _time_phase("compile_duration"): + prerequisites.get_compiled_app(prerender_routes=prerender_routes) + with _time_phase("build_duration"): + build.setup_frontend(Path.cwd()) + build.build() + if zipping: + with _time_phase("zip_duration"): + build.zip_app( + frontend=frontend, + backend=backend, + zip_dest_dir=zip_dest_dir, + include_db_file=upload_db_file, + backend_excluded_dirs=backend_excluded_dirs, + ) + except Exception as exc: + status = "failure" + detail = type(exc).__name__ + raise + finally: + telemetry.send( + "export", + status=status, + detail=detail, + duration=time.monotonic() - start, + compile_duration=phase_durations.get("compile_duration"), + build_duration=phase_durations.get("build_duration"), + zip_duration=phase_durations.get("zip_duration"), ) - - # Post a telemetry event. - telemetry.send("export") diff --git a/reflex/utils/telemetry.py b/reflex/utils/telemetry.py index dcdecd5e0a9..379e8d97fa3 100644 --- a/reflex/utils/telemetry.py +++ b/reflex/utils/telemetry.py @@ -285,15 +285,24 @@ def _prepare_event(event: str, **kwargs) -> _Event | None: if not event_data: return None - additional_keys = ["template", "context", "detail", "user_uuid"] - - properties = event_data["properties"] + additional_keys = [ + "template", + "context", + "detail", + "user_uuid", + "status", + "duration", + "compile_duration", + "build_duration", + "zip_duration", + ] + + # Copy so we don't mutate the cached defaults across events. + properties = event_data["properties"].copy() for key in additional_keys: - if key in properties or key not in kwargs: - continue - - properties[key] = kwargs[key] + if key in kwargs and kwargs[key] is not None: + properties[key] = kwargs[key] stamp = datetime.now(UTC).isoformat() diff --git a/tests/units/test_telemetry.py b/tests/units/test_telemetry.py index dbe307300ac..bd7b248e711 100644 --- a/tests/units/test_telemetry.py +++ b/tests/units/test_telemetry.py @@ -5,6 +5,31 @@ from reflex.utils import telemetry +def _mock_event_defaults() -> dict: + return { + "api_key": "test_api_key", + "properties": { + "distinct_id": 12345, + "distinct_app_id": 78285505863498957834586115958872998605, + "user_os": "Test OS", + "user_os_detail": "Mocked Platform", + "reflex_version": "0.8.0", + "python_version": "3.8.0", + "node_version": None, + "bun_version": None, + "reflex_enterprise_version": None, + "cpu_count": 4, + "memory": 8192, + "cpu_info": {}, + }, + } + + +def _patch_event_defaults(mocker: MockerFixture, value): + """Replace the cached get_event_defaults() so it returns ``value``, bypassing the once_unless_none cache.""" + mocker.patch("reflex.utils.telemetry.get_event_defaults", return_value=value) + + def test_telemetry(): """Test that telemetry is sent correctly.""" # Check that the user OS is one of the supported operating systems. @@ -29,31 +54,112 @@ def test_disable(): assert not telemetry._send("test", telemetry_enabled=False) -@pytest.mark.parametrize("event", ["init", "reinit", "run-dev", "run-prod", "export"]) -def test_send(mocker: MockerFixture, event): +@pytest.mark.parametrize( + ("event", "kwargs", "expected_props"), + [ + ("init", {}, {}), + ("reinit", {}, {}), + ("run-dev", {}, {}), + ("run-prod", {}, {}), + ("export", {}, {}), + ( + "export", + {"status": "success", "duration": 1.23}, + {"status": "success", "duration": 1.23}, + ), + ( + "export", + { + "status": "failure", + "detail": "ValueError", + "duration": 0.5, + "compile_duration": 0.4, + }, + { + "status": "failure", + "detail": "ValueError", + "duration": 0.5, + "compile_duration": 0.4, + }, + ), + ], +) +def test_send(mocker: MockerFixture, event, kwargs, expected_props): httpx_post_mock = mocker.patch("httpx.post") + _patch_event_defaults(mocker, _mock_event_defaults()) - # Mock _get_event_defaults to return a complete valid response - mock_defaults = { - "api_key": "test_api_key", - "properties": { - "distinct_id": 12345, - "distinct_app_id": 78285505863498957834586115958872998605, - "user_os": "Test OS", - "user_os_detail": "Mocked Platform", - "reflex_version": "0.8.0", - "python_version": "3.8.0", - "node_version": None, - "bun_version": None, - "reflex_enterprise_version": None, - "cpu_count": 4, - "memory": 8192, - "cpu_info": {}, - }, - } - mocker.patch( - "reflex.utils.telemetry._get_event_defaults", return_value=mock_defaults + telemetry._send(event, telemetry_enabled=True, **kwargs) + httpx_post_mock.assert_called_once() + posted = httpx_post_mock.call_args.kwargs["json"] + assert posted["event"] == event + for key, value in expected_props.items(): + assert posted["properties"][key] == value + + +def test_send_does_not_leak_kwargs_between_events(mocker: MockerFixture): + """Per-event kwargs must not leak into a subsequent event's payload.""" + httpx_post_mock = mocker.patch("httpx.post") + defaults = _mock_event_defaults() + _patch_event_defaults(mocker, defaults) + + telemetry._send("export", telemetry_enabled=True, status="success", duration=1.0) + telemetry._send( + "export", + telemetry_enabled=True, + status="failure", + detail="ValueError", + duration=2.0, ) - telemetry._send(event, telemetry_enabled=True) + assert httpx_post_mock.call_count == 2 + first_props = httpx_post_mock.call_args_list[0].kwargs["json"]["properties"] + second_props = httpx_post_mock.call_args_list[1].kwargs["json"]["properties"] + + assert first_props["status"] == "success" + assert first_props["duration"] == pytest.approx(1.0) + assert "detail" not in first_props + + assert second_props["status"] == "failure" + assert second_props["detail"] == "ValueError" + assert second_props["duration"] == pytest.approx(2.0) + + # The cached defaults must not have been polluted by either call. + assert "status" not in defaults["properties"] + assert "duration" not in defaults["properties"] + assert "detail" not in defaults["properties"] + + +def test_send_drops_unknown_kwargs(mocker: MockerFixture): + """Unknown kwargs must not land in the posted payload.""" + httpx_post_mock = mocker.patch("httpx.post") + _patch_event_defaults(mocker, _mock_event_defaults()) + + telemetry._send("export", telemetry_enabled=True, foo="bar", secret="leak") + httpx_post_mock.assert_called_once() + props = httpx_post_mock.call_args.kwargs["json"]["properties"] + assert "foo" not in props + assert "secret" not in props + + +def test_send_drops_none_kwargs(mocker: MockerFixture): + """None-valued kwargs for allowed keys are omitted from the posted payload.""" + httpx_post_mock = mocker.patch("httpx.post") + _patch_event_defaults(mocker, _mock_event_defaults()) + + telemetry._send( + "export", + telemetry_enabled=True, + status="success", + detail=None, + duration=0.1, + compile_duration=None, + build_duration=0.05, + zip_duration=None, + ) httpx_post_mock.assert_called_once() + props = httpx_post_mock.call_args.kwargs["json"]["properties"] + assert props["status"] == "success" + assert props["build_duration"] == pytest.approx(0.05) + assert "detail" not in props + assert "compile_duration" not in props + assert "zip_duration" not in props diff --git a/tests/units/utils/test_export.py b/tests/units/utils/test_export.py new file mode 100644 index 00000000000..a621028c68f --- /dev/null +++ b/tests/units/utils/test_export.py @@ -0,0 +1,136 @@ +"""Tests for reflex.utils.export.""" + +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from reflex.utils import export + + +@pytest.fixture +def patched_export(mocker: MockerFixture) -> dict: + """Patch out side-effecting dependencies of ``export.export()``. + + Args: + mocker: pytest-mock fixture. + + Returns: + Dict of patched mocks keyed by short name. + """ + return { + "get_compiled_app": mocker.patch( + "reflex.utils.export.prerequisites.get_compiled_app" + ), + "setup_frontend": mocker.patch("reflex.utils.export.build.setup_frontend"), + "build": mocker.patch("reflex.utils.export.build.build"), + "zip_app": mocker.patch("reflex.utils.export.build.zip_app"), + "send": mocker.patch("reflex.utils.export.telemetry.send"), + "output_system_info": mocker.patch( + "reflex.utils.export.exec.output_system_info" + ), + "console_rule": mocker.patch("reflex.utils.export.console.rule"), + "set_log_level": mocker.patch("reflex.utils.export.console.set_log_level"), + "env_mode_set": mocker.patch( + "reflex.utils.export.environment.REFLEX_ENV_MODE.set" + ), + "get_config": mocker.patch( + "reflex.utils.export.get_config", return_value=mocker.Mock() + ), + } + + +def _send_kwargs(send_mock) -> dict: + """Extract kwargs from the single telemetry.send call. + + Args: + send_mock: Mock for ``telemetry.send``; must have been called exactly once with positional ``"export"``. + + Returns: + The kwargs dict from the recorded call. + """ + assert send_mock.call_count == 1 + args, kwargs = send_mock.call_args + assert args == ("export",) + return kwargs + + +def test_export_success_emits_success_event_with_all_phase_durations(patched_export): + export.export() + + kwargs = _send_kwargs(patched_export["send"]) + assert kwargs["status"] == "success" + assert isinstance(kwargs["duration"], float) + assert kwargs["duration"] >= 0 + assert isinstance(kwargs["compile_duration"], float) + assert isinstance(kwargs["build_duration"], float) + assert isinstance(kwargs["zip_duration"], float) + assert kwargs["detail"] is None + + +@pytest.mark.parametrize( + ("failing_target", "expected_present", "expected_absent"), + [ + ( + "get_compiled_app", + {"compile_duration"}, + {"build_duration", "zip_duration"}, + ), + ( + "setup_frontend", + {"compile_duration", "build_duration"}, + {"zip_duration"}, + ), + ( + "build", + {"compile_duration", "build_duration"}, + {"zip_duration"}, + ), + ( + "zip_app", + {"compile_duration", "build_duration", "zip_duration"}, + set(), + ), + ], +) +def test_export_failure_emits_failure_event_with_partial_phase_durations( + patched_export, + failing_target: str, + expected_present: set[str], + expected_absent: set[str], +): + patched_export[failing_target].side_effect = RuntimeError("boom") + + with pytest.raises(RuntimeError, match="boom"): + export.export() + + kwargs = _send_kwargs(patched_export["send"]) + assert kwargs["status"] == "failure" + assert kwargs["detail"] == "RuntimeError" + assert isinstance(kwargs["duration"], float) + for key in expected_present: + assert isinstance(kwargs[key], float), ( + f"expected {key} to carry a float duration" + ) + for key in expected_absent: + assert kwargs[key] is None, f"expected {key} to be None (phase did not run)" + + +def test_export_backend_only_emits_only_zip_duration(patched_export): + export.export(frontend=False, zipping=True) + + kwargs = _send_kwargs(patched_export["send"]) + assert kwargs["status"] == "success" + assert kwargs["compile_duration"] is None + assert kwargs["build_duration"] is None + assert isinstance(kwargs["zip_duration"], float) + + +def test_export_no_zip_emits_only_compile_and_build_durations(patched_export): + export.export(frontend=True, zipping=False) + + kwargs = _send_kwargs(patched_export["send"]) + assert kwargs["status"] == "success" + assert isinstance(kwargs["compile_duration"], float) + assert isinstance(kwargs["build_duration"], float) + assert kwargs["zip_duration"] is None From 9bd5c208f914311357c74d15c7a74cdd48d8ec75 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 15 May 2026 01:01:13 +0500 Subject: [PATCH 2/5] feat(telemetry): split frontend setup from build duration in export event Time `build.setup_frontend` separately from `build.build` and skip additional keys already present in defaults so callers can't overwrite them. --- reflex/utils/export.py | 4 +++- reflex/utils/telemetry.py | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/reflex/utils/export.py b/reflex/utils/export.py index 0f7fa34c80c..ba108bfe5da 100644 --- a/reflex/utils/export.py +++ b/reflex/utils/export.py @@ -80,8 +80,9 @@ def _time_phase(name: str) -> Iterator[None]: if frontend: with _time_phase("compile_duration"): prerequisites.get_compiled_app(prerender_routes=prerender_routes) - with _time_phase("build_duration"): + with _time_phase("setup_duration"): build.setup_frontend(Path.cwd()) + with _time_phase("build_duration"): build.build() if zipping: with _time_phase("zip_duration"): @@ -103,6 +104,7 @@ def _time_phase(name: str) -> Iterator[None]: detail=detail, duration=time.monotonic() - start, compile_duration=phase_durations.get("compile_duration"), + setup_duration=phase_durations.get("setup_duration"), build_duration=phase_durations.get("build_duration"), zip_duration=phase_durations.get("zip_duration"), ) diff --git a/reflex/utils/telemetry.py b/reflex/utils/telemetry.py index 379e8d97fa3..a30b8c06bb5 100644 --- a/reflex/utils/telemetry.py +++ b/reflex/utils/telemetry.py @@ -293,6 +293,7 @@ def _prepare_event(event: str, **kwargs) -> _Event | None: "status", "duration", "compile_duration", + "setup_duration", "build_duration", "zip_duration", ] @@ -301,6 +302,8 @@ def _prepare_event(event: str, **kwargs) -> _Event | None: properties = event_data["properties"].copy() for key in additional_keys: + if key in properties: + continue if key in kwargs and kwargs[key] is not None: properties[key] = kwargs[key] From d8c79033f47f2cb6489eb8a153e7b25fc9e951cc Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 15 May 2026 01:12:27 +0500 Subject: [PATCH 3/5] test(export): cover new setup_duration phase in export telemetry tests --- tests/units/utils/test_export.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/units/utils/test_export.py b/tests/units/utils/test_export.py index a621028c68f..8d03e3d1f3c 100644 --- a/tests/units/utils/test_export.py +++ b/tests/units/utils/test_export.py @@ -63,6 +63,7 @@ def test_export_success_emits_success_event_with_all_phase_durations(patched_exp assert isinstance(kwargs["duration"], float) assert kwargs["duration"] >= 0 assert isinstance(kwargs["compile_duration"], float) + assert isinstance(kwargs["setup_duration"], float) assert isinstance(kwargs["build_duration"], float) assert isinstance(kwargs["zip_duration"], float) assert kwargs["detail"] is None @@ -74,21 +75,21 @@ def test_export_success_emits_success_event_with_all_phase_durations(patched_exp ( "get_compiled_app", {"compile_duration"}, - {"build_duration", "zip_duration"}, + {"setup_duration", "build_duration", "zip_duration"}, ), ( "setup_frontend", - {"compile_duration", "build_duration"}, - {"zip_duration"}, + {"compile_duration", "setup_duration"}, + {"build_duration", "zip_duration"}, ), ( "build", - {"compile_duration", "build_duration"}, + {"compile_duration", "setup_duration", "build_duration"}, {"zip_duration"}, ), ( "zip_app", - {"compile_duration", "build_duration", "zip_duration"}, + {"compile_duration", "setup_duration", "build_duration", "zip_duration"}, set(), ), ], @@ -122,6 +123,7 @@ def test_export_backend_only_emits_only_zip_duration(patched_export): kwargs = _send_kwargs(patched_export["send"]) assert kwargs["status"] == "success" assert kwargs["compile_duration"] is None + assert kwargs["setup_duration"] is None assert kwargs["build_duration"] is None assert isinstance(kwargs["zip_duration"], float) @@ -132,5 +134,6 @@ def test_export_no_zip_emits_only_compile_and_build_durations(patched_export): kwargs = _send_kwargs(patched_export["send"]) assert kwargs["status"] == "success" assert isinstance(kwargs["compile_duration"], float) + assert isinstance(kwargs["setup_duration"], float) assert isinstance(kwargs["build_duration"], float) assert kwargs["zip_duration"] is None From e7711f73664834479c16fa767f977dc6a3e62fed Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 19 May 2026 01:08:58 +0500 Subject: [PATCH 4/5] test: simplify export test fixtures and assertions Drop redundant Args docstrings, unused console mocks, and a tautological duration check; use call_args.kwargs directly instead of unpacking. --- tests/units/utils/test_export.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/tests/units/utils/test_export.py b/tests/units/utils/test_export.py index 8d03e3d1f3c..9776eb0c043 100644 --- a/tests/units/utils/test_export.py +++ b/tests/units/utils/test_export.py @@ -12,9 +12,6 @@ def patched_export(mocker: MockerFixture) -> dict: """Patch out side-effecting dependencies of ``export.export()``. - Args: - mocker: pytest-mock fixture. - Returns: Dict of patched mocks keyed by short name. """ @@ -29,8 +26,6 @@ def patched_export(mocker: MockerFixture) -> dict: "output_system_info": mocker.patch( "reflex.utils.export.exec.output_system_info" ), - "console_rule": mocker.patch("reflex.utils.export.console.rule"), - "set_log_level": mocker.patch("reflex.utils.export.console.set_log_level"), "env_mode_set": mocker.patch( "reflex.utils.export.environment.REFLEX_ENV_MODE.set" ), @@ -43,16 +38,11 @@ def patched_export(mocker: MockerFixture) -> dict: def _send_kwargs(send_mock) -> dict: """Extract kwargs from the single telemetry.send call. - Args: - send_mock: Mock for ``telemetry.send``; must have been called exactly once with positional ``"export"``. - Returns: The kwargs dict from the recorded call. """ assert send_mock.call_count == 1 - args, kwargs = send_mock.call_args - assert args == ("export",) - return kwargs + return send_mock.call_args.kwargs def test_export_success_emits_success_event_with_all_phase_durations(patched_export): @@ -61,7 +51,6 @@ def test_export_success_emits_success_event_with_all_phase_durations(patched_exp kwargs = _send_kwargs(patched_export["send"]) assert kwargs["status"] == "success" assert isinstance(kwargs["duration"], float) - assert kwargs["duration"] >= 0 assert isinstance(kwargs["compile_duration"], float) assert isinstance(kwargs["setup_duration"], float) assert isinstance(kwargs["build_duration"], float) From 894704147495f312f5fc4013b314eda325f271f1 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 19 May 2026 22:50:47 +0500 Subject: [PATCH 5/5] test: convert telemetry test helpers to pytest fixtures Replace repeated _patch_event_defaults/_mock_event_defaults calls with event_defaults and httpx_post fixtures so each test's setup stays in its signature instead of the body. --- tests/units/test_telemetry.py | 92 ++++++++++++++++------------------- 1 file changed, 43 insertions(+), 49 deletions(-) diff --git a/tests/units/test_telemetry.py b/tests/units/test_telemetry.py index 71314088791..69e18319c26 100644 --- a/tests/units/test_telemetry.py +++ b/tests/units/test_telemetry.py @@ -5,8 +5,15 @@ from reflex.utils import telemetry -def _mock_event_defaults() -> dict: - return { +@pytest.fixture +def event_defaults(mocker: MockerFixture) -> dict: + """Patch ``get_event_defaults()`` with a fresh dict. + + Returns: + The dict that ``get_event_defaults()`` is patched to return, so tests + can assert it isn't mutated by the code under test. + """ + defaults = { "api_key": "test_api_key", "properties": { "distinct_id": 12345, @@ -23,11 +30,18 @@ def _mock_event_defaults() -> dict: "cpu_info": {}, }, } + mocker.patch("reflex.utils.telemetry.get_event_defaults", return_value=defaults) + return defaults -def _patch_event_defaults(mocker: MockerFixture, value): - """Replace the cached get_event_defaults() so it returns ``value``, bypassing the once_unless_none cache.""" - mocker.patch("reflex.utils.telemetry.get_event_defaults", return_value=value) +@pytest.fixture +def httpx_post(mocker: MockerFixture): + """Mock ``httpx.post`` used by ``telemetry._send``. + + Returns: + The mock for ``httpx.post`` so tests can assert on the posted payload. + """ + return mocker.patch("httpx.post") def test_telemetry(): @@ -84,24 +98,17 @@ def test_disable(): ), ], ) -def test_send(mocker: MockerFixture, event, kwargs, expected_props): - httpx_post_mock = mocker.patch("httpx.post") - _patch_event_defaults(mocker, _mock_event_defaults()) - +def test_send(event_defaults, httpx_post, event, kwargs, expected_props): telemetry._send(event, telemetry_enabled=True, **kwargs) - httpx_post_mock.assert_called_once() - posted = httpx_post_mock.call_args.kwargs["json"] + httpx_post.assert_called_once() + posted = httpx_post.call_args.kwargs["json"] assert posted["event"] == event for key, value in expected_props.items(): assert posted["properties"][key] == value -def test_send_does_not_leak_kwargs_between_events(mocker: MockerFixture): +def test_send_does_not_leak_kwargs_between_events(event_defaults, httpx_post): """Per-event kwargs must not leak into a subsequent event's payload.""" - httpx_post_mock = mocker.patch("httpx.post") - defaults = _mock_event_defaults() - _patch_event_defaults(mocker, defaults) - telemetry._send("export", telemetry_enabled=True, status="success", duration=1.0) telemetry._send( "export", @@ -111,9 +118,9 @@ def test_send_does_not_leak_kwargs_between_events(mocker: MockerFixture): duration=2.0, ) - assert httpx_post_mock.call_count == 2 - first_props = httpx_post_mock.call_args_list[0].kwargs["json"]["properties"] - second_props = httpx_post_mock.call_args_list[1].kwargs["json"]["properties"] + assert httpx_post.call_count == 2 + first_props = httpx_post.call_args_list[0].kwargs["json"]["properties"] + second_props = httpx_post.call_args_list[1].kwargs["json"]["properties"] assert first_props["status"] == "success" assert first_props["duration"] == pytest.approx(1.0) @@ -124,28 +131,22 @@ def test_send_does_not_leak_kwargs_between_events(mocker: MockerFixture): assert second_props["duration"] == pytest.approx(2.0) # The cached defaults must not have been polluted by either call. - assert "status" not in defaults["properties"] - assert "duration" not in defaults["properties"] - assert "detail" not in defaults["properties"] + assert "status" not in event_defaults["properties"] + assert "duration" not in event_defaults["properties"] + assert "detail" not in event_defaults["properties"] -def test_send_drops_unknown_kwargs(mocker: MockerFixture): +def test_send_drops_unknown_kwargs(event_defaults, httpx_post): """Unknown kwargs must not land in the posted payload.""" - httpx_post_mock = mocker.patch("httpx.post") - _patch_event_defaults(mocker, _mock_event_defaults()) - telemetry._send("export", telemetry_enabled=True, foo="bar", secret="leak") - httpx_post_mock.assert_called_once() - props = httpx_post_mock.call_args.kwargs["json"]["properties"] + httpx_post.assert_called_once() + props = httpx_post.call_args.kwargs["json"]["properties"] assert "foo" not in props assert "secret" not in props -def test_send_drops_none_kwargs(mocker: MockerFixture): +def test_send_drops_none_kwargs(event_defaults, httpx_post): """None-valued kwargs for allowed keys are omitted from the posted payload.""" - httpx_post_mock = mocker.patch("httpx.post") - _patch_event_defaults(mocker, _mock_event_defaults()) - telemetry._send( "export", telemetry_enabled=True, @@ -156,8 +157,8 @@ def test_send_drops_none_kwargs(mocker: MockerFixture): build_duration=0.05, zip_duration=None, ) - httpx_post_mock.assert_called_once() - props = httpx_post_mock.call_args.kwargs["json"]["properties"] + httpx_post.assert_called_once() + props = httpx_post.call_args.kwargs["json"]["properties"] assert props["status"] == "success" assert props["build_duration"] == pytest.approx(0.05) assert "detail" not in props @@ -165,10 +166,8 @@ def test_send_drops_none_kwargs(mocker: MockerFixture): assert "zip_duration" not in props -def test_prepare_event_merges_properties(mocker: MockerFixture): +def test_prepare_event_merges_properties(event_defaults): """``properties`` payloads are merged into the event properties.""" - _patch_event_defaults(mocker, _mock_event_defaults()) - event = telemetry._prepare_event( "compile", properties={"pages_count": 7, "trigger": "initial"}, @@ -183,12 +182,9 @@ def test_prepare_event_merges_properties(mocker: MockerFixture): assert props["user_os"] == "Test OS" -def test_prepare_event_does_not_mutate_cached_defaults(mocker: MockerFixture): +def test_prepare_event_does_not_mutate_cached_defaults(event_defaults): """``_prepare_event`` must not mutate the @once_unless_none cached defaults.""" - cached = _mock_event_defaults() - _patch_event_defaults(mocker, cached) - - cached_props_snapshot = dict(cached["properties"]) + cached_props_snapshot = dict(event_defaults["properties"]) telemetry._prepare_event("init", template="my-template") telemetry._prepare_event( @@ -196,16 +192,14 @@ def test_prepare_event_does_not_mutate_cached_defaults(mocker: MockerFixture): properties={"pages_count": 3, "duration_ms": 42}, ) - assert cached["properties"] == cached_props_snapshot - assert "template" not in cached["properties"] - assert "pages_count" not in cached["properties"] - assert "duration_ms" not in cached["properties"] + assert event_defaults["properties"] == cached_props_snapshot + assert "template" not in event_defaults["properties"] + assert "pages_count" not in event_defaults["properties"] + assert "duration_ms" not in event_defaults["properties"] -def test_prepare_event_properties_override_kwargs(mocker: MockerFixture): +def test_prepare_event_properties_override_kwargs(event_defaults): """If both kwargs and properties supply the same key, properties wins.""" - _patch_event_defaults(mocker, _mock_event_defaults()) - event = telemetry._prepare_event( "init", template="from-kwarg",