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/3] 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/3] 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/3] 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