Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 45 additions & 19 deletions reflex/utils/export.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -60,25 +63,48 @@ 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("setup_duration"):
build.setup_frontend(Path.cwd())
with _time_phase("build_duration"):
build.build()
Comment thread
FarhanAliRaza marked this conversation as resolved.
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"),
setup_duration=phase_durations.get("setup_duration"),
build_duration=phase_durations.get("build_duration"),
zip_duration=phase_durations.get("zip_duration"),
)

# Post a telemetry event.
telemetry.send("export")
24 changes: 18 additions & 6 deletions reflex/utils/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,15 +285,27 @@ 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",
"setup_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:
if key in properties:
continue

properties[key] = kwargs[key]
if key in kwargs and kwargs[key] is not None:
properties[key] = kwargs[key]
Comment thread
FarhanAliRaza marked this conversation as resolved.

stamp = datetime.now(UTC).isoformat()

Expand Down
152 changes: 129 additions & 23 deletions tests/units/test_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Loading
Loading