reflex-otel 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ **/.DS_Store
2
+ **/*.pyc
3
+ **/__pycache__/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ **/.ruff_cache/
7
+ .mypy_cache/
8
+ assets/external/*
9
+ dist/*
10
+ examples/
11
+ .web
12
+ .states
13
+ .idea
14
+ .vscode
15
+ .coverage
16
+ .coverage.*
17
+ .venv
18
+ venv
19
+ requirements.txt
20
+ .pyi_generator_last_run
21
+ .pyi_generator_diff
22
+ reflex.db
23
+ .codspeed
24
+ .env
25
+ .env.*
26
+ node_modules
27
+ package-lock.json
28
+ *.pyi
29
+ .pre-commit-config.yaml
30
+ .claude/.worktrees
31
+ .claude/settings.local.json
32
+ CLAUDE.local.md
33
+
34
+ # Backups written by scripts/delete_automated_releases.sh
35
+ automated-releases-backup-*.json
@@ -0,0 +1,9 @@
1
+
2
+
3
+ <!-- towncrier release notes start -->
4
+
5
+ ## v0.1.0a1 (2026-09-10)
6
+
7
+ ### Features
8
+
9
+ - Add the `reflex-otel` package: an OpenTelemetry instrumentor that turns on the framework's built-in trace points and metrics (one span per event handler run, chained events parented under the enqueuing span, frontend `traceparent` propagation, event/state/websocket metrics, compile spans) and wraps the ASGI app in the OpenTelemetry ASGI middleware. `OtelPlugin(endpoint=...)` adds browser tracing (a `traceparent` on sampled events and uploads, web vitals, React render timing) to the compiled frontend; without an endpoint nothing is exported. Failed browser exports (for example a collector without CORS) are reported through the app's `frontend_exception_handler`. ([#6227](https://github.com/reflex-dev/reflex/issues/6227))
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.5
2
+ Name: reflex-otel
3
+ Version: 0.1.0a1
4
+ Summary: OpenTelemetry instrumentation for the Reflex framework.
5
+ Author-email: Khaleel Al-Adhami <khaleel@reflex.dev>
6
+ Maintainer-email: Khaleel Al-Adhami <khaleel@reflex.dev>
7
+ License: Apache-2.0
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: opentelemetry-api<2.0,>=1.30.0
10
+ Requires-Dist: opentelemetry-instrumentation-asgi<1.0,>=0.49b0
11
+ Requires-Dist: opentelemetry-instrumentation<1.0,>=0.49b0
12
+ Requires-Dist: reflex-base>=0.9.11a1
13
+ Description-Content-Type: text/markdown
14
+
15
+ # reflex-otel
16
+
17
+ OpenTelemetry instrumentation for the Reflex framework.
18
+
19
+ ```python
20
+ from reflex_otel import ReflexInstrumentor
21
+
22
+ ReflexInstrumentor().instrument()
23
+ ```
24
+
25
+ Nothing in the app module needs guarding: a second `instrument()` is a silent
26
+ no-op, so the line above can run every time the module is imported.
27
+
28
+ Configure the SDK the way you prefer:
29
+
30
+ - Set the standard variables and let `instrument()` do it. When
31
+ `OTEL_TRACES_EXPORTER` or `OTEL_METRICS_EXPORTER` is set and no SDK provider
32
+ has been installed yet, `instrument()` configures `opentelemetry-sdk` from
33
+ the `OTEL_*` environment, exactly as `opentelemetry-instrument` would:
34
+
35
+ ```bash
36
+ OTEL_SERVICE_NAME=myapp OTEL_TRACES_EXPORTER=otlp OTEL_METRICS_EXPORTER=otlp \
37
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 reflex run
38
+ ```
39
+
40
+ - Or build the providers yourself and pass them:
41
+ `ReflexInstrumentor().instrument(tracer_provider=provider, meter_provider=meter_provider)`.
42
+ Do that from a module that is imported once (not the app module, which
43
+ test harnesses may re-import) or the SDK warns about overriding providers.
44
+
45
+ - Or use no code at all: the package registers an `opentelemetry_instrumentor`
46
+ entry point, so `opentelemetry-instrument reflex run` enables it (the
47
+ auto-instrumentation `sitecustomize` reaches the backend worker through the
48
+ inherited `PYTHONPATH`).
49
+
50
+ ## What you get
51
+
52
+ Traces:
53
+
54
+ - One span per event handler run, named after the event: `CONSUMER` for
55
+ events sent by the frontend (a new trace, or a child of the browser's
56
+ `PRODUCER` span when the event carries a `traceparent` field), `INTERNAL`
57
+ for chained events, which are children of the span that enqueued them.
58
+ Only string `traceparent`/`tracestate` fields are read from the event;
59
+ they never reach the handler, and `baggage` or anything else a client
60
+ sends is ignored. The sampled flag of a client `traceparent` is honoured
61
+ by the SDK's default parent-based sampler, so a client decides whether its
62
+ own events are recorded; use `ParentBased(root=..., remote_parent_sampled=...,
63
+ remote_parent_not_sampled=...)` or `OTEL_TRACES_SAMPLER=always_on` to keep
64
+ that decision on the server.
65
+ - HTTP requests and the websocket connection are wrapped in the standard
66
+ OpenTelemetry ASGI middleware (per-message websocket spans are off).
67
+ - One `reflex.compile` span per app compile (`reflex.compile.trigger`,
68
+ `reflex.compile.dry_run`) with the stages `reflex.compile.evaluate_pages`,
69
+ `.pages`, `.copy_assets`, `.install_frontend_packages`, `.write` as
70
+ child spans.
71
+
72
+ What leaves the process: event and handler names, a pseudonymous
73
+ `session.id` (a truncated SHA-256 of the client token, never the token
74
+ itself), exception types, messages and stack traces of failed handlers, and
75
+ the ASGI middleware's request attributes with the `token` query parameter of
76
+ the websocket URL redacted. Event payloads and state are never recorded.
77
+
78
+ Metrics:
79
+
80
+ | Instrument | Type | Unit | Attributes |
81
+ | --- | --- | --- | --- |
82
+ | `reflex.event.duration` | histogram | s | `reflex.event.name`, `reflex.event.background`, `error.type` |
83
+ | `reflex.state.acquire.duration` | histogram | s | `reflex.event.name` |
84
+ | `reflex.websocket.message.size` | histogram | By | `network.io.direction` (`transmit`/`receive`); default `sio` only |
85
+ | `reflex.websocket.connections` | up-down counter | `{connection}` | |
86
+
87
+ Plus the ASGI middleware's `http.server.*` metrics. The instrumentor opts the
88
+ middleware into the stable HTTP semantic conventions
89
+ (`OTEL_SEMCONV_STABILITY_OPT_IN=http`) unless that variable is already set,
90
+ so request attributes use the same generation of names as Reflex's own.
91
+
92
+ ## Browser (frontend) tracing
93
+
94
+ ```python
95
+ # rxconfig.py
96
+ from reflex_otel import OtelPlugin
97
+
98
+ config = rx.Config(
99
+ app_name="myapp",
100
+ plugins=[OtelPlugin(endpoint="https://collector.example.com/v1/traces")],
101
+ )
102
+ ```
103
+
104
+ The plugin compiles a small OpenTelemetry web bundle into the frontend:
105
+
106
+ - every event sent to the backend gets a `PRODUCER` span and a W3C
107
+ `traceparent`, so the backend event span joins the browser trace (one trace
108
+ per interaction, browser → backend → chained events);
109
+ - web vitals (`web_vital.LCP`, `CLS`, `INP`, `FCP`, `TTFB`) as spans with
110
+ `web_vital.value` / `web_vital.rating`;
111
+ - with `render_timing=True`, React commits as `react.render` spans
112
+ (`react.render.phase`, `react.render.actual_duration_ms`); this aliases
113
+ `react-dom/client` to the `react-dom/profiling` build and emits one span per
114
+ commit, so it is off by default;
115
+ - `socket.connect` / `socket.disconnect` spans for reconnect tracking
116
+ (unintentional disconnects are marked as errors).
117
+
118
+ Options: `endpoint` (OTLP/HTTP traces URL reachable from the browser; required
119
+ to export, with no default and no `OTEL_EXPORTER_OTLP_*` fallback: without it
120
+ no exporter is installed and browser spans are dropped), `service_name` (default
121
+ `<app_name>-frontend`), `headers` (compiled into the public bundle — no
122
+ secrets), `web_vitals`, `render_timing`. The endpoint must allow CORS from the
123
+ app origin.
124
+
125
+ ## Options
126
+
127
+ `instrument()` accepts `tracer_provider`, `meter_provider`, `excluded_urls`
128
+ (comma-separated URL patterns skipped by the ASGI middleware; defaults to
129
+ `OTEL_PYTHON_REFLEX_EXCLUDED_URLS`, else `OTEL_PYTHON_EXCLUDED_URLS`, else
130
+ `/ping` plus the compiled frontend's `/assets/` when the backend serves it,
131
+ as `reflex run --env prod` does on one port; pass `""`, or set the variable
132
+ to an empty string, to exclude nothing) and the ASGI hooks
133
+ `server_request_hook`, `client_request_hook`, `client_response_hook`.
134
+ Call `instrument()` before the app is served: `uninstrument()` turns the
135
+ framework trace points off again, but an ASGI middleware that was already
136
+ installed stays until the process restarts.
@@ -0,0 +1,122 @@
1
+ # reflex-otel
2
+
3
+ OpenTelemetry instrumentation for the Reflex framework.
4
+
5
+ ```python
6
+ from reflex_otel import ReflexInstrumentor
7
+
8
+ ReflexInstrumentor().instrument()
9
+ ```
10
+
11
+ Nothing in the app module needs guarding: a second `instrument()` is a silent
12
+ no-op, so the line above can run every time the module is imported.
13
+
14
+ Configure the SDK the way you prefer:
15
+
16
+ - Set the standard variables and let `instrument()` do it. When
17
+ `OTEL_TRACES_EXPORTER` or `OTEL_METRICS_EXPORTER` is set and no SDK provider
18
+ has been installed yet, `instrument()` configures `opentelemetry-sdk` from
19
+ the `OTEL_*` environment, exactly as `opentelemetry-instrument` would:
20
+
21
+ ```bash
22
+ OTEL_SERVICE_NAME=myapp OTEL_TRACES_EXPORTER=otlp OTEL_METRICS_EXPORTER=otlp \
23
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 reflex run
24
+ ```
25
+
26
+ - Or build the providers yourself and pass them:
27
+ `ReflexInstrumentor().instrument(tracer_provider=provider, meter_provider=meter_provider)`.
28
+ Do that from a module that is imported once (not the app module, which
29
+ test harnesses may re-import) or the SDK warns about overriding providers.
30
+
31
+ - Or use no code at all: the package registers an `opentelemetry_instrumentor`
32
+ entry point, so `opentelemetry-instrument reflex run` enables it (the
33
+ auto-instrumentation `sitecustomize` reaches the backend worker through the
34
+ inherited `PYTHONPATH`).
35
+
36
+ ## What you get
37
+
38
+ Traces:
39
+
40
+ - One span per event handler run, named after the event: `CONSUMER` for
41
+ events sent by the frontend (a new trace, or a child of the browser's
42
+ `PRODUCER` span when the event carries a `traceparent` field), `INTERNAL`
43
+ for chained events, which are children of the span that enqueued them.
44
+ Only string `traceparent`/`tracestate` fields are read from the event;
45
+ they never reach the handler, and `baggage` or anything else a client
46
+ sends is ignored. The sampled flag of a client `traceparent` is honoured
47
+ by the SDK's default parent-based sampler, so a client decides whether its
48
+ own events are recorded; use `ParentBased(root=..., remote_parent_sampled=...,
49
+ remote_parent_not_sampled=...)` or `OTEL_TRACES_SAMPLER=always_on` to keep
50
+ that decision on the server.
51
+ - HTTP requests and the websocket connection are wrapped in the standard
52
+ OpenTelemetry ASGI middleware (per-message websocket spans are off).
53
+ - One `reflex.compile` span per app compile (`reflex.compile.trigger`,
54
+ `reflex.compile.dry_run`) with the stages `reflex.compile.evaluate_pages`,
55
+ `.pages`, `.copy_assets`, `.install_frontend_packages`, `.write` as
56
+ child spans.
57
+
58
+ What leaves the process: event and handler names, a pseudonymous
59
+ `session.id` (a truncated SHA-256 of the client token, never the token
60
+ itself), exception types, messages and stack traces of failed handlers, and
61
+ the ASGI middleware's request attributes with the `token` query parameter of
62
+ the websocket URL redacted. Event payloads and state are never recorded.
63
+
64
+ Metrics:
65
+
66
+ | Instrument | Type | Unit | Attributes |
67
+ | --- | --- | --- | --- |
68
+ | `reflex.event.duration` | histogram | s | `reflex.event.name`, `reflex.event.background`, `error.type` |
69
+ | `reflex.state.acquire.duration` | histogram | s | `reflex.event.name` |
70
+ | `reflex.websocket.message.size` | histogram | By | `network.io.direction` (`transmit`/`receive`); default `sio` only |
71
+ | `reflex.websocket.connections` | up-down counter | `{connection}` | |
72
+
73
+ Plus the ASGI middleware's `http.server.*` metrics. The instrumentor opts the
74
+ middleware into the stable HTTP semantic conventions
75
+ (`OTEL_SEMCONV_STABILITY_OPT_IN=http`) unless that variable is already set,
76
+ so request attributes use the same generation of names as Reflex's own.
77
+
78
+ ## Browser (frontend) tracing
79
+
80
+ ```python
81
+ # rxconfig.py
82
+ from reflex_otel import OtelPlugin
83
+
84
+ config = rx.Config(
85
+ app_name="myapp",
86
+ plugins=[OtelPlugin(endpoint="https://collector.example.com/v1/traces")],
87
+ )
88
+ ```
89
+
90
+ The plugin compiles a small OpenTelemetry web bundle into the frontend:
91
+
92
+ - every event sent to the backend gets a `PRODUCER` span and a W3C
93
+ `traceparent`, so the backend event span joins the browser trace (one trace
94
+ per interaction, browser → backend → chained events);
95
+ - web vitals (`web_vital.LCP`, `CLS`, `INP`, `FCP`, `TTFB`) as spans with
96
+ `web_vital.value` / `web_vital.rating`;
97
+ - with `render_timing=True`, React commits as `react.render` spans
98
+ (`react.render.phase`, `react.render.actual_duration_ms`); this aliases
99
+ `react-dom/client` to the `react-dom/profiling` build and emits one span per
100
+ commit, so it is off by default;
101
+ - `socket.connect` / `socket.disconnect` spans for reconnect tracking
102
+ (unintentional disconnects are marked as errors).
103
+
104
+ Options: `endpoint` (OTLP/HTTP traces URL reachable from the browser; required
105
+ to export, with no default and no `OTEL_EXPORTER_OTLP_*` fallback: without it
106
+ no exporter is installed and browser spans are dropped), `service_name` (default
107
+ `<app_name>-frontend`), `headers` (compiled into the public bundle — no
108
+ secrets), `web_vitals`, `render_timing`. The endpoint must allow CORS from the
109
+ app origin.
110
+
111
+ ## Options
112
+
113
+ `instrument()` accepts `tracer_provider`, `meter_provider`, `excluded_urls`
114
+ (comma-separated URL patterns skipped by the ASGI middleware; defaults to
115
+ `OTEL_PYTHON_REFLEX_EXCLUDED_URLS`, else `OTEL_PYTHON_EXCLUDED_URLS`, else
116
+ `/ping` plus the compiled frontend's `/assets/` when the backend serves it,
117
+ as `reflex run --env prod` does on one port; pass `""`, or set the variable
118
+ to an empty string, to exclude nothing) and the ASGI hooks
119
+ `server_request_hook`, `client_request_hook`, `client_response_hook`.
120
+ Call `instrument()` before the app is served: `uninstrument()` turns the
121
+ framework trace points off again, but an ASGI middleware that was already
122
+ installed stays until the process restarts.
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "reflex-otel"
3
+ dynamic = ["version"]
4
+ description = "OpenTelemetry instrumentation for the Reflex framework."
5
+ license.text = "Apache-2.0"
6
+ readme = "README.md"
7
+ authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }]
8
+ maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }]
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "opentelemetry-api >=1.30.0,<2.0",
12
+ "opentelemetry-instrumentation >=0.49b0,<1.0",
13
+ "opentelemetry-instrumentation-asgi >=0.49b0,<1.0",
14
+ # A dev floor above every published reflex-base release: the release tooling
15
+ # lifts it to the earliest published version that satisfies it, which is the
16
+ # first release to ship reflex_base/otel.py.
17
+ "reflex-base >= 0.9.11a1",
18
+ ]
19
+
20
+ [project.entry-points.opentelemetry_instrumentor]
21
+ reflex = "reflex_otel.instrumentor:ReflexInstrumentor"
22
+
23
+ [tool.hatch.version]
24
+ source = "uv-dynamic-versioning"
25
+
26
+ [tool.uv-dynamic-versioning]
27
+ pattern-prefix = "reflex-otel-"
28
+ fallback-version = "0.0.0dev0"
29
+
30
+ [build-system]
31
+ requires = ["hatchling", "uv-dynamic-versioning"]
32
+ build-backend = "hatchling.build"
@@ -0,0 +1,37 @@
1
+ """OpenTelemetry instrumentation for the Reflex framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from reflex_otel.plugin import OtelPlugin
8
+
9
+ if TYPE_CHECKING:
10
+ from reflex_otel.instrumentor import ReflexInstrumentor
11
+
12
+
13
+ def __getattr__(name: str) -> Any:
14
+ """Load the instrumentor on first use.
15
+
16
+ ``rxconfig.py`` imports ``OtelPlugin`` in every CLI process; the instrumentor
17
+ pulls in ``opentelemetry.instrumentation`` (and ``wrapt``), which only the
18
+ backend needs.
19
+
20
+ Args:
21
+ name: The attribute being looked up.
22
+
23
+ Returns:
24
+ The instrumentor class.
25
+
26
+ Raises:
27
+ AttributeError: For any other name.
28
+ """
29
+ if name == "ReflexInstrumentor":
30
+ from reflex_otel.instrumentor import ReflexInstrumentor
31
+
32
+ return ReflexInstrumentor
33
+ msg = f"module {__name__!r} has no attribute {name!r}"
34
+ raise AttributeError(msg)
35
+
36
+
37
+ __all__ = ["OtelPlugin", "ReflexInstrumentor"]
@@ -0,0 +1,241 @@
1
+ """The Reflex instrumentor: turns the framework's built-in trace points on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import re
8
+ from collections.abc import Callable, Collection
9
+ from typing import TYPE_CHECKING, Any, Literal
10
+
11
+ from opentelemetry import trace
12
+ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
13
+ from reflex_base import otel
14
+ from reflex_base.config import get_config
15
+ from reflex_base.environment import environment
16
+
17
+ if TYPE_CHECKING:
18
+ from opentelemetry.trace import Span
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Reflex's own attributes follow the current semantic conventions; the contrib
23
+ # ASGI middleware still defaults to the old HTTP names unless opted in, which
24
+ # would mix both generations in one trace. The contrib packages read the
25
+ # variable once per process, the first time any instrumentor's instrument()
26
+ # runs, so it is set as early as this package can: at import of this module,
27
+ # which every way of reaching the instrumentor goes through.
28
+ os.environ.setdefault("OTEL_SEMCONV_STABILITY_OPT_IN", "http")
29
+
30
+ # Per-message websocket spans are noise; Reflex emits one span per event instead.
31
+ _ASGI_EXCLUDED_SPANS: list[Literal["receive", "send"]] = ["receive", "send"]
32
+ # Frontend health polling; override with excluded_urls or the variables below.
33
+ _DEFAULT_EXCLUDED_URLS = "/ping"
34
+ _EXCLUDED_URLS_ENV_VARS = (
35
+ "OTEL_PYTHON_REFLEX_EXCLUDED_URLS",
36
+ "OTEL_PYTHON_EXCLUDED_URLS",
37
+ )
38
+ # The websocket connects with ?token=<client token>; the token authorizes access
39
+ # to the session's state, so it is stripped from the URL attributes the ASGI
40
+ # middleware records (old and current HTTP semantic conventions).
41
+ _URL_ATTRIBUTES = ("http.url", "url.full", "url.query")
42
+ # Setting either asks for the SDK to be configured from the environment.
43
+ _EXPORTER_ENV_VARS = ("OTEL_TRACES_EXPORTER", "OTEL_METRICS_EXPORTER")
44
+ _TOKEN_PARAM = re.compile(r"\btoken=[^&#]*")
45
+ _REDACTED_TOKEN = "token=REDACTED"
46
+
47
+
48
+ def _default_excluded_urls() -> str:
49
+ """URL patterns the ASGI middleware skips unless configured otherwise.
50
+
51
+ Returns:
52
+ The frontend health poll, plus the compiled frontend's static assets
53
+ when the backend serves them (``reflex run --env prod`` on one port),
54
+ where every chunk of a page load would otherwise get a span.
55
+ """
56
+ patterns = [_DEFAULT_EXCLUDED_URLS]
57
+ if environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.get():
58
+ assets = re.escape(get_config().prepend_frontend_path("/assets/"))
59
+ patterns.append(f"^[a-z]+://[^/]+{assets}")
60
+ return ",".join(patterns)
61
+
62
+
63
+ def _configure_sdk_from_environment() -> None:
64
+ """Install SDK providers from the ``OTEL_*`` environment when nobody has yet.
65
+
66
+ Runs only when an exporter is requested through ``OTEL_TRACES_EXPORTER``
67
+ or ``OTEL_METRICS_EXPORTER`` and the global tracer provider is still the
68
+ API's proxy, i.e. neither the app nor ``opentelemetry-instrument`` set the
69
+ SDK up. It is what lets an app module enable telemetry with a single,
70
+ repeatable ``instrument()`` call instead of building providers itself.
71
+ """
72
+ if not any(os.environ.get(name) for name in _EXPORTER_ENV_VARS):
73
+ return
74
+ if not isinstance(trace.get_tracer_provider(), trace.ProxyTracerProvider):
75
+ return
76
+ try:
77
+ # The configurator behind opentelemetry-instrument and the distro package.
78
+ from opentelemetry.sdk._configuration import _OTelSDKConfigurator
79
+ except ImportError:
80
+ logger.warning(
81
+ "OTEL_TRACES_EXPORTER / OTEL_METRICS_EXPORTER are set but "
82
+ "opentelemetry-sdk is not installed; nothing is exported."
83
+ )
84
+ return
85
+ try:
86
+ _OTelSDKConfigurator().configure()
87
+ except Exception:
88
+ logger.exception(
89
+ "Configuring the OpenTelemetry SDK from the environment failed:"
90
+ )
91
+
92
+
93
+ def _redact_token(span: Span, scope: Any) -> None:
94
+ """Strip the client token from a server span's URL attributes.
95
+
96
+ Args:
97
+ span: The span the ASGI middleware opened for the request.
98
+ scope: The ASGI scope (unused).
99
+ """
100
+ attributes = getattr(span, "attributes", None)
101
+ if not attributes:
102
+ return
103
+ for key in _URL_ATTRIBUTES:
104
+ value = attributes.get(key)
105
+ if isinstance(value, str) and "token=" in value:
106
+ span.set_attribute(key, _TOKEN_PARAM.sub(_REDACTED_TOKEN, value))
107
+
108
+
109
+ def _server_request_hook(
110
+ user_hook: Callable[[Span, Any], None] | None,
111
+ ) -> Callable[[Span, Any], None]:
112
+ """Chain the token redaction in front of the user's server request hook.
113
+
114
+ Args:
115
+ user_hook: The hook passed to ``instrument()``, if any.
116
+
117
+ Returns:
118
+ The hook to install on the ASGI middleware.
119
+ """
120
+ if user_hook is None:
121
+ return _redact_token
122
+
123
+ def hook(span: Span, scope: Any) -> None:
124
+ _redact_token(span, scope)
125
+ user_hook(span, scope)
126
+
127
+ return hook
128
+
129
+
130
+ class ReflexInstrumentor(BaseInstrumentor):
131
+ """Enable the trace points and metrics built into the Reflex runtime.
132
+
133
+ Usage::
134
+
135
+ ReflexInstrumentor().instrument(tracer_provider=provider)
136
+
137
+ or let ``opentelemetry-instrument`` load it through the
138
+ ``opentelemetry_instrumentor`` entry point.
139
+
140
+ Besides the per-event spans and metrics, the app's ASGI callable is
141
+ wrapped in the OpenTelemetry ASGI middleware, so HTTP requests (uploads,
142
+ custom API routes) and the websocket connection get server spans and
143
+ HTTP metrics as well.
144
+ """
145
+
146
+ def instrument(self, **kwargs: Any) -> None:
147
+ """Turn the Reflex trace points on, once per process.
148
+
149
+ A second call is a silent no-op, so the call needs no guard even when
150
+ the app module is imported more than once (test harnesses do that).
151
+
152
+ Args:
153
+ **kwargs: See :meth:`_instrument`.
154
+ """
155
+ if self.is_instrumented_by_opentelemetry:
156
+ return
157
+ super().instrument(**kwargs)
158
+
159
+ def instrumentation_dependencies(self) -> Collection[str]:
160
+ """Return the requirements the base class checks before instrumenting.
161
+
162
+ Empty on purpose: reflex-base is a hard dependency of this package, so
163
+ the resolver already enforces its floor at install time. A runtime
164
+ re-check would only reject the workspace's own development builds.
165
+
166
+ Returns:
167
+ No requirements.
168
+ """
169
+ return ()
170
+
171
+ def _instrument(self, **kwargs: Any) -> None:
172
+ """Turn on the Reflex trace points.
173
+
174
+ Args:
175
+ **kwargs: ``tracer_provider`` and ``meter_provider`` select the
176
+ providers (default: the global ones; when neither is given,
177
+ ``OTEL_TRACES_EXPORTER`` / ``OTEL_METRICS_EXPORTER`` set and no
178
+ SDK installed yet, the SDK is configured from ``OTEL_*``). ``excluded_urls`` is a
179
+ comma-separated list of URL patterns the ASGI middleware skips
180
+ (default: ``OTEL_PYTHON_REFLEX_EXCLUDED_URLS``, else
181
+ ``OTEL_PYTHON_EXCLUDED_URLS``, else ``/ping`` plus the compiled
182
+ frontend's ``/assets/`` when the backend serves it; pass ``""``
183
+ to exclude nothing).
184
+ ``server_request_hook``, ``client_request_hook`` and
185
+ ``client_response_hook`` are forwarded to the ASGI middleware.
186
+ """
187
+ if os.environ.get("OTEL_SDK_DISABLED", "").strip().lower() == "true":
188
+ # The SDK is a no-op; skip the per-event trace points as well.
189
+ logger.info("OTEL_SDK_DISABLED is set; Reflex trace points stay off.")
190
+ return
191
+ # Imported here so `from reflex_otel import OtelPlugin` in rxconfig.py
192
+ # stays cheap for CLI processes that never instrument.
193
+ from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
194
+ from opentelemetry.util.http import parse_excluded_urls
195
+
196
+ tracer_provider = kwargs.get("tracer_provider")
197
+ meter_provider = kwargs.get("meter_provider")
198
+ if tracer_provider is None and meter_provider is None:
199
+ _configure_sdk_from_environment()
200
+ excluded_urls = kwargs.get("excluded_urls")
201
+ if excluded_urls is None:
202
+ # A variable set to "" means "exclude nothing", like an empty kwarg.
203
+ for name in _EXCLUDED_URLS_ENV_VARS:
204
+ if (value := os.environ.get(name)) is not None:
205
+ excluded_urls = value
206
+ break
207
+
208
+ def asgi_middleware(app: otel.ASGIApp) -> otel.ASGIApp:
209
+ # Defaults resolve when the app builds its ASGI callable: only then
210
+ # is it known whether the compiled frontend is mounted into it.
211
+ urls = _default_excluded_urls() if excluded_urls is None else excluded_urls
212
+ # opentelemetry-instrumentation-asgi < 0.56b0 stores a str verbatim
213
+ # and then calls .url_disabled() on it, failing every request.
214
+ if isinstance(urls, str):
215
+ urls = parse_excluded_urls(urls)
216
+ return OpenTelemetryMiddleware(
217
+ app,
218
+ excluded_urls=urls,
219
+ server_request_hook=_server_request_hook(
220
+ kwargs.get("server_request_hook")
221
+ ),
222
+ client_request_hook=kwargs.get("client_request_hook"),
223
+ client_response_hook=kwargs.get("client_response_hook"),
224
+ tracer_provider=tracer_provider,
225
+ meter_provider=meter_provider,
226
+ exclude_spans=_ASGI_EXCLUDED_SPANS,
227
+ )
228
+
229
+ otel.enable(
230
+ tracer_provider=tracer_provider,
231
+ meter_provider=meter_provider,
232
+ asgi_middleware_factory=asgi_middleware,
233
+ )
234
+
235
+ def _uninstrument(self, **kwargs: Any) -> None:
236
+ """Turn off the Reflex trace points.
237
+
238
+ Args:
239
+ **kwargs: Ignored.
240
+ """
241
+ otel.disable()
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Browser-side OpenTelemetry for Reflex apps, installed by reflex_otel.OtelPlugin.
3
+ *
4
+ * - Every event and file upload sent to the backend is marked with a PRODUCER
5
+ * span; sampled sends carry a W3C `traceparent`, so the backend event span
6
+ * joins the browser trace.
7
+ * - Web vitals (LCP, CLS, INP, FCP, TTFB) are reported as spans.
8
+ * - React commits are reported as `react.render` spans via a root <Profiler>
9
+ * (opt-in; production builds need the react-dom profiling alias the plugin adds).
10
+ * - Socket connects/disconnects are recorded as spans for reconnect tracking.
11
+ *
12
+ * Configuration comes from `env.json` (`OTEL` key), written by the plugin.
13
+ */
14
+ import { createElement, Profiler } from "react";
15
+ import {
16
+ context,
17
+ SpanKind,
18
+ SpanStatusCode,
19
+ trace,
20
+ TraceFlags,
21
+ } from "@opentelemetry/api";
22
+ import {
23
+ setGlobalErrorHandler,
24
+ W3CTraceContextPropagator,
25
+ } from "@opentelemetry/core";
26
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
27
+ import { resourceFromAttributes } from "@opentelemetry/resources";
28
+ import {
29
+ AlwaysOffSampler,
30
+ BatchSpanProcessor,
31
+ ParentBasedSampler,
32
+ TraceIdRatioBasedSampler,
33
+ WebTracerProvider,
34
+ } from "@opentelemetry/sdk-trace-web";
35
+ import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
36
+ import env from "$/env.json";
37
+
38
+ const config = env.OTEL ?? {};
39
+ // No endpoint, no exporter: nothing is sampled, so no traceparent is injected
40
+ // either and the backend keeps tracing on its own.
41
+ const exporting = Boolean(config.endpoint);
42
+
43
+ const provider = new WebTracerProvider({
44
+ resource: resourceFromAttributes({ "service.name": config.service_name }),
45
+ // Browser spans are trace roots. Only a sampled span injects a `traceparent`
46
+ // (see onEventSend), so the backend inherits a positive decision and makes
47
+ // its own for everything else.
48
+ sampler: exporting
49
+ ? new ParentBasedSampler({
50
+ root: new TraceIdRatioBasedSampler(config.sample_rate ?? 1),
51
+ })
52
+ : new AlwaysOffSampler(),
53
+ spanProcessors: exporting
54
+ ? [
55
+ new BatchSpanProcessor(
56
+ new OTLPTraceExporter({
57
+ url: config.endpoint,
58
+ headers: config.headers,
59
+ }),
60
+ ),
61
+ ]
62
+ : [],
63
+ });
64
+ const tracer = provider.getTracer("reflex", config.version);
65
+
66
+ // The exporter swallows its own failures (a collector missing CORS headers
67
+ // surfaces only as the browser's console line), so route the first one through
68
+ // the same path as any other frontend exception: it reaches the backend's
69
+ // frontend_exception_handler and the terminal. Once per page: exports retry on
70
+ // every batch and would otherwise repeat the report.
71
+ let exportFailureReported = false;
72
+ setGlobalErrorHandler((error) => {
73
+ if (exportFailureReported || typeof window.onerror !== "function") {
74
+ return;
75
+ }
76
+ exportFailureReported = true;
77
+ const cause = error?.cause ? ` (${error.cause})` : "";
78
+ const hint = error?.cause
79
+ ? " A collector on another origin must allow CORS requests from this page."
80
+ : "";
81
+ const report = new Error(
82
+ `OtelPlugin: exporting browser spans to ${config.endpoint} failed: ${error?.message ?? error}${cause}.${hint}`,
83
+ );
84
+ report.name = "OtelExportError";
85
+ window.onerror(report.message, null, null, null, report);
86
+ });
87
+ const propagator = new W3CTraceContextPropagator();
88
+
89
+ const setter = {
90
+ set(carrier, key, value) {
91
+ carrier[key] = value;
92
+ },
93
+ };
94
+
95
+ // Absolute epoch time (ms) of a performance timeline offset.
96
+ const epoch = (offset) => performance.timeOrigin + offset;
97
+
98
+ let connectCount = 0;
99
+
100
+ // Fire-and-forget to the backend: a PRODUCER span that marks the send. The
101
+ // browser has no completion signal for an event, so it has no duration. An
102
+ // unsampled span carries no traceparent: the backend then samples the event
103
+ // as a root of its own instead of inheriting "not sampled".
104
+ const markSend = (name, carrier) => {
105
+ const span = tracer.startSpan(name, {
106
+ kind: SpanKind.PRODUCER,
107
+ attributes: { "reflex.event.name": name },
108
+ });
109
+ if (span.spanContext().traceFlags & TraceFlags.SAMPLED) {
110
+ propagator.inject(trace.setSpan(context.active(), span), carrier, setter);
111
+ }
112
+ span.end();
113
+ };
114
+
115
+ window.__reflex_otel = {
116
+ onEventSend(event) {
117
+ markSend(event.name, event);
118
+ },
119
+ onUploadSend(handler, headers) {
120
+ // The upload is an HTTP request: the traceparent travels as a header and
121
+ // the backend's request span, then the handler span, join the trace.
122
+ markSend(handler, headers);
123
+ },
124
+ onSocketConnect() {
125
+ connectCount += 1;
126
+ tracer
127
+ .startSpan("socket.connect", {
128
+ attributes: { "reflex.socket.connect_count": connectCount },
129
+ })
130
+ .end();
131
+ },
132
+ onSocketDisconnect(reason) {
133
+ const span = tracer.startSpan("socket.disconnect", {
134
+ attributes: { "reflex.socket.disconnect_reason": reason },
135
+ });
136
+ // Intentional disconnects (navigation, server shutdown) are not errors.
137
+ if (!reason.startsWith("io ")) {
138
+ span.setStatus({ code: SpanStatusCode.ERROR, message: reason });
139
+ }
140
+ span.end();
141
+ // Unload disconnects happen after the processor's own pagehide flush ran,
142
+ // so the span would otherwise sit in the queue while the page tears down.
143
+ // Export failures are already reported by the global error handler.
144
+ provider.forceFlush().catch(() => {});
145
+ },
146
+ };
147
+
148
+ if (config.web_vitals) {
149
+ // FCP/LCP/TTFB values are offsets from the current navigation: activation
150
+ // start for a (pre)rendered page, `navigationStartTime` for a BFCache
151
+ // restore or soft navigation. INP is a duration starting at its interaction;
152
+ // CLS is unitless and reported as an instant.
153
+ const report = (metric) => {
154
+ const activationStart =
155
+ performance.getEntriesByType("navigation")[0]?.activationStart ?? 0;
156
+ const attributes = {
157
+ "web_vital.name": metric.name,
158
+ "web_vital.value": metric.value,
159
+ "web_vital.rating": metric.rating,
160
+ "web_vital.id": metric.id,
161
+ "web_vital.navigation_type": metric.navigationType,
162
+ };
163
+ let startTime = epoch(metric.navigationStartTime || activationStart);
164
+ let endTime = startTime + metric.value;
165
+ if (metric.name === "INP") {
166
+ startTime = epoch(metric.entries[0]?.startTime ?? 0);
167
+ endTime = startTime + metric.value;
168
+ } else if (metric.name === "CLS") {
169
+ startTime = endTime = Date.now();
170
+ }
171
+ tracer
172
+ .startSpan(`web_vital.${metric.name}`, { startTime, attributes })
173
+ .end(endTime);
174
+ };
175
+ onCLS(report);
176
+ onFCP(report);
177
+ onINP(report);
178
+ onLCP(report);
179
+ onTTFB(report);
180
+ }
181
+
182
+ const onRender = (
183
+ id,
184
+ phase,
185
+ actualDuration,
186
+ baseDuration,
187
+ startTime,
188
+ commitTime,
189
+ ) => {
190
+ tracer
191
+ .startSpan("react.render", {
192
+ startTime: epoch(startTime),
193
+ attributes: {
194
+ "react.profiler.id": id,
195
+ "react.render.phase": phase,
196
+ "react.render.actual_duration_ms": actualDuration,
197
+ "react.render.base_duration_ms": baseDuration,
198
+ },
199
+ })
200
+ .end(epoch(commitTime));
201
+ };
202
+
203
+ /**
204
+ * Root wrapper used by the patched entry: profiles React commits when enabled.
205
+ */
206
+ export const OtelRoot = ({ children }) =>
207
+ config.render_timing
208
+ ? createElement(Profiler, { id: "app", onRender }, children)
209
+ : children;
@@ -0,0 +1,193 @@
1
+ """Compile-time plugin that adds browser-side OpenTelemetry to a Reflex app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from reflex_base.config import get_config
12
+ from reflex_base.constants.base import ReactRouter, Reflex
13
+ from reflex_base.constants.compiler import Embed
14
+ from reflex_base.plugins.base import Plugin
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ BROWSER_MODULE = "utils/otel.js"
19
+ _BROWSER_MODULE_SOURCE = Path(__file__).with_name("otel.js")
20
+
21
+ # Pinned npm packages for the browser module.
22
+ FRONTEND_DEPENDENCIES = (
23
+ "@opentelemetry/api@1.9.1",
24
+ "@opentelemetry/core@2.10.0",
25
+ "@opentelemetry/exporter-trace-otlp-http@0.221.0",
26
+ "@opentelemetry/resources@2.10.0",
27
+ "@opentelemetry/sdk-trace-web@2.10.0",
28
+ "web-vitals@6.1.1",
29
+ )
30
+
31
+ _ENTRY_IMPORT = 'import { OtelRoot } from "$/utils/otel";\n'
32
+ # React roots rendered by the entry: the hydrated document root, and the
33
+ # embed entry's client-rendered root. Each is wrapped in the profiling root.
34
+ _ENTRY_ROOT_ANCHORS = (
35
+ "createElement(HydratedRouter)",
36
+ "createElement(RouterProvider, { router })",
37
+ )
38
+
39
+ # React strips <Profiler> from production builds; the profiling build keeps it.
40
+ # The alias is appended to the existing `resolve.alias` array of the generated
41
+ # config: a second `resolve` key would silently override the first one. The
42
+ # anchor tolerates whitespace changes in the template.
43
+ _VITE_CONFIG_ANCHOR = re.compile(
44
+ r"resolve:\s*\{\s*mainFields:\s*\[[^\]]*\],\s*alias:\s*\[[ \t]*\n"
45
+ )
46
+ _VITE_PROFILING_ALIAS = (
47
+ ' { find: "react-dom/client", replacement: "react-dom/profiling" },\n'
48
+ )
49
+
50
+
51
+ def _patch_entry_client(content: str) -> str:
52
+ """Load the browser module and profile the React root in ``entry.client.js``.
53
+
54
+ Args:
55
+ content: The current entry file.
56
+
57
+ Returns:
58
+ The patched entry file.
59
+ """
60
+ if _ENTRY_IMPORT in content:
61
+ return content
62
+ if not any(anchor in content for anchor in _ENTRY_ROOT_ANCHORS):
63
+ logger.warning(
64
+ "OtelPlugin: no React root found in %s; render timing is disabled.",
65
+ Embed.ENTRY_PATH,
66
+ )
67
+ for anchor in _ENTRY_ROOT_ANCHORS:
68
+ content = content.replace(anchor, f"createElement(OtelRoot, null, {anchor})", 1)
69
+ return _ENTRY_IMPORT + content
70
+
71
+
72
+ def _patch_vite_config(content: str) -> str:
73
+ """Alias react-dom to its profiling build so <Profiler> works in production.
74
+
75
+ Args:
76
+ content: The current ``vite.config.js``.
77
+
78
+ Returns:
79
+ The patched config.
80
+
81
+ Raises:
82
+ RuntimeError: If the config no longer has the expected shape.
83
+ """
84
+ if _VITE_PROFILING_ALIAS in content:
85
+ return content
86
+ match = _VITE_CONFIG_ANCHOR.search(content)
87
+ if match is None:
88
+ msg = (
89
+ "OtelPlugin cannot enable render_timing: the resolve.alias block "
90
+ f"was not found in {ReactRouter.VITE_CONFIG_FILE}."
91
+ )
92
+ raise RuntimeError(msg)
93
+ return content[: match.end()] + _VITE_PROFILING_ALIAS + content[match.end() :]
94
+
95
+
96
+ # repr=False keeps Plugin.__repr__ (headers would print otherwise); eq=False
97
+ # keeps instances hashable, like every other plugin.
98
+ @dataclass(repr=False, eq=False)
99
+ class OtelPlugin(Plugin):
100
+ """Ship the reflex-otel browser module with the compiled frontend.
101
+
102
+ Add it to ``rx.Config(plugins=[...])``. Every event sent to the backend
103
+ then carries a W3C ``traceparent`` (the backend event span becomes a
104
+ child of the browser span), and web vitals, React commit timings and
105
+ socket (re)connects are exported as spans.
106
+
107
+ The endpoint must accept OTLP/HTTP from the browser (CORS). ``headers``
108
+ are compiled into the public bundle, so never put secrets there.
109
+ """
110
+
111
+ # OTLP/HTTP traces URL the browser exports to. Deliberately no default and
112
+ # no OTEL_EXPORTER_OTLP_* fallback: those name the backend's collector,
113
+ # usually on a private network. Without it no exporter is installed.
114
+ endpoint: str | None = None
115
+ # Resource service.name of the browser spans; defaults to "<app_name>-frontend".
116
+ service_name: str | None = None
117
+ # Extra HTTP headers sent by the browser exporter (public!).
118
+ headers: dict[str, str] = field(default_factory=dict)
119
+ web_vitals: bool = True
120
+ # One `react.render` span per React commit (uses the react-dom profiling
121
+ # build in production). Off by default because of the span volume.
122
+ render_timing: bool = False
123
+ # Fraction of browser traces to sample (0..1). Browser spans are trace
124
+ # roots, so the backend's parent-based sampler follows this decision.
125
+ sample_rate: float = 1.0
126
+
127
+ def __post_init__(self):
128
+ """Validate the sampling ratio.
129
+
130
+ Raises:
131
+ ValueError: If ``sample_rate`` is outside ``[0, 1]``.
132
+ """
133
+ if not 0 <= self.sample_rate <= 1:
134
+ msg = f"sample_rate must be between 0 and 1, got {self.sample_rate!r}."
135
+ raise ValueError(msg)
136
+
137
+ def get_frontend_dependencies(self, **context: Any) -> tuple[str, ...]:
138
+ """Return the npm packages the browser module imports.
139
+
140
+ Args:
141
+ context: The context for the plugin.
142
+
143
+ Returns:
144
+ The pinned package specifiers.
145
+ """
146
+ return FRONTEND_DEPENDENCIES
147
+
148
+ def get_static_assets(self, **context: Any) -> list[tuple[Path, str]]:
149
+ """Return the browser module to write into ``.web``.
150
+
151
+ Args:
152
+ context: The context for the plugin.
153
+
154
+ Returns:
155
+ The module path and its source.
156
+ """
157
+ return [(Path(BROWSER_MODULE), _BROWSER_MODULE_SOURCE.read_text())]
158
+
159
+ def pre_compile(self, **context: Any) -> None:
160
+ """Patch the client entry to load the module and profile the root.
161
+
162
+ Args:
163
+ context: The pre-compile plugin context.
164
+ """
165
+ if self.endpoint is None:
166
+ logger.warning(
167
+ "OtelPlugin: no endpoint configured; browser spans are not exported."
168
+ )
169
+ context["add_modify_task"](Embed.ENTRY_PATH, _patch_entry_client)
170
+ if self.render_timing:
171
+ context["add_modify_task"](ReactRouter.VITE_CONFIG_FILE, _patch_vite_config)
172
+
173
+ def update_env_json(self, **context: Any) -> dict[str, Any]:
174
+ """Expose the browser configuration through ``env.json``.
175
+
176
+ Args:
177
+ context: The context for the plugin.
178
+
179
+ Returns:
180
+ The ``OTEL`` entry read by the browser module.
181
+ """
182
+ return {
183
+ "OTEL": {
184
+ "endpoint": self.endpoint,
185
+ "service_name": self.service_name
186
+ or f"{get_config().app_name}-frontend",
187
+ "headers": self.headers,
188
+ "web_vitals": self.web_vitals,
189
+ "render_timing": self.render_timing,
190
+ "sample_rate": self.sample_rate,
191
+ "version": Reflex.VERSION,
192
+ }
193
+ }
File without changes