From 74274985a5e414448327faac6f5d4793816f6578 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 18 Aug 2026 00:27:50 +0500 Subject: [PATCH] feat(otel): compile spans --- packages/reflex-base/news/6227.feature.md | 2 +- packages/reflex-base/src/reflex_base/otel.py | 49 +++++++++++++++++++- packages/reflex-otel/README.md | 4 ++ packages/reflex-otel/news/6227.feature.md | 2 +- reflex/app.py | 43 ++++++++--------- reflex/compiler/compiler.py | 22 ++++++--- tests/units/reflex_base/test_otel.py | 25 ++++++++++ tests/units/test_app.py | 29 ++++++++++++ 8 files changed, 146 insertions(+), 30 deletions(-) diff --git a/packages/reflex-base/news/6227.feature.md b/packages/reflex-base/news/6227.feature.md index 0ed2e91a1e3..f91d6bb90a9 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 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. diff --git a/packages/reflex-base/src/reflex_base/otel.py b/packages/reflex-base/src/reflex_base/otel.py index 79ca5dd93f5..c937d6a0d94 100644 --- a/packages/reflex-base/src/reflex_base/otel.py +++ b/packages/reflex-base/src/reflex_base/otel.py @@ -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 @@ -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 @@ -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" @@ -217,6 +224,46 @@ def remote_context(carrier: Mapping[str, Any]) -> _AttachedContext: return _AttachedContext(propagate.extract(carrier, context=Context())) +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 diff --git a/packages/reflex-otel/README.md b/packages/reflex-otel/README.md index fbcea659435..566d16c3f78 100644 --- a/packages/reflex-otel/README.md +++ b/packages/reflex-otel/README.md @@ -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: diff --git a/packages/reflex-otel/news/6227.feature.md b/packages/reflex-otel/news/6227.feature.md index fdc6ea8d17b..85e518cbfb6 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) 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. diff --git a/reflex/app.py b/reflex/app.py index aa21898bde6..0375befbbd0 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -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.""" diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index f7fcd8c3509..39276c8c5ca 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -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, @@ -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"), + ): for route in app._unevaluated_pages: console.debug(f"Evaluating page: {route}") app._compile_page(route, save_page=False) @@ -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), @@ -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, @@ -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( @@ -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) diff --git a/tests/units/reflex_base/test_otel.py b/tests/units/reflex_base/test_otel.py index 9f2243f556d..7272ba32b2c 100644 --- a/tests/units/reflex_base/test_otel.py +++ b/tests/units/reflex_base/test_otel.py @@ -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 @@ -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", + } diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 773b463044f..6965ffb1e81 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -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