Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions docs/api-reference/observability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# 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 `<app_name>-frontend`. `headers` are
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.
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def get_sidebar_items_api_reference():
api_reference.plugins,
api_reference.utils,
api_reference.telemetry,
api_reference.observability,
],
)
]
Expand Down
2 changes: 1 addition & 1 deletion packages/reflex-base/news/6227.feature.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
FarhanAliRaza marked this conversation as resolved.
Comment thread
FarhanAliRaza marked this conversation as resolved.
socket.emit("event", event);
}
};
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions packages/reflex-otel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<app_name>-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`
Expand Down
2 changes: 1 addition & 1 deletion packages/reflex-otel/news/6227.feature.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/reflex-otel/src/reflex_otel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -98,3 +100,6 @@ def _uninstrument(self, **kwargs: Any) -> None:
**kwargs: Ignored.
"""
otel.disable()


__all__ = ["OtelPlugin", "ReflexInstrumentor"]
151 changes: 151 additions & 0 deletions packages/reflex-otel/src/reflex_otel/otel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* 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 <Profiler>
* (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,
ParentBasedSampler,
TraceIdRatioBasedSampler,
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({
Comment thread
FarhanAliRaza marked this conversation as resolved.
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 }),
),
],
});
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();
Comment thread
FarhanAliRaza marked this conversation as resolved.
// 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 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,
"web_vital.rating": metric.rating,
"web_vital.id": metric.id,
"web_vital.navigation_type": metric.navigationType,
};
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;
} 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;
Loading
Loading