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
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 and socket messages (`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`); they cost one boolean check until the `reflex-otel` package enables them.
49 changes: 48 additions & 1 deletion packages/reflex-base/src/reflex_base/otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from __future__ import annotations

from collections.abc import Awaitable, Callable, Iterator, Mapping
from contextlib import contextmanager
from contextlib import contextmanager, nullcontext
from time import perf_counter
from typing import TYPE_CHECKING, Any

Expand All @@ -25,6 +25,8 @@
from reflex_base.constants.base import Reflex

if TYPE_CHECKING:
from contextlib import AbstractContextManager

from reflex_base.event import Event
from reflex_base.event.context import EventContext
from reflex_base.registry import RegisteredEventHandler
Expand All @@ -40,6 +42,11 @@
ATTR_CODE_FUNCTION_NAME = "code.function.name"
ATTR_ERROR_TYPE = "error.type"
ATTR_NETWORK_IO_DIRECTION = "network.io.direction"
ATTR_COMPILE_TRIGGER = "reflex.compile.trigger"
ATTR_COMPILE_DRY_RUN = "reflex.compile.dry_run"

# Span name of one full app compile; the compile stages nest under it.
COMPILE_SPAN_NAME = "reflex.compile"

# Metric instrument names.
METRIC_EVENT_DURATION = "reflex.event.duration"
Expand Down Expand Up @@ -217,6 +224,46 @@ def remote_context(carrier: Mapping[str, Any]) -> _AttachedContext:
return _AttachedContext(propagate.extract(carrier, context=Context()))
Comment thread
FarhanAliRaza marked this conversation as resolved.


def span(
name: str, attributes: Mapping[str, Any] | None = None
) -> AbstractContextManager[trace.Span | None]:
"""Open an internal span, or do nothing when tracing is off.

Used for coarse framework phases such as the compile stages; ``name``
must be a static, low-cardinality identifier such as ``reflex.compile.pages``.

Args:
name: The span name.
attributes: Attributes to set on the span.

Returns:
A context manager yielding the span (or None when tracing is off).
"""
if not enabled:
return nullcontext()
return _tracer.start_as_current_span(name, attributes=attributes)


def compile_span(
trigger: str | None, dry_run: bool
) -> AbstractContextManager[trace.Span | None]:
"""Open the span covering one app compile.

Args:
trigger: What initiated the compile, when known.
dry_run: Whether the compile writes nothing to disk.

Returns:
A context manager yielding the span (or None when tracing is off).
"""
if not enabled:
return nullcontext()
attributes: dict[str, Any] = {ATTR_COMPILE_DRY_RUN: dry_run}
if trigger is not None:
attributes[ATTR_COMPILE_TRIGGER] = trigger
return _tracer.start_as_current_span(COMPILE_SPAN_NAME, attributes=attributes)


@contextmanager
def event_span(
event: Event, ctx: EventContext, registered_handler: RegisteredEventHandler
Expand Down
4 changes: 4 additions & 0 deletions packages/reflex-otel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ Traces:
are consumed and never reach the handler.
- HTTP requests and the websocket connection are wrapped in the standard
OpenTelemetry ASGI middleware (per-message websocket spans are off).
- One `reflex.compile` span per app compile (`reflex.compile.trigger`,
`reflex.compile.dry_run`) with the stages `reflex.compile.evaluate_pages`,
`.pages`, `.copy_assets`, `.install_frontend_packages`, `.write` as
child spans.

Metrics:

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) 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.
43 changes: 22 additions & 21 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1649,32 +1649,33 @@ def _compile(
ReflexRuntimeError: When any page uses state, but no rx.State subclass is defined.
FileNotFoundError: When a plugin requires a file that does not exist.
"""
ctx = TelemetryContext.start(trigger=trigger)
if ctx is None:
compiler.compile_app(
self,
prerender_routes=prerender_routes,
dry_run=dry_run,
use_rich=use_rich,
)
return

with ctx:
did_real_compile = False
try:
did_real_compile = compiler.compile_app(
with otel.compile_span(trigger, dry_run):
ctx = TelemetryContext.start(trigger=trigger)
if ctx is None:
compiler.compile_app(
self,
prerender_routes=prerender_routes,
dry_run=dry_run,
use_rich=use_rich,
)
except Exception as exc:
ctx.set_exception(exc)
did_real_compile = True
raise
finally:
if did_real_compile:
telemetry_accounting.record_compile(self, ctx)
return

with ctx:
did_real_compile = False
try:
did_real_compile = compiler.compile_app(
self,
prerender_routes=prerender_routes,
dry_run=dry_run,
use_rich=use_rich,
)
except Exception as exc:
ctx.set_exception(exc)
did_real_compile = True
raise
finally:
if did_real_compile:
telemetry_accounting.record_compile(self, ctx)

def _write_stateful_pages_marker(self):
"""Write list of routes that create dynamic states for the backend to use later."""
Expand Down
22 changes: 16 additions & 6 deletions reflex/compiler/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any

from reflex_base import constants
from reflex_base import constants, otel
from reflex_base.components.component import (
BaseComponent,
Component,
Expand Down Expand Up @@ -1176,7 +1176,10 @@ def compile_app(
app.style = evaluate_style_namespaces(app.style)

if not should_compile and not dry_run:
with console.timing("Evaluate Pages (Backend)"):
with (
console.timing("Evaluate Pages (Backend)"),
otel.span("reflex.compile.evaluate_pages"),
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Include the stateful-marker backend evaluation in reflex.compile.evaluate_pages

When .web/backend exists and stateful_pages.json is present, the earlier branch evaluates each marked page with _compile_page(..., save_page=False) and returns at line 1171 before this span is entered. I reproduced that normal backend_startup path with an OTLP exporter: only the reflex.compile root was emitted even though page evaluation ran. Please wrap the marker-driven loop in the same stage span (or factor both paths through a shared helper) and cover the early-return marker path.

):
for route in app._unevaluated_pages:
console.debug(f"Evaluating page: {route}")
app._compile_page(route, save_page=False)
Expand Down Expand Up @@ -1218,7 +1221,11 @@ def compile_app(
),
)

with console.timing("Compile pages"), compile_ctx:
with (
console.timing("Compile pages"),
otel.span("reflex.compile.pages"),
compile_ctx,
):
compile_ctx.compile(
evaluate_progress=lambda: progress.advance(task),
render_progress=lambda: progress.advance(task),
Expand Down Expand Up @@ -1325,7 +1332,7 @@ def compile_app(

assets_src = Path.cwd() / constants.Dirs.APP_ASSETS
if assets_src.is_dir() and not dry_run:
with console.timing("Copy assets"):
with console.timing("Copy assets"), otel.span("reflex.compile.copy_assets"):
path_ops.update_directory_tree(
src=assets_src,
dest=Path.cwd() / prerequisites.get_web_dir() / constants.Dirs.PUBLIC,
Expand Down Expand Up @@ -1404,7 +1411,10 @@ def add_save_task(
# dry-run return) so ``--dry`` never mutates ``.web`` or the manifest.
utils.prune_stale_memo_files(path for path, _ in memo_component_files)

with console.timing("Install Frontend Packages"):
with (
console.timing("Install Frontend Packages"),
otel.span("reflex.compile.install_frontend_packages"),
):
app._get_frontend_packages(all_imports)

frontend_skeleton.update_react_router_config(
Expand Down Expand Up @@ -1454,7 +1464,7 @@ def add_save_task(
raise FileNotFoundError(msg)
output_mapping[path] = modify_fn(file_content)

with console.timing("Write to Disk"):
with console.timing("Write to Disk"), otel.span("reflex.compile.write"):
for output_path, code in output_mapping.items():
utils.write_file(output_path, code)

Expand Down
25 changes: 25 additions & 0 deletions tests/units/reflex_base/test_otel.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for the reflex_base.otel trace points."""

import asyncio
from contextlib import nullcontext
from time import perf_counter

import pytest
Expand Down Expand Up @@ -215,3 +216,27 @@ def test_attach_context(otel_exporter: InMemorySpanExporter):
assert trace.get_current_span() is outer
finally:
otel_context.detach(token)


def test_span_helpers_noop_when_disabled():
assert isinstance(otel.span("x"), nullcontext)
assert isinstance(otel.compile_span("hot_reload", False), nullcontext)


def test_compile_span_attributes(otel_exporter: InMemorySpanExporter):
with otel.compile_span(None, True), otel.span("Compile pages", {"k": "v"}):
pass
stage, compile = otel_exporter.get_finished_spans()
assert compile.name == otel.COMPILE_SPAN_NAME
assert compile.attributes == {otel.ATTR_COMPILE_DRY_RUN: True}
compile_context = compile.get_span_context()
assert stage.parent is not None
assert compile_context is not None
assert stage.parent.span_id == compile_context.span_id
assert stage.attributes == {"k": "v"}
with otel.compile_span("backend_startup", False):
pass
assert otel_exporter.get_finished_spans()[-1].attributes == {
otel.ATTR_COMPILE_DRY_RUN: False,
otel.ATTR_COMPILE_TRIGGER: "backend_startup",
}
29 changes: 29 additions & 0 deletions tests/units/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4339,3 +4339,32 @@ async def test_connect_disconnect_counts_connections(otel_metrics):
assert point.value == 1
# Release t2 so a shared token store (redis) does not leak into other tests.
await ns._token_manager.disconnect_all()


def test_compile_emits_stage_spans(
compilable_app: tuple[App, Path], mocker: MockerFixture, otel_exporter
):
"""A real compile runs inside `reflex.compile` with the stages as children.

Args:
compilable_app: compilable_app fixture.
mocker: pytest mocker object.
otel_exporter: In-memory span exporter with tracing enabled.
"""
mocker.patch(
"reflex_base.config._get_config", return_value=rx.Config(app_name="testing")
)
app, web_dir = compilable_app
mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=web_dir)
app._compile(trigger="hot_reload")
spans = {span.name: span for span in otel_exporter.get_finished_spans()}
root = spans[otel.COMPILE_SPAN_NAME]
assert root.parent is None
assert root.attributes[otel.ATTR_COMPILE_TRIGGER] == "hot_reload"
assert root.attributes[otel.ATTR_COMPILE_DRY_RUN] is False
stages = {name for name in spans if name.startswith("reflex.compile.")}
assert {"reflex.compile.pages", "reflex.compile.write"} <= stages
for name in stages:
parent = spans[name].parent
assert parent is not None
assert parent.span_id == root.get_span_context().span_id
Loading