Skip to content

Commit f537771

Browse files
sdreyerclaude
andauthored
feat(arcade-serve): optional pluggable telemetry library integration (#862)
## Summary Adds an opt-in integration with a pluggable telemetry library so deployments that need richer observability (Sentry, request correlation, structured logging) can swap it in without forking `arcade-serve`. When the library isn't installed, behavior is identical to today. - New bridge module `libs/arcade-serve/arcade_serve/fastapi/_arcade_telemetry.py` — guarded `importlib` check, no new declared dependency. - `OTELHandler.instrument_app()` delegates global OTel tracer/meter/logger provider setup to the optional library when available, then still attaches the FastAPI / HTTPX / aiohttp / Requests auto-instrumentors against whatever providers are set. Falls back to the built-in OTLP exporter path otherwise. - `OTELHandler.__init__` accepts `service_name` / `service_version` so the resource identity matches the MCP server's configured name/version instead of the hard-coded `"worker"`. - `create_arcade_mcp()` registers the library's `CorrelationMiddleware` when present, so `X-Request-Id` is propagated through the request lifecycle. - `arcade-serve` patch → minor version bump (3.2.5 → 3.3.0). ## Why `OTELHandler` is currently a hard-coded OTLP exporter setup. Anyone who wants Sentry, request correlation, or non-OTLP destinations either monkey-patches it or runs a fork. This makes that integration a one-line install: drop a wheel into the venv and `OTELHandler` defers to it transparently. Nothing changes for the default install. ## Behavior | Scenario | Provider setup | Auto-instrumentation | Middleware | |---|---|---|---| | Default install (no telemetry lib) | `OTELHandler`'s built-in OTLP exporters (unchanged) | FastAPI / HTTPX / aiohttp / Requests | None added | | Telemetry lib installed | Library's `new_telemetry(...)` | FastAPI / HTTPX / aiohttp / Requests (against global providers) | Library's `CorrelationMiddleware` | | `otel_enable=False` | Nothing initializes | Nothing | None added | The two providers can't coexist (both set global OTel providers — last-writer-wins), so when the optional library is present it fully owns provider setup. ## Test plan - [x] `uv run pytest libs/tests/worker/` — 14 tests pass, including 7 new ones covering both code paths (library absent + simulated-present via `sys.modules` monkeypatch). - [x] `uv run pytest libs/tests/worker/ libs/tests/arcade_mcp_server/` — full sweep: 1429 passing. - [x] `mypy arcade_serve` clean. - [x] `mypy arcade_mcp_server` clean. - [x] `pre-commit` (ruff + ruff-format + guards) clean. ## Notes for reviewers - The bridge module is the **only** file that touches `arcade_telemetry`. Every other call site goes through `is_available()` / `init_providers()` / `correlation_middleware_cls()` / `shutdown()`. - No `optional-dependencies` extra is declared in `pyproject.toml` — the integration is purely runtime-detected. (An extra was attempted but breaks `uv sync` since the target library isn't published to PyPI.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes global OpenTelemetry provider initialization and MCP middleware ordering when OTel and arcade-telemetry are both enabled; default installs are unchanged but observability behavior can shift for deployments that add the optional library. > > **Overview** > Adds a **runtime-detected** bridge to optional `arcade-telemetry` so richer observability (e.g. Sentry, request correlation) can be enabled by installing a wheel, with **no change** when the library is absent. > > **`OTELHandler`** now accepts **`service_name`** / **`service_version`** (MCP passes configured server identity instead of a hard-coded `"worker"`). When `arcade-telemetry` is importable, global tracer/meter/logger setup goes through **`new_telemetry`** (and optional loguru wiring); FastAPI/HTTPX/aiohttp/Requests instrumentors still attach against whatever global providers are set. If the library is missing or opts out (`None` handle), behavior stays on the **built-in OTLP exporter** path; **`shutdown`** routes to the telemetry handle when delegation was used. > > **`create_arcade_mcp`** registers **`CorrelationMiddleware`** from arcade-telemetry when OTel is enabled and the class is available, added **last** so `X-Request-Id` context is set before other middleware runs. > > New **`_arcade_telemetry`** module isolates all `importlib` touches; **`arcade-serve`** bumps **3.2.5 → 3.3.0**. Tests cover delegate vs built-in paths and MCP middleware gating. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e753a00. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7b7387b commit f537771

4 files changed

Lines changed: 420 additions & 21 deletions

File tree

libs/arcade-mcp-server/arcade_mcp_server/worker.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from arcade_core.discovery import discover_tools
1919
from arcade_core.toolkit import ToolkitLoadError
2020
from arcade_serve.fastapi import FastAPIWorker, TaskTrackerMiddleware
21+
from arcade_serve.fastapi import _arcade_telemetry as _arcade_telemetry_bridge
2122
from arcade_serve.fastapi.telemetry import OTELHandler
2223
from fastapi import FastAPI
2324
from loguru import logger
@@ -159,6 +160,8 @@ def create_arcade_mcp(
159160
otel_handler = OTELHandler(
160161
enable=otel_enable,
161162
log_level=logging.DEBUG if debug else logging.INFO,
163+
service_name=mcp_settings.server.name,
164+
service_version=mcp_settings.server.version,
162165
)
163166

164167
@asynccontextmanager
@@ -208,6 +211,16 @@ async def track_tasks_middleware(
208211

209212
app.add_middleware(AddTrailingSlashToPathMiddleware)
210213

214+
# Register CorrelationMiddleware last so it lands outermost in the
215+
# Starlette stack — `add_middleware` order is innermost-first, so the
216+
# final call wraps everything else. This ensures every other middleware
217+
# (task tracker, trailing-slash, auth) runs with the X-Request-Id
218+
# contextvar already populated.
219+
if otel_enable:
220+
_correlation_mw = _arcade_telemetry_bridge.correlation_middleware_cls()
221+
if _correlation_mw is not None:
222+
app.add_middleware(_correlation_mw) # type: ignore[arg-type]
223+
211224
# Add OAuth discovery endpoint if auth is enabled
212225
if resource_server_validator and resource_server_validator.supports_oauth_discovery():
213226
canonical_url = getattr(resource_server_validator, "canonical_url", None)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Optional bridge to the ``arcade-telemetry`` library.
2+
3+
When ``arcade-telemetry`` is installed alongside ``arcade-serve``, the OTel
4+
tracer/meter/logger providers are set up by ``arcade_telemetry.new_telemetry``
5+
instead of ``OTELHandler``'s built-in OTLP exporters. ``OTELHandler`` still
6+
attaches the FastAPI / HTTPX / aiohttp / Requests auto-instrumentors against
7+
whatever the global providers happen to be, so spans flow through the
8+
arcade-telemetry pipeline.
9+
10+
When ``arcade-telemetry`` is not installed, every helper here is a no-op and
11+
``OTELHandler`` runs its original code path.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import importlib
17+
from typing import Any
18+
19+
_TELEMETRY_MODULE = "arcade_telemetry"
20+
_STARLETTE_MODULE = "arcade_telemetry.starlette"
21+
22+
23+
def _try_import(module_name: str) -> Any | None:
24+
try:
25+
return importlib.import_module(module_name)
26+
except ImportError:
27+
return None
28+
29+
30+
def is_available() -> bool:
31+
"""Return True iff ``arcade_telemetry`` can be imported."""
32+
return _try_import(_TELEMETRY_MODULE) is not None
33+
34+
35+
def init_providers(
36+
*,
37+
service_name: str,
38+
environment: str,
39+
version: str,
40+
log_level: int,
41+
) -> Any | None:
42+
"""Initialize global OTel providers via arcade-telemetry, and wire the
43+
loguru → OTLP bridge if ``arcade_telemetry.loguru`` is importable.
44+
45+
Returns the arcade-telemetry ``Telemetry`` handle on success, or ``None``
46+
if arcade-telemetry is not installed. Callers should treat ``None`` as "do
47+
the OTELHandler in-house setup instead". The return type is intentionally
48+
``Any`` so this module doesn't pull arcade-telemetry types into the
49+
public arcade-serve API.
50+
"""
51+
module = _try_import(_TELEMETRY_MODULE)
52+
if module is None:
53+
return None
54+
tel = module.new_telemetry(
55+
service_name=service_name,
56+
environment=environment,
57+
version=version,
58+
log_level=log_level,
59+
)
60+
if tel is None:
61+
# arcade-telemetry opted out (e.g. all signals routed to NONE) — skip
62+
# the loguru bridge too, otherwise OTELHandler's fallthrough to the
63+
# in-house OTLP path would attach loguru AND stdlib handlers.
64+
return None
65+
loguru_module = _try_import(f"{_TELEMETRY_MODULE}.loguru")
66+
if loguru_module is not None:
67+
loguru_module.install_loguru_integration(
68+
service=service_name,
69+
environment=environment,
70+
version=version,
71+
log_level=log_level,
72+
)
73+
return tel
74+
75+
76+
def correlation_middleware_cls() -> type | None:
77+
"""Return arcade-telemetry's ASGI CorrelationMiddleware class, or None."""
78+
module = _try_import(_STARLETTE_MODULE)
79+
if module is None:
80+
return None
81+
cls = getattr(module, "CorrelationMiddleware", None)
82+
if not isinstance(cls, type):
83+
return None
84+
return cls
85+
86+
87+
def shutdown(handle: Any | None) -> None:
88+
"""Best-effort shutdown of an arcade-telemetry handle."""
89+
if handle is None:
90+
return
91+
shutdown_fn = getattr(handle, "shutdown", None)
92+
if callable(shutdown_fn):
93+
shutdown_fn()

libs/arcade-serve/arcade_serve/fastapi/telemetry.py

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
import urllib.parse
44
import warnings
5-
from typing import Literal, Optional
5+
from typing import Any, Literal, Optional
66

77
# requests scans the environment for chardet at import time and emits a
88
# RequestsDependencyWarning when chardet>=6 is present (e.g. pulled in by tox).
@@ -34,6 +34,8 @@
3434
from opentelemetry.semconv._incubating.attributes import deployment_attributes
3535
from opentelemetry.semconv.attributes import service_attributes
3636

37+
from arcade_serve.fastapi import _arcade_telemetry
38+
3739
EXCLUDED_URLS = "/worker/health"
3840
EXCLUDED_SPANS: list[Literal["send", "receive"]] = ["send", "receive"]
3941

@@ -43,39 +45,75 @@ class ShutdownError(Exception):
4345

4446

4547
class OTELHandler:
46-
def __init__(self, enable: bool = True, log_level: int = logging.INFO):
48+
def __init__(
49+
self,
50+
enable: bool = True,
51+
log_level: int = logging.INFO,
52+
*,
53+
service_name: str = "worker",
54+
service_version: str = "",
55+
):
4756
self.enable = enable
4857
self.log_level = log_level
58+
self.service_name = service_name
59+
self.service_version = service_version
4960
self._tracer_provider: Optional[TracerProvider] = None
5061
self._tracer_span_exporter: Optional[OTLPSpanExporter] = None
5162
self._meter_provider: Optional[MeterProvider] = None
5263
self._meter_reader: Optional[PeriodicExportingMetricReader] = None
5364
self._otlp_metric_exporter: Optional[OTLPMetricExporter] = None
5465
self._logger_provider: Optional[LoggerProvider] = None
5566
self._log_processor: Optional[BatchLogRecordProcessor] = None
67+
self._arcade_telemetry_handle: Optional[Any] = None
5668
self.environment = os.environ.get("ARCADE_ENVIRONMENT", "local")
5769

5870
def instrument_app(self, app: FastAPI) -> None:
59-
if self.enable:
60-
logging.info(
61-
"🔎 Initializing OpenTelemetry. Use environment variables to configure the connection"
62-
)
63-
self.resource = Resource(
64-
attributes={
65-
service_attributes.SERVICE_NAME: "worker",
66-
deployment_attributes.DEPLOYMENT_ENVIRONMENT_NAME: self.environment,
67-
}
68-
)
69-
70-
self._init_tracer()
71-
self._init_metrics()
72-
self._init_logging(self.log_level)
73-
FastAPIInstrumentor().instrument_app(
74-
app, excluded_urls=EXCLUDED_URLS, exclude_spans=EXCLUDED_SPANS
71+
if not self.enable:
72+
return
73+
74+
if _arcade_telemetry.is_available():
75+
logging.info("🔎 Initializing OpenTelemetry via arcade-telemetry")
76+
handle = _arcade_telemetry.init_providers(
77+
service_name=self.service_name,
78+
environment=self.environment,
79+
version=self.service_version,
80+
log_level=self.log_level,
7581
)
76-
HTTPXClientInstrumentor()._instrument(tracer_provider=self._tracer_provider)
77-
AioHttpClientInstrumentor()._instrument(tracer_provider=self._tracer_provider)
78-
RequestsInstrumentor()._instrument(tracer_provider=self._tracer_provider)
82+
if handle is not None:
83+
self._arcade_telemetry_handle = handle
84+
FastAPIInstrumentor().instrument_app(
85+
app, excluded_urls=EXCLUDED_URLS, exclude_spans=EXCLUDED_SPANS
86+
)
87+
# Pass tracer_provider=None so instrumentors pick up the global
88+
# provider set by arcade-telemetry.
89+
HTTPXClientInstrumentor()._instrument(tracer_provider=None)
90+
AioHttpClientInstrumentor()._instrument(tracer_provider=None)
91+
RequestsInstrumentor()._instrument(tracer_provider=None)
92+
return
93+
# init_providers returned None (arcade-telemetry import race or
94+
# internal opt-out) — fall through to the in-house OTLP setup so
95+
# shutdown() has something to tear down.
96+
97+
logging.info(
98+
"🔎 Initializing OpenTelemetry. Use environment variables to configure the connection"
99+
)
100+
resource_attrs: dict[str, str] = {
101+
service_attributes.SERVICE_NAME: self.service_name,
102+
deployment_attributes.DEPLOYMENT_ENVIRONMENT_NAME: self.environment,
103+
}
104+
if self.service_version:
105+
resource_attrs[service_attributes.SERVICE_VERSION] = self.service_version
106+
self.resource = Resource(attributes=resource_attrs)
107+
108+
self._init_tracer()
109+
self._init_metrics()
110+
self._init_logging(self.log_level)
111+
FastAPIInstrumentor().instrument_app(
112+
app, excluded_urls=EXCLUDED_URLS, exclude_spans=EXCLUDED_SPANS
113+
)
114+
HTTPXClientInstrumentor()._instrument(tracer_provider=self._tracer_provider)
115+
AioHttpClientInstrumentor()._instrument(tracer_provider=self._tracer_provider)
116+
RequestsInstrumentor()._instrument(tracer_provider=self._tracer_provider)
79117

80118
def _init_tracer(self) -> None:
81119
self._tracer_provider = TracerProvider(resource=self.resource)
@@ -152,6 +190,10 @@ def _shutdown_logging(self) -> None:
152190
self._logger_provider.shutdown()
153191

154192
def shutdown(self) -> None:
193+
if self._arcade_telemetry_handle is not None:
194+
_arcade_telemetry.shutdown(self._arcade_telemetry_handle)
195+
self._arcade_telemetry_handle = None
196+
return
155197
self._shutdown_tracer()
156198
self._shutdown_metrics()
157199
self._shutdown_logging()

0 commit comments

Comments
 (0)