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.
- reflex_otel-0.1.0a1/.gitignore +35 -0
- reflex_otel-0.1.0a1/CHANGELOG.md +9 -0
- reflex_otel-0.1.0a1/PKG-INFO +136 -0
- reflex_otel-0.1.0a1/README.md +122 -0
- reflex_otel-0.1.0a1/pyproject.toml +32 -0
- reflex_otel-0.1.0a1/src/reflex_otel/__init__.py +37 -0
- reflex_otel-0.1.0a1/src/reflex_otel/instrumentor.py +241 -0
- reflex_otel-0.1.0a1/src/reflex_otel/otel.js +209 -0
- reflex_otel-0.1.0a1/src/reflex_otel/plugin.py +193 -0
- reflex_otel-0.1.0a1/src/reflex_otel/py.typed +0 -0
|
@@ -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
|