Skip to content
Open
19 changes: 6 additions & 13 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -243,20 +243,13 @@ jobs:
tests/live_gateway/mcp/test_request_trace_policy.py \
tests/unit/mcpgateway/services/test_a2a_service.py \
tests/live_gateway/a2a/test_a2a_trace_context_e2e.py \
--basetemp="${RUNNER_TEMP}/observability-tests" -v --tb=short
uv run --extra plugins --extra observability pytest \
tests/unit/mcpgateway/services/test_gateway_service.py \
-k health_completion_metrics -v --tb=short
-v --tb=short

- name: Preserve observability failure artifacts
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: observability-failure-artifacts
path: |
${{ runner.temp }}/observability-tests/**/*.log
${{ runner.temp }}/observability-tests/**/collector.jsonl
retention-days: 7
- name: 🧪 Legacy Instana (< 3.12) live-gateway regression
run: |
LEGACY_INSTANA_E2E=1 uv run --extra observability --with "instana==3.11.1" pytest \
tests/live_gateway/mcp/test_legacy_instana_e2e.py \
-v --tb=short

# -----------------------------------------------------------
# 4️⃣ Upload coverage artifacts (XML + HTML)
Expand Down
142 changes: 141 additions & 1 deletion mcpgateway/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import inspect
import logging
import os
import sys
from typing import Any, Callable, cast, Dict, Mapping, MutableMapping, Optional
from urllib.parse import urlparse

Expand All @@ -42,6 +43,7 @@
OTEL_AVAILABLE = True
except ImportError:
# OpenTelemetry not installed - set to None for graceful degradation
otel_baggage = None
trace = None
otel_extract = None
otel_inject = None
Expand Down Expand Up @@ -95,7 +97,6 @@ def create(attrs: Dict[str, Any]) -> Dict[str, Any]: # type: ignore[override]
# Provide minimal module shims so tests can patch ConsoleSpanExporter path
try:
# Standard
import sys
import types

if ("pytest" in sys.modules) or (os.getenv("MCP_TESTING") == "1"):
Expand Down Expand Up @@ -346,6 +347,139 @@ class BaggageSpanAttributePolicy:
_BAGGAGE_SPAN_ATTRIBUTE_POLICY = BaggageSpanAttributePolicy()


_LEGACY_INSTANA_COMPAT_MARKER = "_contextforge_otel_context_compat"


def _legacy_instana_span_context(context: Any, span_context_cls: Any) -> Any:
"""Translate an OpenTelemetry ``Context`` into a legacy Instana ``SpanContext``.

Mirrors the parent conversion that Instana >= 3.12 performs for ``context=``.
An explicit context without a valid span yields an invalid Instana span
context, so the new span becomes a root (standard OTel semantics) instead of
silently inheriting the ambient current span.

Args:
context: OpenTelemetry context supplied by the caller.
span_context_cls: ``instana.span_context.SpanContext``.

Returns:
An Instana ``SpanContext`` describing the parent.
"""
# Third-Party
from instana.util.ids import hex_id, hex_id_limited # pylint: disable=import-outside-toplevel,import-error
from opentelemetry import trace as otel_trace_api # pylint: disable=import-outside-toplevel

parent = otel_trace_api.get_current_span(context).get_span_context()
if isinstance(parent, span_context_cls):
return parent
if parent is None or not parent.is_valid:
return span_context_cls(trace_id=otel_trace_api.INVALID_TRACE_ID, span_id=otel_trace_api.INVALID_SPAN_ID, is_remote=False)
hex_trace_id = hex_id(parent.trace_id)
is_long = len(hex_trace_id) > 16
return span_context_cls(
trace_id=int(hex_id_limited(parent.trace_id), 16) if is_long else parent.trace_id,
span_id=parent.span_id,
is_remote=parent.is_remote,
trace_flags=parent.trace_flags,
trace_state=parent.trace_state,
long_trace_id=hex_trace_id if is_long else None,
)


def _wrap_legacy_instana_method(original: Callable[..., Any], span_context_cls: Any) -> Callable[..., Any]:
"""Wrap a legacy Instana tracer method so it accepts the OTel ``context=`` keyword.

Args:
original: Unbound ``InstanaTracer`` method that only understands ``span_context``.
span_context_cls: ``instana.span_context.SpanContext``.

Returns:
Wrapper translating ``context`` into ``span_context``; all other arguments,
return values, and exceptions pass through unchanged.
"""

def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
"""Translate ``context`` to ``span_context`` and delegate.

Args:
self: Tracer instance.
*args: Positional arguments forwarded unchanged.
**kwargs: Keyword arguments; ``context`` is translated.

Returns:
Whatever the original method returns.
"""
if "context" in kwargs:
context = kwargs.pop("context")
if context is not None and kwargs.get("span_context") is None:
kwargs["span_context"] = _legacy_instana_span_context(context, span_context_cls)
return original(self, *args, **kwargs)

wrapper.__name__ = getattr(original, "__name__", "wrapper")
wrapper.__qualname__ = getattr(original, "__qualname__", wrapper.__name__)
wrapper.__doc__ = getattr(original, "__doc__", None)
wrapper.__wrapped__ = original # type: ignore[attr-defined]
setattr(wrapper, _LEGACY_INSTANA_COMPAT_MARKER, True)
return wrapper


def _accepts_kwarg(func: Any, name: str) -> Optional[bool]:
"""Return whether ``func`` accepts keyword ``name``; ``None`` if not introspectable.

Args:
func: Callable to inspect.
name: Keyword argument name.

Returns:
True/False, or None when the signature cannot be determined.
"""
try:
parameters = inspect.signature(func).parameters
except (TypeError, ValueError):
return None
return name in parameters or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parameters.values())


def ensure_legacy_instana_context_compat() -> bool:
"""Make legacy Instana tracers (< 3.12) accept the standard OTel ``context=`` keyword.

MCP SDK 2.x, FastAPI native telemetry, and ContextForge call
``start_span(context=...)`` / ``start_as_current_span(context=...)``. Legacy
``InstanaTracer`` only accepts ``span_context=`` and raises ``TypeError``.
The adapter patches the ``InstanaTracer`` class once, so tracers cached by
third-party modules before ContextForge initializes are covered too.

It is a no-op when Instana was not loaded by the process (this function never
imports Instana itself), when the tracer already supports ``context``
(Instana >= 3.12), or when it was already applied.

Returns:
True if the legacy tracer is (now) adapted, False otherwise.
"""
tracer_module = sys.modules.get("instana.tracer")
span_context_module = sys.modules.get("instana.span_context")
tracer_cls = getattr(tracer_module, "InstanaTracer", None)
span_context_cls = getattr(span_context_module, "SpanContext", None)
if tracer_cls is None or span_context_cls is None:
return False

adapted = False
for method_name in ("start_span", "start_as_current_span"):
method = tracer_cls.__dict__.get(method_name)
if method is None:
continue
if getattr(method, _LEGACY_INSTANA_COMPAT_MARKER, False):
adapted = True
continue
# Only adapt the exact legacy capability: explicit `span_context`, no `context`.
if _accepts_kwarg(method, "context") is not False or _accepts_kwarg(method, "span_context") is not True:
continue
setattr(tracer_cls, method_name, _wrap_legacy_instana_method(method, span_context_cls))
adapted = True
logger.info("Adapted legacy InstanaTracer.%s to accept the OpenTelemetry `context` argument", method_name)
return adapted


def _supports_exporter_kwarg(exporter_cls: Any, kwarg: str) -> bool:
"""Return whether an exporter constructor accepts a keyword argument.

Expand Down Expand Up @@ -1196,6 +1330,12 @@ def init_telemetry() -> Optional[Any]:
"""
# pylint: disable=global-statement
global _TRACER
# Process-wide: FastAPI and MCP SDK telemetry use the global (possibly legacy
# Instana) tracer even when ContextForge's own OTEL integration is disabled.
try:
ensure_legacy_instana_context_compat()
except Exception as exc: # pylint: disable=broad-exception-caught
logger.warning("Failed to adapt legacy Instana tracer: %s", exc)
cfg = get_settings()

# Check if observability is disabled (default: disabled)
Expand Down
1 change: 1 addition & 0 deletions tests/live_gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Specific subsuites need additional services on top:
| `mcp/` | gateway with MCP transports registered | `make testing-up` (default profile) |
| `mcp/test_oauth_status_live.py` | Postgres reachable at `localhost:5433` (the compose default) | `make testing-up` |
| `mcp/test_private_key_jwt_e2e.py` | stub AS and upstream reachable via `host.docker.internal` (the compose default) | `make testing-up` |
| `mcp/test_legacy_instana_e2e.py` | self-contained: boots its own gateway under legacy Instana AutoTrace; set `LEGACY_INSTANA_E2E=1` to fail instead of skip | `LEGACY_INSTANA_E2E=1 uv run --extra observability --with "instana==3.11.1" pytest tests/live_gateway/mcp/test_legacy_instana_e2e.py` |
| `sso/` | Keycloak (jwks tests) and/or Entra ID (entra tests) | `docker compose --profile sso up -d` for Keycloak; `AZURE_*` env vars for Entra |
| `e2e_rust/` | gateway built with the Rust transport (edge or full mode) | `make testing-up` with the Rust profile, or rebuild compose images with Rust enabled |
| `a2a/` | self-contained: each test boots its own gateway subprocess; needs the `observability` extra | `uv run --extra plugins --extra observability pytest tests/live_gateway/a2a/` |
Expand Down
Loading