Skip to content

Commit 6112083

Browse files
feat(telemetry): report export status, duration, and phase timings (#6509)
* 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. * 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. * test(export): cover new setup_duration phase in export telemetry tests * 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. * 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.
1 parent 21f11df commit 6112083

4 files changed

Lines changed: 334 additions & 95 deletions

File tree

reflex/utils/export.py

Lines changed: 49 additions & 21 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,27 +63,52 @@ 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(
66-
prerender_routes=prerender_routes, trigger="export"
67-
)
68-
# Set up .web directory and install frontend dependencies.
69-
build.setup_frontend(Path.cwd())
66+
start = time.monotonic()
67+
phase_durations: dict[str, float] = {}
68+
status = "success"
69+
detail: str | None = None
7070

71-
# Build the static app.
72-
if frontend:
73-
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
7478

75-
# Zip up the app.
76-
if zipping:
77-
build.zip_app(
78-
frontend=frontend,
79-
backend=backend,
80-
zip_dest_dir=zip_dest_dir,
81-
include_db_file=upload_db_file,
82-
backend_excluded_dirs=backend_excluded_dirs,
79+
try:
80+
if frontend:
81+
with _time_phase("compile_duration"):
82+
# Ensure module can be imported and app.compile() is called.
83+
prerequisites.get_compiled_app(
84+
prerender_routes=prerender_routes, trigger="export"
85+
)
86+
with _time_phase("setup_duration"):
87+
# Set up .web directory and install frontend dependencies.
88+
build.setup_frontend(Path.cwd())
89+
with _time_phase("build_duration"):
90+
build.build()
91+
if zipping:
92+
with _time_phase("zip_duration"):
93+
build.zip_app(
94+
frontend=frontend,
95+
backend=backend,
96+
zip_dest_dir=zip_dest_dir,
97+
include_db_file=upload_db_file,
98+
backend_excluded_dirs=backend_excluded_dirs,
99+
)
100+
except Exception as exc:
101+
status = "failure"
102+
detail = type(exc).__name__
103+
raise
104+
finally:
105+
telemetry.send(
106+
"export",
107+
status=status,
108+
detail=detail,
109+
duration=time.monotonic() - start,
110+
compile_duration=phase_durations.get("compile_duration"),
111+
setup_duration=phase_durations.get("setup_duration"),
112+
build_duration=phase_durations.get("build_duration"),
113+
zip_duration=phase_durations.get("zip_duration"),
83114
)
84-
85-
# Post a telemetry event.
86-
telemetry.send("export")

reflex/utils/telemetry.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,16 +293,27 @@ def _prepare_event(
293293
if not event_data:
294294
return None
295295

296-
additional_keys = ["template", "context", "detail", "user_uuid"]
296+
additional_keys = [
297+
"template",
298+
"context",
299+
"detail",
300+
"user_uuid",
301+
"status",
302+
"duration",
303+
"compile_duration",
304+
"setup_duration",
305+
"build_duration",
306+
"zip_duration",
307+
]
297308

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

301312
for key in additional_keys:
302-
if key in merged_properties or key not in kwargs:
313+
if key in merged_properties:
303314
continue
304-
305-
merged_properties[key] = kwargs[key]
315+
if key in kwargs and kwargs[key] is not None:
316+
merged_properties[key] = kwargs[key]
306317

307318
if properties:
308319
merged_properties.update(properties)

tests/units/test_telemetry.py

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

77

8+
@pytest.fixture
9+
def event_defaults(mocker: MockerFixture) -> dict:
10+
"""Patch ``get_event_defaults()`` with a fresh dict.
11+
12+
Returns:
13+
The dict that ``get_event_defaults()`` is patched to return, so tests
14+
can assert it isn't mutated by the code under test.
15+
"""
16+
defaults = {
17+
"api_key": "test_api_key",
18+
"properties": {
19+
"distinct_id": 12345,
20+
"distinct_app_id": 78285505863498957834586115958872998605,
21+
"user_os": "Test OS",
22+
"user_os_detail": "Mocked Platform",
23+
"reflex_version": "0.8.0",
24+
"python_version": "3.8.0",
25+
"node_version": None,
26+
"bun_version": None,
27+
"reflex_enterprise_version": None,
28+
"cpu_count": 4,
29+
"memory": 8192,
30+
"cpu_info": {},
31+
},
32+
}
33+
mocker.patch("reflex.utils.telemetry.get_event_defaults", return_value=defaults)
34+
return defaults
35+
36+
37+
@pytest.fixture
38+
def httpx_post(mocker: MockerFixture):
39+
"""Mock ``httpx.post`` used by ``telemetry._send``.
40+
41+
Returns:
42+
The mock for ``httpx.post`` so tests can assert on the posted payload.
43+
"""
44+
return mocker.patch("httpx.post")
45+
46+
847
def test_telemetry():
948
"""Test that telemetry is sent correctly."""
1049
# Check that the user OS is one of the supported operating systems.
@@ -29,62 +68,106 @@ def test_disable():
2968
assert not telemetry._send("test", telemetry_enabled=False)
3069

3170

32-
@pytest.mark.parametrize("event", ["init", "reinit", "run-dev", "run-prod", "export"])
33-
def test_send(mocker: MockerFixture, event):
34-
httpx_post_mock = mocker.patch("httpx.post")
35-
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
71+
@pytest.mark.parametrize(
72+
("event", "kwargs", "expected_props"),
73+
[
74+
("init", {}, {}),
75+
("reinit", {}, {}),
76+
("run-dev", {}, {}),
77+
("run-prod", {}, {}),
78+
("export", {}, {}),
79+
(
80+
"export",
81+
{"status": "success", "duration": 1.23},
82+
{"status": "success", "duration": 1.23},
83+
),
84+
(
85+
"export",
86+
{
87+
"status": "failure",
88+
"detail": "ValueError",
89+
"duration": 0.5,
90+
"compile_duration": 0.4,
91+
},
92+
{
93+
"status": "failure",
94+
"detail": "ValueError",
95+
"duration": 0.5,
96+
"compile_duration": 0.4,
97+
},
98+
),
99+
],
100+
)
101+
def test_send(event_defaults, httpx_post, event, kwargs, expected_props):
102+
telemetry._send(event, telemetry_enabled=True, **kwargs)
103+
httpx_post.assert_called_once()
104+
posted = httpx_post.call_args.kwargs["json"]
105+
assert posted["event"] == event
106+
for key, value in expected_props.items():
107+
assert posted["properties"][key] == value
108+
109+
110+
def test_send_does_not_leak_kwargs_between_events(event_defaults, httpx_post):
111+
"""Per-event kwargs must not leak into a subsequent event's payload."""
112+
telemetry._send("export", telemetry_enabled=True, status="success", duration=1.0)
113+
telemetry._send(
114+
"export",
115+
telemetry_enabled=True,
116+
status="failure",
117+
detail="ValueError",
118+
duration=2.0,
56119
)
57120

58-
telemetry._send(event, telemetry_enabled=True)
59-
httpx_post_mock.assert_called_once()
60-
61-
62-
def _make_mock_defaults():
63-
return {
64-
"api_key": "test_api_key",
65-
"properties": {
66-
"distinct_id": 12345,
67-
"distinct_app_id": 78285505863498957834586115958872998605,
68-
"user_os": "Test OS",
69-
"user_os_detail": "Mocked Platform",
70-
"reflex_version": "0.8.0",
71-
"python_version": "3.8.0",
72-
"node_version": None,
73-
"bun_version": None,
74-
"reflex_enterprise_version": None,
75-
"cpu_count": 4,
76-
"memory": 8192,
77-
"cpu_info": {},
78-
},
79-
}
80-
81-
82-
def test_prepare_event_merges_properties(mocker: MockerFixture):
83-
mocker.patch(
84-
"reflex.utils.telemetry._get_event_defaults",
85-
return_value=_make_mock_defaults(),
121+
assert httpx_post.call_count == 2
122+
first_props = httpx_post.call_args_list[0].kwargs["json"]["properties"]
123+
second_props = httpx_post.call_args_list[1].kwargs["json"]["properties"]
124+
125+
assert first_props["status"] == "success"
126+
assert first_props["duration"] == pytest.approx(1.0)
127+
assert "detail" not in first_props
128+
129+
assert second_props["status"] == "failure"
130+
assert second_props["detail"] == "ValueError"
131+
assert second_props["duration"] == pytest.approx(2.0)
132+
133+
# The cached defaults must not have been polluted by either call.
134+
assert "status" not in event_defaults["properties"]
135+
assert "duration" not in event_defaults["properties"]
136+
assert "detail" not in event_defaults["properties"]
137+
138+
139+
def test_send_drops_unknown_kwargs(event_defaults, httpx_post):
140+
"""Unknown kwargs must not land in the posted payload."""
141+
telemetry._send("export", telemetry_enabled=True, foo="bar", secret="leak")
142+
httpx_post.assert_called_once()
143+
props = httpx_post.call_args.kwargs["json"]["properties"]
144+
assert "foo" not in props
145+
assert "secret" not in props
146+
147+
148+
def test_send_drops_none_kwargs(event_defaults, httpx_post):
149+
"""None-valued kwargs for allowed keys are omitted from the posted payload."""
150+
telemetry._send(
151+
"export",
152+
telemetry_enabled=True,
153+
status="success",
154+
detail=None,
155+
duration=0.1,
156+
compile_duration=None,
157+
build_duration=0.05,
158+
zip_duration=None,
86159
)
160+
httpx_post.assert_called_once()
161+
props = httpx_post.call_args.kwargs["json"]["properties"]
162+
assert props["status"] == "success"
163+
assert props["build_duration"] == pytest.approx(0.05)
164+
assert "detail" not in props
165+
assert "compile_duration" not in props
166+
assert "zip_duration" not in props
87167

168+
169+
def test_prepare_event_merges_properties(event_defaults):
170+
"""``properties`` payloads are merged into the event properties."""
88171
event = telemetry._prepare_event(
89172
"compile",
90173
properties={"pages_count": 7, "trigger": "initial"},
@@ -99,35 +182,24 @@ def test_prepare_event_merges_properties(mocker: MockerFixture):
99182
assert props["user_os"] == "Test OS"
100183

101184

102-
def test_prepare_event_does_not_mutate_cached_defaults(mocker: MockerFixture):
185+
def test_prepare_event_does_not_mutate_cached_defaults(event_defaults):
103186
"""``_prepare_event`` must not mutate the @once_unless_none cached defaults."""
104-
cached = _make_mock_defaults()
105-
mocker.patch(
106-
"reflex.utils.telemetry._get_event_defaults",
107-
return_value=cached,
108-
)
109-
110-
cached_props_snapshot = dict(cached["properties"])
187+
cached_props_snapshot = dict(event_defaults["properties"])
111188

112189
telemetry._prepare_event("init", template="my-template")
113190
telemetry._prepare_event(
114191
"compile",
115192
properties={"pages_count": 3, "duration_ms": 42},
116193
)
117194

118-
assert cached["properties"] == cached_props_snapshot
119-
assert "template" not in cached["properties"]
120-
assert "pages_count" not in cached["properties"]
121-
assert "duration_ms" not in cached["properties"]
195+
assert event_defaults["properties"] == cached_props_snapshot
196+
assert "template" not in event_defaults["properties"]
197+
assert "pages_count" not in event_defaults["properties"]
198+
assert "duration_ms" not in event_defaults["properties"]
122199

123200

124-
def test_prepare_event_properties_override_kwargs(mocker: MockerFixture):
201+
def test_prepare_event_properties_override_kwargs(event_defaults):
125202
"""If both kwargs and properties supply the same key, properties wins."""
126-
mocker.patch(
127-
"reflex.utils.telemetry._get_event_defaults",
128-
return_value=_make_mock_defaults(),
129-
)
130-
131203
event = telemetry._prepare_event(
132204
"init",
133205
template="from-kwarg",

0 commit comments

Comments
 (0)