Skip to content

Commit 9bada16

Browse files
feat: capture pre-init Python environment in init telemetry (#6534)
* feat: capture pre-init Python environment in init telemetry Snapshot virtualenv state and presence of pyproject.toml/requirements.txt before template scaffolding runs so the "init" event reflects the user's actual starting environment, not files the template created. * test: cover requirements.txt detection in init telemetry env * feat(telemetry): extend init env snapshot with uv.lock and reflex.lock Also short-circuit get_init_environment() when telemetry is disabled and attach the snapshot to reinit events so reinitialized projects are captured alongside fresh inits.
1 parent 0ac41df commit 9bada16

6 files changed

Lines changed: 179 additions & 4 deletions

File tree

packages/reflex-base/src/reflex_base/constants/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
GitIgnore,
4646
PyprojectToml,
4747
RequirementsTxt,
48+
UvLock,
4849
)
4950
from .custom_components import CustomComponents
5051
from .event import Endpoint, EventTriggers, SocketEvent
@@ -120,4 +121,5 @@
120121
"SocketEvent",
121122
"StateManagerMode",
122123
"Templates",
124+
"UvLock",
123125
]

packages/reflex-base/src/reflex_base/constants/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ class RequirementsTxt(SimpleNamespace):
6565
DEFAULTS_STUB = f"{Reflex.MODULE_NAME}=="
6666

6767

68+
class UvLock(SimpleNamespace):
69+
"""uv.lock constants."""
70+
71+
# The uv lockfile.
72+
FILE = "uv.lock"
73+
74+
6875
class DefaultPorts(SimpleNamespace):
6976
"""Default port constants."""
7077

reflex/constants/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
GitIgnore,
4444
PyprojectToml,
4545
RequirementsTxt,
46+
UvLock,
4647
)
4748
from .custom_components import CustomComponents
4849
from .event import Endpoint, EventTriggers, SocketEvent
@@ -115,4 +116,5 @@
115116
"SocketEvent",
116117
"StateManagerMode",
117118
"Templates",
119+
"UvLock",
118120
]

reflex/utils/telemetry.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55
import importlib.metadata
66
import json
77
import multiprocessing
8+
import os
89
import platform
10+
import sys
911
import warnings
1012
from contextlib import suppress
1113
from datetime import datetime, timezone
14+
from pathlib import Path
1215
from typing import Any, TypedDict, cast
1316

1417
from reflex_base import constants
18+
from reflex_base.config import get_config
1519
from reflex_base.environment import environment
1620
from reflex_base.utils.decorator import once, once_unless_none
1721
from reflex_base.utils.exceptions import ReflexError
@@ -166,6 +170,38 @@ def get_cpu_count() -> int:
166170
return multiprocessing.cpu_count()
167171

168172

173+
def is_in_virtualenv() -> bool:
174+
"""Whether the current Python is running inside a virtual environment.
175+
176+
Returns:
177+
True if a virtual environment appears to be active.
178+
"""
179+
if sys.prefix != sys.base_prefix:
180+
return True
181+
return bool(os.environ.get("VIRTUAL_ENV"))
182+
183+
184+
def get_init_environment() -> dict[str, bool]:
185+
"""Return Python tooling flags for the current working directory.
186+
187+
Returns:
188+
A dict with ``in_virtualenv``, ``has_pyproject_toml``,
189+
``has_requirements_txt``, ``has_uv_lock`` and ``has_reflex_lock``
190+
boolean flags, or an empty dict when telemetry is disabled (so the
191+
filesystem stats are skipped when their results would be discarded).
192+
"""
193+
if not get_config().telemetry_enabled:
194+
return {}
195+
196+
return {
197+
"in_virtualenv": is_in_virtualenv(),
198+
"has_pyproject_toml": Path(constants.PyprojectToml.FILE).exists(),
199+
"has_requirements_txt": Path(constants.RequirementsTxt.FILE).exists(),
200+
"has_uv_lock": Path(constants.UvLock.FILE).exists(),
201+
"has_reflex_lock": Path(constants.Bun.ROOT_LOCKFILE_DIR).is_dir(),
202+
}
203+
204+
169205
def get_reflex_enterprise_version() -> str | None:
170206
"""Get the version of reflex-enterprise if installed.
171207
@@ -349,8 +385,6 @@ def _send(
349385
properties: dict[str, Any] | None = None,
350386
**kwargs,
351387
) -> bool:
352-
from reflex_base.config import get_config
353-
354388
# Get the telemetry_enabled from the config if it is not specified.
355389
if telemetry_enabled is None:
356390
telemetry_enabled = get_config().telemetry_enabled

reflex/utils/templates.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,9 +381,12 @@ def initialize_app(app_name: str, template: str | None = None) -> str | None:
381381
# Local imports to avoid circular imports.
382382
from reflex.utils import telemetry
383383

384+
# Snapshot must reflect the user's CWD, not files the template would create.
385+
init_environment = telemetry.get_init_environment()
386+
384387
# Check if the app is already initialized.
385388
if constants.Config.FILE.exists():
386-
telemetry.send("reinit")
389+
telemetry.send("reinit", properties=init_environment)
387390
return None
388391

389392
templates: dict[str, Template] = {}
@@ -412,7 +415,7 @@ def initialize_app(app_name: str, template: str | None = None) -> str | None:
412415
app_name=app_name, template=template, templates=templates
413416
)
414417

415-
telemetry.send("init", template=template)
418+
telemetry.send("init", template=template, properties=init_environment)
416419

417420
return template
418421

tests/units/test_telemetry.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from types import SimpleNamespace
2+
13
import pytest
24
from packaging.version import parse as parse_python_version
35
from pytest_mock import MockerFixture
@@ -198,6 +200,131 @@ def test_prepare_event_does_not_mutate_cached_defaults(event_defaults):
198200
assert "duration_ms" not in event_defaults["properties"]
199201

200202

203+
@pytest.fixture
204+
def venv_state(monkeypatch: pytest.MonkeyPatch):
205+
"""Force a deterministic `is_in_virtualenv` reading.
206+
207+
Returns:
208+
A callable that overrides `sys.prefix`, `sys.base_prefix`, and the
209+
`VIRTUAL_ENV` env-var for the duration of the test.
210+
"""
211+
212+
def configure(*, prefix: str, base_prefix: str, virtual_env: str | None) -> None:
213+
monkeypatch.setattr(telemetry.sys, "prefix", prefix)
214+
monkeypatch.setattr(telemetry.sys, "base_prefix", base_prefix)
215+
if virtual_env is None:
216+
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
217+
else:
218+
monkeypatch.setenv("VIRTUAL_ENV", virtual_env)
219+
220+
return configure
221+
222+
223+
def test_is_in_virtualenv_detects_pep_405_venv(venv_state):
224+
venv_state(prefix="/tmp/venv", base_prefix="/usr", virtual_env=None)
225+
assert telemetry.is_in_virtualenv() is True
226+
227+
228+
def test_is_in_virtualenv_falls_back_to_virtual_env_var(venv_state):
229+
venv_state(prefix="/usr", base_prefix="/usr", virtual_env="/tmp/venv")
230+
assert telemetry.is_in_virtualenv() is True
231+
232+
233+
def test_is_in_virtualenv_returns_false_for_system_python(venv_state):
234+
venv_state(prefix="/usr", base_prefix="/usr", virtual_env=None)
235+
assert telemetry.is_in_virtualenv() is False
236+
237+
238+
@pytest.fixture
239+
def patch_telemetry_config(mocker: MockerFixture):
240+
"""Patch ``telemetry.get_config`` with a stub of a chosen ``telemetry_enabled``.
241+
242+
Returns:
243+
A callable ``patch(enabled)`` that installs the mock on demand.
244+
"""
245+
246+
def patch(*, enabled: bool) -> None:
247+
mocker.patch(
248+
"reflex.utils.telemetry.get_config",
249+
return_value=SimpleNamespace(telemetry_enabled=enabled),
250+
)
251+
252+
return patch
253+
254+
255+
@pytest.fixture
256+
def init_environment_cwd(
257+
tmp_path, monkeypatch: pytest.MonkeyPatch, patch_telemetry_config
258+
):
259+
"""Chdir into a clean tmp dir and force telemetry-enabled config.
260+
261+
Returns:
262+
The temporary directory now serving as the working directory.
263+
"""
264+
monkeypatch.chdir(tmp_path)
265+
patch_telemetry_config(enabled=True)
266+
return tmp_path
267+
268+
269+
def test_get_init_environment_reports_dependency_files(
270+
init_environment_cwd, venv_state
271+
):
272+
(init_environment_cwd / "pyproject.toml").write_text("")
273+
(init_environment_cwd / "uv.lock").write_text("")
274+
(init_environment_cwd / "reflex.lock").mkdir()
275+
venv_state(prefix="/tmp/venv", base_prefix="/usr", virtual_env=None)
276+
277+
assert telemetry.get_init_environment() == {
278+
"in_virtualenv": True,
279+
"has_pyproject_toml": True,
280+
"has_requirements_txt": False,
281+
"has_uv_lock": True,
282+
"has_reflex_lock": True,
283+
}
284+
285+
286+
def test_get_init_environment_reports_requirements_txt(
287+
init_environment_cwd, venv_state
288+
):
289+
(init_environment_cwd / "requirements.txt").write_text("")
290+
venv_state(prefix="/usr", base_prefix="/usr", virtual_env="/tmp/venv")
291+
292+
assert telemetry.get_init_environment() == {
293+
"in_virtualenv": True,
294+
"has_pyproject_toml": False,
295+
"has_requirements_txt": True,
296+
"has_uv_lock": False,
297+
"has_reflex_lock": False,
298+
}
299+
300+
301+
def test_get_init_environment_empty_directory(init_environment_cwd, venv_state):
302+
venv_state(prefix="/usr", base_prefix="/usr", virtual_env=None)
303+
304+
assert telemetry.get_init_environment() == {
305+
"in_virtualenv": False,
306+
"has_pyproject_toml": False,
307+
"has_requirements_txt": False,
308+
"has_uv_lock": False,
309+
"has_reflex_lock": False,
310+
}
311+
312+
313+
def test_get_init_environment_short_circuits_when_telemetry_disabled(
314+
tmp_path, monkeypatch: pytest.MonkeyPatch, patch_telemetry_config
315+
):
316+
"""When telemetry is disabled the env snapshot is skipped entirely.
317+
318+
A pyproject.toml is staged so a non-short-circuiting implementation would
319+
surface ``has_pyproject_toml: True`` instead of an empty dict.
320+
"""
321+
monkeypatch.chdir(tmp_path)
322+
(tmp_path / "pyproject.toml").write_text("")
323+
patch_telemetry_config(enabled=False)
324+
325+
assert telemetry.get_init_environment() == {}
326+
327+
201328
def test_prepare_event_properties_override_kwargs(event_defaults):
202329
"""If both kwargs and properties supply the same key, properties wins."""
203330
event = telemetry._prepare_event(

0 commit comments

Comments
 (0)