From e25ba668fb87463bc979930ba3d9ed41df9c43d0 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 18 Aug 2026 00:36:29 +0500 Subject: [PATCH 1/5] feat(otel): browser tracing plugin with traceparent propagation, web vitals and render timing --- packages/reflex-base/news/6227.feature.md | 2 +- .../reflex_base/.templates/web/utils/state.js | 4 + packages/reflex-otel/README.md | 30 +++ packages/reflex-otel/news/6227.feature.md | 2 +- .../reflex-otel/src/reflex_otel/__init__.py | 5 + packages/reflex-otel/src/reflex_otel/otel.js | 137 ++++++++++++++ .../reflex-otel/src/reflex_otel/plugin.py | 177 ++++++++++++++++++ tests/units/reflex_otel/test_plugin.py | 89 +++++++++ 8 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 packages/reflex-otel/src/reflex_otel/otel.js create mode 100644 packages/reflex-otel/src/reflex_otel/plugin.py create mode 100644 tests/units/reflex_otel/test_plugin.py diff --git a/packages/reflex-base/news/6227.feature.md b/packages/reflex-base/news/6227.feature.md index f91d6bb90a9..0c6ff2c84c3 100644 --- a/packages/reflex-base/news/6227.feature.md +++ b/packages/reflex-base/news/6227.feature.md @@ -1 +1 @@ -Add inert OpenTelemetry trace points and metrics around event handler execution, state acquisition, socket messages and compile stages (`reflex_base.otel`); they cost one boolean check until the `reflex-otel` package enables them. +Add inert OpenTelemetry trace points and metrics around event handler execution, state acquisition, socket messages and compile stages (`reflex_base.otel`) and a `window.__reflex_otel` hook in the frontend event loop; they cost one boolean check until the `reflex-otel` package enables them. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 8ba6d00509c..a6179cea34b 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -426,6 +426,8 @@ export const applyEvent = async (event, socket, navigate, params) => { // Send the event to the server. if (socket) { + // Instrumentation hook (installed by reflex-otel): may add a traceparent. + window.__reflex_otel?.onEventSend(event); socket.emit("event", event); } }; @@ -673,6 +675,7 @@ export const connect = async ( socket.current.on("connect", async () => { socket.current.wait_connect = false; setConnectErrors([]); + window.__reflex_otel?.onSocketConnect(); window.addEventListener("pagehide", pagehideHandler); window.addEventListener("beforeunload", disconnectTrigger); if (socket.current.rehydrate) { @@ -702,6 +705,7 @@ export const connect = async ( socket.current.on("disconnect", (reason, details) => { socket.current.wait_connect = false; + window.__reflex_otel?.onSocketDisconnect(reason); const try_reconnect = reason !== "io server disconnect" && reason !== "io client disconnect"; window.removeEventListener("beforeunload", disconnectTrigger); diff --git a/packages/reflex-otel/README.md b/packages/reflex-otel/README.md index 566d16c3f78..db3e08babe4 100644 --- a/packages/reflex-otel/README.md +++ b/packages/reflex-otel/README.md @@ -50,6 +50,36 @@ Metrics: Plus the ASGI middleware's `http.server.*` metrics. +## Browser (frontend) tracing + +```python +# rxconfig.py +from reflex_otel import OtelPlugin + +config = rx.Config(app_name="myapp", plugins=[OtelPlugin()]) +``` + +The plugin compiles a small OpenTelemetry web bundle into the frontend: + +- every event sent to the backend gets a `PRODUCER` span and a W3C + `traceparent`, so the backend event span joins the browser trace (one trace + per interaction, browser → backend → chained events); +- web vitals (`web_vital.LCP`, `CLS`, `INP`, `FCP`, `TTFB`) as spans with + `web_vital.value` / `web_vital.rating`; +- with `render_timing=True`, React commits as `react.render` spans + (`react.render.phase`, `react.render.actual_duration_ms`); this aliases + `react-dom/client` to the `react-dom/profiling` build and emits one span per + commit, so it is off by default; +- `socket.connect` / `socket.disconnect` spans for reconnect tracking + (unintentional disconnects are marked as errors). + +Options: `endpoint` (OTLP/HTTP traces URL, defaults from +`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` / `OTEL_EXPORTER_OTLP_ENDPOINT`, else +`http://localhost:4318/v1/traces`), `service_name` (default +`-frontend`), `headers` (compiled into the public bundle — no +secrets), `web_vitals`, `render_timing`. The endpoint must allow CORS from the +app origin. + ## Options `instrument()` accepts `tracer_provider`, `meter_provider`, `excluded_urls` diff --git a/packages/reflex-otel/news/6227.feature.md b/packages/reflex-otel/news/6227.feature.md index 85e518cbfb6..1f5e8886fb0 100644 --- a/packages/reflex-otel/news/6227.feature.md +++ b/packages/reflex-otel/news/6227.feature.md @@ -1 +1 @@ -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. +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` adds browser tracing (traceparent per event, web vitals, React render timing) to the compiled frontend. diff --git a/packages/reflex-otel/src/reflex_otel/__init__.py b/packages/reflex-otel/src/reflex_otel/__init__.py index 2d1323faacc..979d6a127a0 100644 --- a/packages/reflex-otel/src/reflex_otel/__init__.py +++ b/packages/reflex-otel/src/reflex_otel/__init__.py @@ -9,6 +9,8 @@ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from reflex_base import otel +from reflex_otel.plugin import OtelPlugin + _instruments = ("reflex-base >= 0.9.7.post45.dev0",) # Per-message websocket spans are noise; Reflex emits one span per event instead. @@ -98,3 +100,6 @@ def _uninstrument(self, **kwargs: Any) -> None: **kwargs: Ignored. """ otel.disable() + + +__all__ = ["OtelPlugin", "ReflexInstrumentor"] diff --git a/packages/reflex-otel/src/reflex_otel/otel.js b/packages/reflex-otel/src/reflex_otel/otel.js new file mode 100644 index 00000000000..2a195ed3a23 --- /dev/null +++ b/packages/reflex-otel/src/reflex_otel/otel.js @@ -0,0 +1,137 @@ +/** + * Browser-side OpenTelemetry for Reflex apps, installed by reflex_otel.OtelPlugin. + * + * - Every event sent to the backend gets a CLIENT span and a W3C `traceparent`, + * so the backend event span joins the browser trace. + * - Web vitals (LCP, CLS, INP, FCP, TTFB) are reported as spans. + * - React commits are reported as `react.render` spans via a root + * (opt-in; production builds need the react-dom profiling alias the plugin adds). + * - Socket connects/disconnects are recorded as spans for reconnect tracking. + * + * Configuration comes from `env.json` (`OTEL` key), written by the plugin. + */ +import { createElement, Profiler } from "react"; +import { context, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; +import { W3CTraceContextPropagator } from "@opentelemetry/core"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { + BatchSpanProcessor, + WebTracerProvider, +} from "@opentelemetry/sdk-trace-web"; +import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals"; +import env from "$/env.json"; + +const config = env.OTEL ?? {}; + +const provider = new WebTracerProvider({ + resource: resourceFromAttributes({ "service.name": config.service_name }), + spanProcessors: [ + new BatchSpanProcessor( + new OTLPTraceExporter({ url: config.endpoint, headers: config.headers }), + ), + ], +}); +const tracer = provider.getTracer("reflex", config.version); +const propagator = new W3CTraceContextPropagator(); + +const setter = { + set(carrier, key, value) { + carrier[key] = value; + }, +}; + +// Absolute epoch time (ms) of a performance timeline offset. +const epoch = (offset) => performance.timeOrigin + offset; + +let connectCount = 0; + +window.__reflex_otel = { + onEventSend(event) { + // Fire-and-forget over the socket: a PRODUCER span that marks the send. The + // browser has no completion signal for an event, so it has no duration. + const span = tracer.startSpan(event.name, { + kind: SpanKind.PRODUCER, + attributes: { "reflex.event.name": event.name }, + }); + propagator.inject(trace.setSpan(context.active(), span), event, setter); + span.end(); + }, + onSocketConnect() { + connectCount += 1; + tracer + .startSpan("socket.connect", { + attributes: { "reflex.socket.connect_count": connectCount }, + }) + .end(); + }, + onSocketDisconnect(reason) { + const span = tracer.startSpan("socket.disconnect", { + attributes: { "reflex.socket.disconnect_reason": reason }, + }); + // Intentional disconnects (navigation, server shutdown) are not errors. + if (!reason.startsWith("io ")) { + span.setStatus({ code: SpanStatusCode.ERROR, message: reason }); + } + span.end(); + }, +}; + +if (config.web_vitals) { + // FCP/LCP/TTFB values are offsets from navigation start; INP is a duration + // starting at its interaction; CLS is unitless and reported as an instant. + const report = (metric) => { + const attributes = { + "web_vital.name": metric.name, + "web_vital.value": metric.value, + "web_vital.rating": metric.rating, + "web_vital.id": metric.id, + "web_vital.navigation_type": metric.navigationType, + }; + let startTime = epoch(0); + let endTime = epoch(metric.value); + if (metric.name === "INP") { + startTime = epoch(metric.entries[0]?.startTime ?? 0); + endTime = startTime + metric.value; + } else if (metric.name === "CLS") { + startTime = endTime = Date.now(); + } + tracer + .startSpan(`web_vital.${metric.name}`, { startTime, attributes }) + .end(endTime); + }; + onCLS(report); + onFCP(report); + onINP(report); + onLCP(report); + onTTFB(report); +} + +const onRender = ( + id, + phase, + actualDuration, + baseDuration, + startTime, + commitTime, +) => { + tracer + .startSpan("react.render", { + startTime: epoch(startTime), + attributes: { + "react.profiler.id": id, + "react.render.phase": phase, + "react.render.actual_duration_ms": actualDuration, + "react.render.base_duration_ms": baseDuration, + }, + }) + .end(epoch(commitTime)); +}; + +/** + * Root wrapper used by the patched entry: profiles React commits when enabled. + */ +export const OtelRoot = ({ children }) => + config.render_timing + ? createElement(Profiler, { id: "app", onRender }, children) + : children; diff --git a/packages/reflex-otel/src/reflex_otel/plugin.py b/packages/reflex-otel/src/reflex_otel/plugin.py new file mode 100644 index 00000000000..a5388ecd4d3 --- /dev/null +++ b/packages/reflex-otel/src/reflex_otel/plugin.py @@ -0,0 +1,177 @@ +"""Compile-time plugin that adds browser-side OpenTelemetry to a Reflex app.""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from reflex_base.config import get_config +from reflex_base.constants.base import ReactRouter, Reflex +from reflex_base.constants.compiler import Embed +from reflex_base.plugins.base import Plugin + +logger = logging.getLogger(__name__) + +BROWSER_MODULE = "utils/otel.js" +_BROWSER_MODULE_SOURCE = Path(__file__).with_name("otel.js") + +# Pinned npm packages for the browser module. +FRONTEND_DEPENDENCIES = ( + "@opentelemetry/api@1.9.1", + "@opentelemetry/core@2.10.0", + "@opentelemetry/exporter-trace-otlp-http@0.221.0", + "@opentelemetry/resources@2.10.0", + "@opentelemetry/sdk-trace-web@2.10.0", + "web-vitals@6.1.1", +) + +_ENTRY_IMPORT = 'import { OtelRoot } from "$/utils/otel";\n' +_ENTRY_ROOT_ANCHOR = "createElement(HydratedRouter)" +_ENTRY_ROOT_WRAPPED = "createElement(OtelRoot, null, createElement(HydratedRouter))" + +# React strips from production builds; the profiling build keeps it. +_VITE_CONFIG_ANCHOR = "export default defineConfig((config) => ({\n" +_VITE_PROFILING_ALIAS = ( + ' resolve: { alias: { "react-dom/client": "react-dom/profiling" } },\n' +) + + +def _default_endpoint() -> str: + """Resolve the OTLP/HTTP traces URL the browser exports to. + + Mirrors the SDK environment variables so one configuration covers both + the backend exporter and the browser. + + Returns: + The traces endpoint URL. + """ + if traces := os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"): + return traces + base = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + return base.rstrip("/") + "/v1/traces" + + +def _patch_entry_client(content: str) -> str: + """Load the browser module and profile the React root in ``entry.client.js``. + + Args: + content: The current entry file. + + Returns: + The patched entry file. + """ + if _ENTRY_IMPORT in content: + return content + if _ENTRY_ROOT_ANCHOR not in content: + logger.warning( + "OtelPlugin: %r not found in %s; React render timing is disabled.", + _ENTRY_ROOT_ANCHOR, + Embed.ENTRY_PATH, + ) + return _ENTRY_IMPORT + content.replace(_ENTRY_ROOT_ANCHOR, _ENTRY_ROOT_WRAPPED, 1) + + +def _patch_vite_config(content: str) -> str: + """Alias react-dom to its profiling build so works in production. + + Args: + content: The current ``vite.config.js``. + + Returns: + The patched config. + + Raises: + RuntimeError: If the config no longer has the expected shape. + """ + if _VITE_PROFILING_ALIAS in content: + return content + if _VITE_CONFIG_ANCHOR not in content: + msg = ( + f"OtelPlugin cannot enable render_timing: {_VITE_CONFIG_ANCHOR!r} " + f"was not found in {ReactRouter.VITE_CONFIG_FILE}." + ) + raise RuntimeError(msg) + return content.replace( + _VITE_CONFIG_ANCHOR, _VITE_CONFIG_ANCHOR + _VITE_PROFILING_ALIAS, 1 + ) + + +@dataclass +class OtelPlugin(Plugin): + """Ship the reflex-otel browser module with the compiled frontend. + + Add it to ``rx.Config(plugins=[...])``. Every event sent to the backend + then carries a W3C ``traceparent`` (the backend event span becomes a + child of the browser span), and web vitals, React commit timings and + socket (re)connects are exported as spans. + + The endpoint must accept OTLP/HTTP from the browser (CORS). ``headers`` + are compiled into the public bundle, so never put secrets there. + """ + + # OTLP/HTTP traces URL; defaults follow OTEL_EXPORTER_OTLP_* env vars. + endpoint: str = field(default_factory=_default_endpoint) + # Resource service.name of the browser spans; defaults to "-frontend". + service_name: str | None = None + # Extra HTTP headers sent by the browser exporter (public!). + headers: dict[str, str] = field(default_factory=dict) + web_vitals: bool = True + # One `react.render` span per React commit (uses the react-dom profiling + # build in production). Off by default because of the span volume. + render_timing: bool = False + + def get_frontend_dependencies(self, **context: Any) -> tuple[str, ...]: + """Return the npm packages the browser module imports. + + Args: + context: The context for the plugin. + + Returns: + The pinned package specifiers. + """ + return FRONTEND_DEPENDENCIES + + def get_static_assets(self, **context: Any) -> list[tuple[Path, str]]: + """Return the browser module to write into ``.web``. + + Args: + context: The context for the plugin. + + Returns: + The module path and its source. + """ + return [(Path(BROWSER_MODULE), _BROWSER_MODULE_SOURCE.read_text())] + + def pre_compile(self, **context: Any) -> None: + """Patch the client entry to load the module and profile the root. + + Args: + context: The pre-compile plugin context. + """ + context["add_modify_task"](Embed.ENTRY_PATH, _patch_entry_client) + if self.render_timing: + context["add_modify_task"](ReactRouter.VITE_CONFIG_FILE, _patch_vite_config) + + def update_env_json(self, **context: Any) -> dict[str, Any]: + """Expose the browser configuration through ``env.json``. + + Args: + context: The context for the plugin. + + Returns: + The ``OTEL`` entry read by the browser module. + """ + return { + "OTEL": { + "endpoint": self.endpoint, + "service_name": self.service_name + or f"{get_config().app_name}-frontend", + "headers": self.headers, + "web_vitals": self.web_vitals, + "render_timing": self.render_timing, + "version": Reflex.VERSION, + } + } diff --git a/tests/units/reflex_otel/test_plugin.py b/tests/units/reflex_otel/test_plugin.py new file mode 100644 index 00000000000..ddeede06e8f --- /dev/null +++ b/tests/units/reflex_otel/test_plugin.py @@ -0,0 +1,89 @@ +"""Tests for the reflex_otel browser plugin.""" + +from pathlib import Path + +import pytest +from reflex_base.constants.base import ReactRouter +from reflex_base.constants.compiler import Embed +from reflex_otel.plugin import ( + BROWSER_MODULE, + FRONTEND_DEPENDENCIES, + OtelPlugin, + _patch_entry_client, + _patch_vite_config, +) + + +def test_default_endpoint_follows_otel_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", raising=False) + monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False) + assert OtelPlugin().endpoint == "http://localhost:4318/v1/traces" + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318/") + assert OtelPlugin().endpoint == "http://collector:4318/v1/traces" + monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "https://x/t") + assert OtelPlugin().endpoint == "https://x/t" + assert OtelPlugin(endpoint="http://y").endpoint == "http://y" + + +def test_frontend_dependencies_and_asset(): + plugin = OtelPlugin() + assert plugin.get_frontend_dependencies() == FRONTEND_DEPENDENCIES + ((path, source),) = plugin.get_static_assets() + assert path == Path(BROWSER_MODULE) + assert "window.__reflex_otel" in source + assert "export const OtelRoot" in source + + +def test_env_json_entry(): + plugin = OtelPlugin( + endpoint="http://c/v1/traces", headers={"x": "y"}, web_vitals=False + ) + entry = plugin.update_env_json()["OTEL"] + assert entry["endpoint"] == "http://c/v1/traces" + assert entry["headers"] == {"x": "y"} + assert entry["web_vitals"] is False + assert entry["render_timing"] is False + assert entry["service_name"].endswith("-frontend") + assert ( + OtelPlugin(service_name="web").update_env_json()["OTEL"]["service_name"] + == "web" + ) + + +def _pre_compile_tasks(plugin: OtelPlugin) -> list: + tasks = [] + plugin.pre_compile( + add_save_task=None, + add_modify_task=lambda path, fn: tasks.append((path, fn)), + radix_themes_plugin=None, + unevaluated_pages=[], + ) + return tasks + + +def test_pre_compile_patches_entry(caplog: pytest.LogCaptureFixture): + ((path, fn),) = _pre_compile_tasks(OtelPlugin()) + assert path == Embed.ENTRY_PATH + entry = "import x;\nhydrateRoot(document, createElement(HydratedRouter));\n" + patched = fn(entry) + assert patched.startswith('import { OtelRoot } from "$/utils/otel";\n') + assert "createElement(OtelRoot, null, createElement(HydratedRouter))" in patched + assert fn(patched) == patched + with caplog.at_level("WARNING"): + assert _patch_entry_client("other();\n") == ( + 'import { OtelRoot } from "$/utils/otel";\nother();\n' + ) + assert "render timing is disabled" in caplog.text + + +def test_render_timing_aliases_react_dom_profiling(): + (_, (path, fn)) = _pre_compile_tasks(OtelPlugin(render_timing=True)) + assert path == ReactRouter.VITE_CONFIG_FILE + config = ( + 'import x;\nexport default defineConfig((config) => ({\n base: "/",\n}));\n' + ) + patched = fn(config) + assert '"react-dom/client": "react-dom/profiling"' in patched + assert fn(patched) == patched + with pytest.raises(RuntimeError, match="render_timing"): + _patch_vite_config("export default {};\n") From c366f114023235d40b3348a12b8887662ca05c39 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 18 Aug 2026 00:37:42 +0500 Subject: [PATCH 2/5] docs(otel): observability reference page --- docs/api-reference/observability.md | 91 +++++++++++++++++++ .../sidebar/sidebar_items/reference.py | 1 + 2 files changed, 92 insertions(+) create mode 100644 docs/api-reference/observability.md diff --git a/docs/api-reference/observability.md b/docs/api-reference/observability.md new file mode 100644 index 00000000000..a7b30f98710 --- /dev/null +++ b/docs/api-reference/observability.md @@ -0,0 +1,91 @@ +# Observability (OpenTelemetry) + +Reflex has built-in [OpenTelemetry](https://opentelemetry.io) trace points and +metrics. They are inert (one boolean check) until you install the optional +`reflex-otel` package and turn them on. Any OpenTelemetry backend works: +Jaeger, Grafana Tempo, SigNoz, Honeycomb, Datadog, ... + +## Backend: install and enable + +```bash +pip install reflex-otel opentelemetry-sdk opentelemetry-exporter-otlp-proto-http +``` + +Configure the SDK as usual and enable the Reflex instrumentation once, at +import time of your app module: + +```python +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from reflex_otel import ReflexInstrumentor + +if not ReflexInstrumentor().is_instrumented_by_opentelemetry: + provider = TracerProvider() + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + trace.set_tracer_provider(provider) + ReflexInstrumentor().instrument(tracer_provider=provider) +``` + +Guard the setup as shown: Reflex hot reload re-imports the app module, and +`set_tracer_provider()` / `instrument()` warn when called twice. + +`ReflexInstrumentor` also registers the standard `opentelemetry_instrumentor` +entry point, so `opentelemetry-instrument reflex run` enables it with no code. + +### What is traced + +- One span per event handler run, named after the event, with + `reflex.event.name`, `reflex.event.txid`, `reflex.event.background`, + `session.id` and `code.function.name`. Exceptions are recorded on the span. + Events sent by the browser are `SERVER` spans: a new trace, or a child of the + browser span when the frontend plugin (below) is active. +- Events returned by a handler (chained events) are `INTERNAL` children of the + span that produced them. +- HTTP requests and the websocket connection get spans and `http.server.*` + metrics from the OpenTelemetry ASGI middleware. +- Each app compile is a `reflex.compile` span with the stages + (`reflex.compile.pages`, `reflex.compile.write`, ...) as children. + +### Metrics + +| Instrument | Type | Unit | Attributes | +| --- | --- | --- | --- | +| `reflex.event.duration` | histogram | s | `reflex.event.name`, `reflex.event.background`, `error.type` | +| `reflex.state.acquire.duration` | histogram | s | `reflex.event.name` | +| `reflex.websocket.message.size` | histogram | By | `network.io.direction` | +| `reflex.websocket.connections` | up-down counter | `{connection}` | | + +Pass `meter_provider=` to `instrument()` to export them (defaults to the global +meter provider). + +## Frontend: browser traces + +Add the plugin to your config to trace the browser as well: + +```python +# rxconfig.py +import reflex as rx +from reflex_otel import OtelPlugin + +config = rx.Config( + app_name="my_app", + plugins=[OtelPlugin(endpoint="https://collector.example.com/v1/traces")], +) +``` + +The compiled frontend then + +- sends a W3C `traceparent` with every event, so each user interaction is one + trace: browser span → backend event span → chained events; +- reports web vitals (`web_vital.LCP`, `CLS`, `INP`, `FCP`, `TTFB`) as spans; +- with `render_timing=True`, reports React commits as `react.render` spans + (one per commit; uses the `react-dom/profiling` build); +- records `socket.connect` / `socket.disconnect` spans. + +`endpoint` defaults to `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, then +`OTEL_EXPORTER_OTLP_ENDPOINT` + `/v1/traces`, then +`http://localhost:4318/v1/traces`; it must accept OTLP/HTTP from the browser +(CORS). `service_name` defaults to `-frontend`. `headers` are +compiled into the public bundle, so never put secrets in them. diff --git a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/reference.py b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/reference.py index 99ebe52d58c..e72968f9edd 100644 --- a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/reference.py +++ b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/reference.py @@ -28,6 +28,7 @@ def get_sidebar_items_api_reference(): api_reference.plugins, api_reference.utils, api_reference.telemetry, + api_reference.observability, ], ) ] From d3bfbe1a57416ebf74f3f2cd325a6127197e1326 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 18 Aug 2026 02:51:04 +0500 Subject: [PATCH 3/5] fix(otel): put the profiling alias inside the existing vite resolve block --- packages/reflex-otel/src/reflex_otel/plugin.py | 8 ++++++-- tests/units/reflex_otel/test_plugin.py | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/reflex-otel/src/reflex_otel/plugin.py b/packages/reflex-otel/src/reflex_otel/plugin.py index a5388ecd4d3..557d81c1a68 100644 --- a/packages/reflex-otel/src/reflex_otel/plugin.py +++ b/packages/reflex-otel/src/reflex_otel/plugin.py @@ -33,9 +33,13 @@ _ENTRY_ROOT_WRAPPED = "createElement(OtelRoot, null, createElement(HydratedRouter))" # React strips from production builds; the profiling build keeps it. -_VITE_CONFIG_ANCHOR = "export default defineConfig((config) => ({\n" +# The alias is appended to the existing `resolve.alias` array of the generated +# config: a second `resolve` key would silently override the first one. +_VITE_CONFIG_ANCHOR = ( + ' resolve: {\n mainFields: ["browser", "module", "jsnext"],\n alias: [\n' +) _VITE_PROFILING_ALIAS = ( - ' resolve: { alias: { "react-dom/client": "react-dom/profiling" } },\n' + ' { find: "react-dom/client", replacement: "react-dom/profiling" },\n' ) diff --git a/tests/units/reflex_otel/test_plugin.py b/tests/units/reflex_otel/test_plugin.py index ddeede06e8f..7c1daf1830e 100644 --- a/tests/units/reflex_otel/test_plugin.py +++ b/tests/units/reflex_otel/test_plugin.py @@ -3,6 +3,7 @@ from pathlib import Path import pytest +from reflex_base.compiler.templates import vite_config_template from reflex_base.constants.base import ReactRouter from reflex_base.constants.compiler import Embed from reflex_otel.plugin import ( @@ -79,11 +80,20 @@ def test_pre_compile_patches_entry(caplog: pytest.LogCaptureFixture): def test_render_timing_aliases_react_dom_profiling(): (_, (path, fn)) = _pre_compile_tasks(OtelPlugin(render_timing=True)) assert path == ReactRouter.VITE_CONFIG_FILE - config = ( - 'import x;\nexport default defineConfig((config) => ({\n base: "/",\n}));\n' + config = vite_config_template( + base="/", + hmr=False, + force_full_reload=False, + experimental_hmr=False, + sourcemap=False, ) patched = fn(config) - assert '"react-dom/client": "react-dom/profiling"' in patched + # A single `resolve` key: a duplicate one would be overridden by the last. + assert patched.count("resolve: {") == 1 + resolve_block = patched[patched.index("resolve: {") :] + assert ( + 'find: "react-dom/client", replacement: "react-dom/profiling"' in resolve_block + ) assert fn(patched) == patched with pytest.raises(RuntimeError, match="render_timing"): _patch_vite_config("export default {};\n") From 2c0c19225aec1996ae81458321acddc9257701a2 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 19 Aug 2026 03:12:41 +0500 Subject: [PATCH 4/5] fix(otel): browser sample_rate, flush unload disconnect span, anchor web vitals to the current navigation --- docs/api-reference/observability.md | 4 +++- packages/reflex-otel/src/reflex_otel/otel.js | 22 +++++++++++++++---- .../reflex-otel/src/reflex_otel/plugin.py | 4 ++++ tests/units/reflex_otel/test_plugin.py | 3 +++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/api-reference/observability.md b/docs/api-reference/observability.md index a7b30f98710..8b6f7b7e5d8 100644 --- a/docs/api-reference/observability.md +++ b/docs/api-reference/observability.md @@ -88,4 +88,6 @@ The compiled frontend then `OTEL_EXPORTER_OTLP_ENDPOINT` + `/v1/traces`, then `http://localhost:4318/v1/traces`; it must accept OTLP/HTTP from the browser (CORS). `service_name` defaults to `-frontend`. `headers` are -compiled into the public bundle, so never put secrets in them. +compiled into the public bundle, so never put secrets in them. `sample_rate` +(default `1.0`) samples browser traces at the root; a parent-based backend +sampler follows that decision, so it also bounds the backend event traces. diff --git a/packages/reflex-otel/src/reflex_otel/otel.js b/packages/reflex-otel/src/reflex_otel/otel.js index 2a195ed3a23..954a73c7b4c 100644 --- a/packages/reflex-otel/src/reflex_otel/otel.js +++ b/packages/reflex-otel/src/reflex_otel/otel.js @@ -17,6 +17,8 @@ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { BatchSpanProcessor, + ParentBasedSampler, + TraceIdRatioBasedSampler, WebTracerProvider, } from "@opentelemetry/sdk-trace-web"; import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals"; @@ -26,6 +28,11 @@ const config = env.OTEL ?? {}; const provider = new WebTracerProvider({ resource: resourceFromAttributes({ "service.name": config.service_name }), + // Browser spans are trace roots: their sampled flag travels in `traceparent` + // and a parent-based backend sampler follows it, so sample here. + sampler: new ParentBasedSampler({ + root: new TraceIdRatioBasedSampler(config.sample_rate ?? 1), + }), spanProcessors: [ new BatchSpanProcessor( new OTLPTraceExporter({ url: config.endpoint, headers: config.headers }), @@ -74,13 +81,20 @@ window.__reflex_otel = { span.setStatus({ code: SpanStatusCode.ERROR, message: reason }); } span.end(); + // Unload disconnects happen after the processor's own pagehide flush ran, + // so the span would otherwise sit in the queue while the page tears down. + provider.forceFlush(); }, }; if (config.web_vitals) { - // FCP/LCP/TTFB values are offsets from navigation start; INP is a duration - // starting at its interaction; CLS is unitless and reported as an instant. + // FCP/LCP/TTFB values are offsets from the current navigation: activation + // start for a (pre)rendered page, `navigationStartTime` for a BFCache + // restore or soft navigation. INP is a duration starting at its interaction; + // CLS is unitless and reported as an instant. const report = (metric) => { + const activationStart = + performance.getEntriesByType("navigation")[0]?.activationStart ?? 0; const attributes = { "web_vital.name": metric.name, "web_vital.value": metric.value, @@ -88,8 +102,8 @@ if (config.web_vitals) { "web_vital.id": metric.id, "web_vital.navigation_type": metric.navigationType, }; - let startTime = epoch(0); - let endTime = epoch(metric.value); + let startTime = epoch(metric.navigationStartTime || activationStart); + let endTime = startTime + metric.value; if (metric.name === "INP") { startTime = epoch(metric.entries[0]?.startTime ?? 0); endTime = startTime + metric.value; diff --git a/packages/reflex-otel/src/reflex_otel/plugin.py b/packages/reflex-otel/src/reflex_otel/plugin.py index 557d81c1a68..945d1c3eb94 100644 --- a/packages/reflex-otel/src/reflex_otel/plugin.py +++ b/packages/reflex-otel/src/reflex_otel/plugin.py @@ -126,6 +126,9 @@ class OtelPlugin(Plugin): # One `react.render` span per React commit (uses the react-dom profiling # build in production). Off by default because of the span volume. render_timing: bool = False + # Fraction of browser traces to sample (0..1). Browser spans are trace + # roots, so the backend's parent-based sampler follows this decision. + sample_rate: float = 1.0 def get_frontend_dependencies(self, **context: Any) -> tuple[str, ...]: """Return the npm packages the browser module imports. @@ -176,6 +179,7 @@ def update_env_json(self, **context: Any) -> dict[str, Any]: "headers": self.headers, "web_vitals": self.web_vitals, "render_timing": self.render_timing, + "sample_rate": self.sample_rate, "version": Reflex.VERSION, } } diff --git a/tests/units/reflex_otel/test_plugin.py b/tests/units/reflex_otel/test_plugin.py index 7c1daf1830e..e9a591b8a45 100644 --- a/tests/units/reflex_otel/test_plugin.py +++ b/tests/units/reflex_otel/test_plugin.py @@ -44,6 +44,9 @@ def test_env_json_entry(): assert entry["headers"] == {"x": "y"} assert entry["web_vitals"] is False assert entry["render_timing"] is False + assert entry["sample_rate"] is OtelPlugin.sample_rate + rate = 0.5 + assert OtelPlugin(sample_rate=rate).update_env_json()["OTEL"]["sample_rate"] is rate assert entry["service_name"].endswith("-frontend") assert ( OtelPlugin(service_name="web").update_env_json()["OTEL"]["service_name"] From 4e1d604263b44fa68166127eaa363aab2e60d615 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 19 Aug 2026 03:24:18 +0500 Subject: [PATCH 5/5] fix(otel): reject out-of-range sample_rate at plugin construction --- packages/reflex-otel/src/reflex_otel/plugin.py | 10 ++++++++++ tests/units/reflex_otel/test_plugin.py | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/packages/reflex-otel/src/reflex_otel/plugin.py b/packages/reflex-otel/src/reflex_otel/plugin.py index 945d1c3eb94..a9d3cb37c22 100644 --- a/packages/reflex-otel/src/reflex_otel/plugin.py +++ b/packages/reflex-otel/src/reflex_otel/plugin.py @@ -130,6 +130,16 @@ class OtelPlugin(Plugin): # roots, so the backend's parent-based sampler follows this decision. sample_rate: float = 1.0 + def __post_init__(self): + """Validate the sampling ratio. + + Raises: + ValueError: If ``sample_rate`` is outside ``[0, 1]``. + """ + if not 0 <= self.sample_rate <= 1: + msg = f"sample_rate must be between 0 and 1, got {self.sample_rate!r}." + raise ValueError(msg) + def get_frontend_dependencies(self, **context: Any) -> tuple[str, ...]: """Return the npm packages the browser module imports. diff --git a/tests/units/reflex_otel/test_plugin.py b/tests/units/reflex_otel/test_plugin.py index e9a591b8a45..8847fe78efb 100644 --- a/tests/units/reflex_otel/test_plugin.py +++ b/tests/units/reflex_otel/test_plugin.py @@ -100,3 +100,9 @@ def test_render_timing_aliases_react_dom_profiling(): assert fn(patched) == patched with pytest.raises(RuntimeError, match="render_timing"): _patch_vite_config("export default {};\n") + + +@pytest.mark.parametrize("rate", [-0.1, 1.5]) +def test_sample_rate_out_of_range(rate: float): + with pytest.raises(ValueError, match="sample_rate"): + OtelPlugin(sample_rate=rate)