Skip to content
Merged
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
70 changes: 49 additions & 21 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,27 +63,52 @@ 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, trigger="export"
)
# 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"):
# Ensure module can be imported and app.compile() is called.
prerequisites.get_compiled_app(
prerender_routes=prerender_routes, trigger="export"
)
with _time_phase("setup_duration"):
# Set up .web directory and install frontend dependencies.
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")
19 changes: 15 additions & 4 deletions reflex/utils/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,16 +293,27 @@ def _prepare_event(
if not event_data:
return None

additional_keys = ["template", "context", "detail", "user_uuid"]
additional_keys = [
"template",
"context",
"detail",
"user_uuid",
"status",
"duration",
"compile_duration",
"setup_duration",
"build_duration",
"zip_duration",
]

# Shallow-copy so we don't mutate the cached default properties dict.
merged_properties = dict(event_data["properties"])

for key in additional_keys:
if key in merged_properties or key not in kwargs:
if key in merged_properties:
continue

merged_properties[key] = kwargs[key]
if key in kwargs and kwargs[key] is not None:
merged_properties[key] = kwargs[key]

if properties:
merged_properties.update(properties)
Expand Down
212 changes: 142 additions & 70 deletions tests/units/test_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,45 @@
from reflex.utils import telemetry


@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,
"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=defaults)
return defaults


@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():
"""Test that telemetry is sent correctly."""
# Check that the user OS is one of the supported operating systems.
Expand All @@ -29,62 +68,106 @@ 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):
httpx_post_mock = mocker.patch("httpx.post")

# 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
@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(event_defaults, httpx_post, event, kwargs, expected_props):
telemetry._send(event, telemetry_enabled=True, **kwargs)
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(event_defaults, httpx_post):
"""Per-event kwargs must not leak into a subsequent event's payload."""
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)
httpx_post_mock.assert_called_once()


def _make_mock_defaults():
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 test_prepare_event_merges_properties(mocker: MockerFixture):
mocker.patch(
"reflex.utils.telemetry._get_event_defaults",
return_value=_make_mock_defaults(),
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)
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 event_defaults["properties"]
assert "duration" not in event_defaults["properties"]
assert "detail" not in event_defaults["properties"]


def test_send_drops_unknown_kwargs(event_defaults, httpx_post):
"""Unknown kwargs must not land in the posted payload."""
telemetry._send("export", telemetry_enabled=True, foo="bar", secret="leak")
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(event_defaults, httpx_post):
"""None-valued kwargs for allowed keys are omitted from the posted payload."""
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.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
assert "compile_duration" not in props
assert "zip_duration" not in props


def test_prepare_event_merges_properties(event_defaults):
"""``properties`` payloads are merged into the event properties."""
event = telemetry._prepare_event(
"compile",
properties={"pages_count": 7, "trigger": "initial"},
Expand All @@ -99,35 +182,24 @@ 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 = _make_mock_defaults()
mocker.patch(
"reflex.utils.telemetry._get_event_defaults",
return_value=cached,
)

cached_props_snapshot = dict(cached["properties"])
cached_props_snapshot = dict(event_defaults["properties"])

telemetry._prepare_event("init", template="my-template")
telemetry._prepare_event(
"compile",
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."""
mocker.patch(
"reflex.utils.telemetry._get_event_defaults",
return_value=_make_mock_defaults(),
)

event = telemetry._prepare_event(
"init",
template="from-kwarg",
Expand Down
Loading
Loading