Skip to content

Commit d361da3

Browse files
committed
feat(telemetry): track per-feature usage counters on compile events
Introduce a typed FeatureName registry and `increment_feature` helper so runtime call sites (uploads, cookies, storage, models, lifespan tasks, shared state, dynamic routes) and the compile-time collector (state-manager mode, CORS, background event handlers) feed a uniform counters map into the compile event. Always emit every known key so zeros are distinguishable from missing detectors.
1 parent 9ed3692 commit d361da3

10 files changed

Lines changed: 332 additions & 47 deletions

File tree

packages/reflex-components-core/src/reflex_components_core/core/upload.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from reflex_base.vars.sequence import ArrayVar, LiteralStringVar
3939
from reflex_components_sonner.toast import toast
4040

41+
from reflex.utils.telemetry_context import increment_feature
4142
from reflex_components_core.base.fragment import Fragment
4243
from reflex_components_core.core._upload import UploadChunkIterator, UploadFile
4344
from reflex_components_core.core.cond import cond
@@ -288,6 +289,7 @@ def create(cls, *children, **props) -> Component:
288289
"""
289290
# Mark the Upload component as used in the app.
290291
cls.is_used = True
292+
increment_feature("upload_count")
291293

292294
props.setdefault("multiple", True)
293295

pyi_hashes.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "7fd81a99bde5b0ff94bb52523597fd5c",
2121
"packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "753d6ae315369530dad450ed643f5be6",
2222
"packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "ba60a7d9cba75b27a1133bd63a9fbd59",
23-
"packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "2dd6ba6e3a4d61fc1d79eb582a7cc548",
23+
"packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "91d66d21b80fad4c0ebaa9c88274d2e2",
2424
"packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "5e1dcb1130bc8af282783fae329ae6a6",
2525
"packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "c96fed4da42a13576d64f84e3c7cb25c",
2626
"packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "f09129ddefb57ab4c7769c86dc9a3153",

reflex/app.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@
9696
should_prerender_routes,
9797
)
9898
from reflex.utils.misc import run_in_thread
99-
from reflex.utils.telemetry_context import CompileTrigger, TelemetryContext
99+
from reflex.utils.telemetry_context import (
100+
CompileTrigger,
101+
TelemetryContext,
102+
increment_feature,
103+
)
100104
from reflex.utils.token_manager import RedisTokenManager, TokenManager
101105

102106
if sys.version_info < (3, 13):
@@ -907,7 +911,10 @@ def add_page(
907911
# Setup dynamic args for the route.
908912
# this state assignment is only required for tests using the deprecated state kwarg for App
909913
state = self._state or State
910-
state.setup_dynamic_args(get_route_args(route))
914+
route_args = get_route_args(route)
915+
state.setup_dynamic_args(route_args)
916+
if route_args:
917+
increment_feature("dynamic_routes_count")
911918

912919
self._load_events[route] = (
913920
(on_load if isinstance(on_load, list) else [on_load])

reflex/app_mixins/lifespan.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from reflex_base.utils.exceptions import InvalidLifespanTaskTypeError
1616
from starlette.applications import Starlette
1717

18+
from reflex.utils.telemetry_context import increment_feature
19+
1820
from .mixin import AppMixin
1921

2022
if TYPE_CHECKING:
@@ -198,4 +200,7 @@ def register_lifespan_task(
198200
functools.update_wrapper(registered_task, task)
199201
self._lifespan_tasks[registered_task] = None
200202
console.debug(f"Registered lifespan task: {task_name}")
203+
module = getattr(registered_task, "__module__", None) or ""
204+
if module != "reflex" and not module.startswith("reflex."):
205+
increment_feature("lifespan_tasks_count")
201206
return task

reflex/istate/shared.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from reflex.istate.manager.token import BaseStateToken
1515
from reflex.state import BaseState, State, _override_base_method
16+
from reflex.utils.telemetry_context import increment_feature
1617

1718
UPDATE_OTHER_CLIENT_TASKS: set[asyncio.Task] = set()
1819
LINKED_STATE = TypeVar("LINKED_STATE", bound="SharedStateBaseInternal")
@@ -519,3 +520,4 @@ def __init_subclass__(cls, **kwargs):
519520
# pulls in all linked states and substates which may not actually be
520521
# accessed for this event.
521522
root_state._always_dirty_substates.add(SharedStateBaseInternal.get_name())
523+
increment_feature("shared_state_count")

reflex/istate/storage.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from reflex_base.utils import format
88

9+
from reflex.utils.telemetry_context import increment_feature
10+
911

1012
class ClientStorageBase:
1113
"""Base class for client-side storage."""
@@ -73,6 +75,7 @@ def __new__(
7375
inst.domain = domain
7476
inst.secure = secure
7577
inst.same_site = same_site
78+
increment_feature("cookie_count")
7679
return inst
7780

7881

@@ -109,6 +112,7 @@ def __new__(
109112
inst = super().__new__(cls, object)
110113
inst.name = name
111114
inst.sync = sync
115+
increment_feature("local_storage_count")
112116
return inst
113117

114118

@@ -141,4 +145,5 @@ def __new__(
141145
else:
142146
inst = super().__new__(cls, object)
143147
inst.name = name
148+
increment_feature("session_storage_count")
144149
return inst

reflex/model.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from reflex_base.utils import console
1414
from reflex_base.utils.serializers import serializer
1515

16+
from reflex.utils.telemetry_context import increment_feature
17+
1618
if TYPE_CHECKING:
1719
from typing import TypeVar
1820

@@ -200,6 +202,7 @@ def register(cls, model: SQLModelOrSqlAlchemyT) -> SQLModelOrSqlAlchemyT:
200202
The model passed in as an argument (Allows decorator usage)
201203
"""
202204
cls.models.add(model)
205+
increment_feature("db_model_count")
203206
return model
204207

205208
@classmethod

reflex/utils/telemetry_accounting.py

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,27 @@
33
from __future__ import annotations
44

55
from collections.abc import Iterable, Iterator
6-
from typing import TYPE_CHECKING, Any, TypedDict
6+
from typing import TYPE_CHECKING, TypedDict
77

88
from reflex_base.config import get_config
99
from reflex_base.utils import console
1010

1111
from reflex.utils import telemetry
12+
from reflex.utils.telemetry_context import (
13+
_KNOWN_FEATURES,
14+
TelemetryContext,
15+
_recorded_features,
16+
increment_feature,
17+
)
18+
19+
__all__ = ["increment_feature", "record_compile"]
1220

1321
if TYPE_CHECKING:
1422
from reflex_base.components.component import BaseComponent
1523

1624
from reflex.app import App
1725
from reflex.state import BaseState
18-
from reflex.utils.telemetry_context import CompileTrigger, TelemetryContext
26+
from reflex.utils.telemetry_context import CompileTrigger, FeatureName
1927

2028

2129
class _StateStats(TypedDict):
@@ -42,7 +50,7 @@ class _CompileEventProperties(TypedDict):
4250
pages_count: int
4351
component_counts: dict[str, int]
4452
states: list[_StateStats]
45-
features_used: dict[str, Any]
53+
features_used: dict[FeatureName, int]
4654
duration_ms: int
4755
trigger: CompileTrigger | None
4856
exception: _ExceptionInfo | None
@@ -78,13 +86,14 @@ def _collect_compile_event_payload(
7886
The properties dict to send to PostHog.
7987
"""
8088
config = get_config()
89+
user_states = list(_walk_states(app._state))
8190
return {
8291
"plugins_enabled": [p.__class__.__name__ for p in config.plugins],
8392
"plugins_disabled": [p.__name__ for p in config.disable_plugins],
8493
"pages_count": len(app._pages),
8594
"component_counts": _count_components(app._pages.values()),
86-
"states": _collect_all_state_stats(app),
87-
"features_used": dict(ctx.features_used),
95+
"states": [_collect_state_stats(s) for s in user_states],
96+
"features_used": _collect_features_used(ctx, user_states),
8897
"duration_ms": ctx.elapsed_ms(),
8998
"trigger": ctx.trigger,
9099
"exception": _sanitize_exception(ctx.exception),
@@ -141,18 +150,6 @@ def _walk_states(root: type[BaseState] | None) -> Iterator[type[BaseState]]:
141150
yield from _walk_states(sub)
142151

143152

144-
def _collect_all_state_stats(app: App) -> list[_StateStats]:
145-
"""Collect per-state statistics for every state attached to the app.
146-
147-
Args:
148-
app: The compiled application.
149-
150-
Returns:
151-
A list of per-state stat dicts.
152-
"""
153-
return [_collect_state_stats(state_cls) for state_cls in _walk_states(app._state)]
154-
155-
156153
def _collect_state_stats(state_cls: type[BaseState]) -> _StateStats:
157154
"""Collect structural statistics for a single state class.
158155
@@ -191,3 +188,64 @@ def _sanitize_exception(exc: BaseException | None) -> _ExceptionInfo | None:
191188
if exc is None:
192189
return None
193190
return {"type": type(exc).__name__}
191+
192+
193+
def _collect_features_used(
194+
ctx: TelemetryContext, user_states: list[type[BaseState]]
195+
) -> dict[FeatureName, int]:
196+
"""Build the ``features_used`` snapshot for the compile event.
197+
198+
Every known feature ships with its invocation count (zero by default), so
199+
consumers don't have to distinguish "feature not used" from "detector broken."
200+
201+
Args:
202+
ctx: The active telemetry context.
203+
user_states: Pre-walked user state classes (shared with state stats).
204+
205+
Returns:
206+
Dict of feature key -> invocation count.
207+
"""
208+
features: dict[FeatureName, int] = dict.fromkeys(_KNOWN_FEATURES, 0)
209+
features.update(_recorded_features)
210+
_record_config_attestations(features)
211+
_record_background_handler_count(features, user_states)
212+
features.update(ctx.features_used)
213+
return features
214+
215+
216+
_STATE_MANAGER_FEATURE: dict[str, FeatureName] = {
217+
"disk": "state_manager_disk",
218+
"memory": "state_manager_memory",
219+
"redis": "state_manager_redis",
220+
}
221+
222+
223+
def _record_config_attestations(features: dict[FeatureName, int]) -> None:
224+
"""Write config-derived feature counts (state-manager mode, CORS).
225+
226+
Args:
227+
features: The snapshot to populate.
228+
"""
229+
config = get_config()
230+
key = _STATE_MANAGER_FEATURE.get(config.state_manager_mode.value)
231+
if key is not None:
232+
features[key] = 1
233+
if tuple(config.cors_allowed_origins) != ("*",):
234+
features["cors_customized"] = 1
235+
236+
237+
def _record_background_handler_count(
238+
features: dict[FeatureName, int], user_states: list[type[BaseState]]
239+
) -> None:
240+
"""Count ``@rx.event(background=True)`` handlers across user states.
241+
242+
Args:
243+
features: The snapshot to populate.
244+
user_states: Pre-walked user state classes.
245+
"""
246+
features["background_event_handlers_count"] = sum(
247+
1
248+
for state_cls in user_states
249+
for handler in state_cls.event_handlers.values()
250+
if handler.is_background
251+
)

reflex/utils/telemetry_context.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import dataclasses
66
import time
7-
from typing import Any, Literal
7+
from typing import Literal, get_args
88

99
from reflex_base.config import get_config
1010
from reflex_base.context.base import BaseContext
@@ -13,13 +13,51 @@
1313
"initial", "cli_compile", "backend_startup", "hot_reload", "export"
1414
]
1515

16+
FeatureName = Literal[
17+
"background_event_handlers_count",
18+
"cookie_count",
19+
"cors_customized",
20+
"db_model_count",
21+
"dynamic_routes_count",
22+
"lifespan_tasks_count",
23+
"local_storage_count",
24+
"session_storage_count",
25+
"shared_state_count",
26+
"state_manager_disk",
27+
"state_manager_memory",
28+
"state_manager_redis",
29+
"upload_count",
30+
]
31+
32+
_KNOWN_FEATURES: tuple[FeatureName, ...] = get_args(FeatureName)
33+
34+
# Counters bumped outside an active compile context (import-time class
35+
# definitions, decorators, registrations) accumulate here so they survive
36+
# into the next compile event.
37+
_recorded_features: dict[FeatureName, int] = {}
38+
39+
40+
def increment_feature(name: FeatureName, by: int = 1) -> None:
41+
"""Bump a feature invocation counter.
42+
43+
Writes to the active TelemetryContext if one is attached, else to the
44+
process-level counter so import-time signals survive into the next compile.
45+
46+
Args:
47+
name: The feature counter to bump.
48+
by: How much to add. Defaults to 1.
49+
"""
50+
target = TelemetryContext.get()
51+
target_dict = target.features_used if target is not None else _recorded_features
52+
target_dict[name] = target_dict.get(name, 0) + by
53+
1654

1755
@dataclasses.dataclass(frozen=True, kw_only=True, slots=True, eq=False)
1856
class TelemetryContext(BaseContext):
1957
"""Per-compile telemetry handle attached to the current contextvar."""
2058

2159
start_perf_counter: float = dataclasses.field(default_factory=time.perf_counter)
22-
features_used: dict[str, Any] = dataclasses.field(default_factory=dict)
60+
features_used: dict[FeatureName, int] = dataclasses.field(default_factory=dict)
2361
trigger: CompileTrigger | None = None
2462
exception: BaseException | None = dataclasses.field(default=None, repr=False)
2563

0 commit comments

Comments
 (0)