Skip to content

Commit e0b43a3

Browse files
committed
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.
1 parent 39c74cb commit e0b43a3

4 files changed

Lines changed: 324 additions & 49 deletions

File tree

reflex/utils/export.py

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Export utilities."""
22

3+
import time
4+
from collections.abc import Iterator
5+
from contextlib import contextmanager
36
from pathlib import Path
47

58
from reflex_base import constants
@@ -60,25 +63,46 @@ def export(
6063
# Compile the app in production mode and export it.
6164
console.rule("[bold]Compiling production app and preparing for export.")
6265

63-
if frontend:
64-
# Ensure module can be imported and app.compile() is called.
65-
prerequisites.get_compiled_app(prerender_routes=prerender_routes)
66-
# Set up .web directory and install frontend dependencies.
67-
build.setup_frontend(Path.cwd())
66+
start = time.monotonic()
67+
phase_durations: dict[str, float] = {}
68+
status = "success"
69+
detail: str | None = None
6870

69-
# Build the static app.
70-
if frontend:
71-
build.build()
71+
@contextmanager
72+
def _time_phase(name: str) -> Iterator[None]:
73+
t0 = time.monotonic()
74+
try:
75+
yield
76+
finally:
77+
phase_durations[name] = time.monotonic() - t0
7278

73-
# Zip up the app.
74-
if zipping:
75-
build.zip_app(
76-
frontend=frontend,
77-
backend=backend,
78-
zip_dest_dir=zip_dest_dir,
79-
include_db_file=upload_db_file,
80-
backend_excluded_dirs=backend_excluded_dirs,
79+
try:
80+
if frontend:
81+
with _time_phase("compile_duration"):
82+
prerequisites.get_compiled_app(prerender_routes=prerender_routes)
83+
with _time_phase("build_duration"):
84+
build.setup_frontend(Path.cwd())
85+
build.build()
86+
if zipping:
87+
with _time_phase("zip_duration"):
88+
build.zip_app(
89+
frontend=frontend,
90+
backend=backend,
91+
zip_dest_dir=zip_dest_dir,
92+
include_db_file=upload_db_file,
93+
backend_excluded_dirs=backend_excluded_dirs,
94+
)
95+
except Exception as exc:
96+
status = "failure"
97+
detail = type(exc).__name__
98+
raise
99+
finally:
100+
telemetry.send(
101+
"export",
102+
status=status,
103+
detail=detail,
104+
duration=time.monotonic() - start,
105+
compile_duration=phase_durations.get("compile_duration"),
106+
build_duration=phase_durations.get("build_duration"),
107+
zip_duration=phase_durations.get("zip_duration"),
81108
)
82-
83-
# Post a telemetry event.
84-
telemetry.send("export")

reflex/utils/telemetry.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -285,15 +285,24 @@ def _prepare_event(event: str, **kwargs) -> _Event | None:
285285
if not event_data:
286286
return None
287287

288-
additional_keys = ["template", "context", "detail", "user_uuid"]
289-
290-
properties = event_data["properties"]
288+
additional_keys = [
289+
"template",
290+
"context",
291+
"detail",
292+
"user_uuid",
293+
"status",
294+
"duration",
295+
"compile_duration",
296+
"build_duration",
297+
"zip_duration",
298+
]
299+
300+
# Copy so we don't mutate the cached defaults across events.
301+
properties = event_data["properties"].copy()
291302

292303
for key in additional_keys:
293-
if key in properties or key not in kwargs:
294-
continue
295-
296-
properties[key] = kwargs[key]
304+
if key in kwargs and kwargs[key] is not None:
305+
properties[key] = kwargs[key]
297306

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

tests/units/test_telemetry.py

Lines changed: 129 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@
55
from reflex.utils import telemetry
66

77

8+
def _mock_event_defaults() -> dict:
9+
return {
10+
"api_key": "test_api_key",
11+
"properties": {
12+
"distinct_id": 12345,
13+
"distinct_app_id": 78285505863498957834586115958872998605,
14+
"user_os": "Test OS",
15+
"user_os_detail": "Mocked Platform",
16+
"reflex_version": "0.8.0",
17+
"python_version": "3.8.0",
18+
"node_version": None,
19+
"bun_version": None,
20+
"reflex_enterprise_version": None,
21+
"cpu_count": 4,
22+
"memory": 8192,
23+
"cpu_info": {},
24+
},
25+
}
26+
27+
28+
def _patch_event_defaults(mocker: MockerFixture, value):
29+
"""Replace the cached get_event_defaults() so it returns ``value``, bypassing the once_unless_none cache."""
30+
mocker.patch("reflex.utils.telemetry.get_event_defaults", return_value=value)
31+
32+
833
def test_telemetry():
934
"""Test that telemetry is sent correctly."""
1035
# Check that the user OS is one of the supported operating systems.
@@ -29,31 +54,112 @@ def test_disable():
2954
assert not telemetry._send("test", telemetry_enabled=False)
3055

3156

32-
@pytest.mark.parametrize("event", ["init", "reinit", "run-dev", "run-prod", "export"])
33-
def test_send(mocker: MockerFixture, event):
57+
@pytest.mark.parametrize(
58+
("event", "kwargs", "expected_props"),
59+
[
60+
("init", {}, {}),
61+
("reinit", {}, {}),
62+
("run-dev", {}, {}),
63+
("run-prod", {}, {}),
64+
("export", {}, {}),
65+
(
66+
"export",
67+
{"status": "success", "duration": 1.23},
68+
{"status": "success", "duration": 1.23},
69+
),
70+
(
71+
"export",
72+
{
73+
"status": "failure",
74+
"detail": "ValueError",
75+
"duration": 0.5,
76+
"compile_duration": 0.4,
77+
},
78+
{
79+
"status": "failure",
80+
"detail": "ValueError",
81+
"duration": 0.5,
82+
"compile_duration": 0.4,
83+
},
84+
),
85+
],
86+
)
87+
def test_send(mocker: MockerFixture, event, kwargs, expected_props):
3488
httpx_post_mock = mocker.patch("httpx.post")
89+
_patch_event_defaults(mocker, _mock_event_defaults())
3590

36-
# Mock _get_event_defaults to return a complete valid response
37-
mock_defaults = {
38-
"api_key": "test_api_key",
39-
"properties": {
40-
"distinct_id": 12345,
41-
"distinct_app_id": 78285505863498957834586115958872998605,
42-
"user_os": "Test OS",
43-
"user_os_detail": "Mocked Platform",
44-
"reflex_version": "0.8.0",
45-
"python_version": "3.8.0",
46-
"node_version": None,
47-
"bun_version": None,
48-
"reflex_enterprise_version": None,
49-
"cpu_count": 4,
50-
"memory": 8192,
51-
"cpu_info": {},
52-
},
53-
}
54-
mocker.patch(
55-
"reflex.utils.telemetry._get_event_defaults", return_value=mock_defaults
91+
telemetry._send(event, telemetry_enabled=True, **kwargs)
92+
httpx_post_mock.assert_called_once()
93+
posted = httpx_post_mock.call_args.kwargs["json"]
94+
assert posted["event"] == event
95+
for key, value in expected_props.items():
96+
assert posted["properties"][key] == value
97+
98+
99+
def test_send_does_not_leak_kwargs_between_events(mocker: MockerFixture):
100+
"""Per-event kwargs must not leak into a subsequent event's payload."""
101+
httpx_post_mock = mocker.patch("httpx.post")
102+
defaults = _mock_event_defaults()
103+
_patch_event_defaults(mocker, defaults)
104+
105+
telemetry._send("export", telemetry_enabled=True, status="success", duration=1.0)
106+
telemetry._send(
107+
"export",
108+
telemetry_enabled=True,
109+
status="failure",
110+
detail="ValueError",
111+
duration=2.0,
56112
)
57113

58-
telemetry._send(event, telemetry_enabled=True)
114+
assert httpx_post_mock.call_count == 2
115+
first_props = httpx_post_mock.call_args_list[0].kwargs["json"]["properties"]
116+
second_props = httpx_post_mock.call_args_list[1].kwargs["json"]["properties"]
117+
118+
assert first_props["status"] == "success"
119+
assert first_props["duration"] == pytest.approx(1.0)
120+
assert "detail" not in first_props
121+
122+
assert second_props["status"] == "failure"
123+
assert second_props["detail"] == "ValueError"
124+
assert second_props["duration"] == pytest.approx(2.0)
125+
126+
# The cached defaults must not have been polluted by either call.
127+
assert "status" not in defaults["properties"]
128+
assert "duration" not in defaults["properties"]
129+
assert "detail" not in defaults["properties"]
130+
131+
132+
def test_send_drops_unknown_kwargs(mocker: MockerFixture):
133+
"""Unknown kwargs must not land in the posted payload."""
134+
httpx_post_mock = mocker.patch("httpx.post")
135+
_patch_event_defaults(mocker, _mock_event_defaults())
136+
137+
telemetry._send("export", telemetry_enabled=True, foo="bar", secret="leak")
138+
httpx_post_mock.assert_called_once()
139+
props = httpx_post_mock.call_args.kwargs["json"]["properties"]
140+
assert "foo" not in props
141+
assert "secret" not in props
142+
143+
144+
def test_send_drops_none_kwargs(mocker: MockerFixture):
145+
"""None-valued kwargs for allowed keys are omitted from the posted payload."""
146+
httpx_post_mock = mocker.patch("httpx.post")
147+
_patch_event_defaults(mocker, _mock_event_defaults())
148+
149+
telemetry._send(
150+
"export",
151+
telemetry_enabled=True,
152+
status="success",
153+
detail=None,
154+
duration=0.1,
155+
compile_duration=None,
156+
build_duration=0.05,
157+
zip_duration=None,
158+
)
59159
httpx_post_mock.assert_called_once()
160+
props = httpx_post_mock.call_args.kwargs["json"]["properties"]
161+
assert props["status"] == "success"
162+
assert props["build_duration"] == pytest.approx(0.05)
163+
assert "detail" not in props
164+
assert "compile_duration" not in props
165+
assert "zip_duration" not in props

0 commit comments

Comments
 (0)