| """OpenTelemetry setup: Azure Monitor (deployed), OTLP/console (local), httpx outbound spans.""" |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import os |
| from typing import TYPE_CHECKING, Any, cast |
| from urllib.parse import urlparse |
|
|
| if TYPE_CHECKING: |
| from data360.config import MCPServerSettings |
|
|
| _logger = logging.getLogger(__name__) |
|
|
| _HTTPX_INSTRUMENTED = False |
| _MIN_TRANSPORT_PARTS_FOR_URL = 2 |
|
|
|
|
| def data360_operation_from_url(url: str | None) -> str: |
| """Derive a stable operation label for spans from the outbound request URL (no query logged).""" |
| if not url: |
| return "http" |
| try: |
| path = urlparse(url).path.lower() |
| except Exception: |
| return "http" |
| if "searchv2" in path: |
| return "search" |
| if "metadata" in path: |
| return "metadata" |
| if "disaggregation" in path: |
| return "disaggregation" |
| |
| if path.rstrip("/").endswith("/data") or "/data360/data" in path: |
| return "data" |
| if "codelist" in path: |
| return "codelist" |
| if path.rstrip("/").endswith("/indicators"): |
| return "indicators" |
| if "chart" in path or "vega" in path: |
| return "charts" |
| return "http" |
|
|
|
|
| def _httpx_request_hook(span, request: object) -> None: |
| """Attach Data360-specific attributes; never log bodies or full URLs with secrets.""" |
| try: |
| if span is None: |
| return |
| is_recording = getattr(span, "is_recording", None) |
| if callable(is_recording) and not is_recording(): |
| return |
| url_str: str | None = None |
| if isinstance(request, tuple) and len(request) >= _MIN_TRANSPORT_PARTS_FOR_URL: |
| url_str = str(request[1]) |
| else: |
| req_any = cast("Any", request) |
| url_attr = getattr(req_any, "url", None) |
| if url_attr is not None: |
| url_str = str(url_attr) |
| op = data360_operation_from_url(url_str) |
| span.set_attribute("data360.operation", op) |
| if url_str: |
| try: |
| parsed = urlparse(url_str) |
| netloc = parsed.netloc.split("@")[-1] |
| span.set_attribute("server.address", netloc.split(":")[0]) |
| except Exception: |
| pass |
| except Exception: |
| pass |
|
|
|
|
| def _httpx_response_hook(span, request: object, response: object) -> None: |
| """Record outcome; avoid logging bodies.""" |
| try: |
| if span is None: |
| return |
| is_recording = getattr(span, "is_recording", None) |
| if callable(is_recording) and not is_recording(): |
| return |
| status = getattr(response, "status_code", None) |
| if status is not None: |
| span.set_attribute("http.response.status_code", int(status)) |
| except Exception: |
| pass |
|
|
|
|
| def configure_open_telemetry_for_server(settings: MCPServerSettings) -> None: |
| """Configure trace export: Azure Monitor when deployed; OTLP or console when local.""" |
| connection_string = settings.azure_connection_string or os.environ.get( |
| "APPLICATIONINSIGHTS_CONNECTION_STRING" |
| ) |
| env = (settings.env or "").lower() |
|
|
| if env != "local" and connection_string: |
| try: |
| from azure.monitor.opentelemetry import ( |
| configure_azure_monitor, |
| ) |
|
|
| configure_azure_monitor(connection_string=connection_string) |
| _logger.info("Azure Monitor OpenTelemetry configured.") |
| except ImportError: |
| _logger.warning("azure-monitor-opentelemetry not available.") |
| return |
|
|
| |
| endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or os.environ.get( |
| "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" |
| ) |
| console = os.environ.get("MCP_OTEL_CONSOLE", "").lower() in ("1", "true", "yes") |
|
|
| if not endpoint and not console: |
| _logger.debug( |
| "Local telemetry: no OTLP endpoint and MCP_OTEL_CONSOLE unset; " |
| "traces use noop unless OTEL_* env configures a provider elsewhere." |
| ) |
| return |
|
|
| from opentelemetry import trace |
| from opentelemetry.sdk.resources import SERVICE_NAME, Resource |
| from opentelemetry.sdk.trace import TracerProvider |
| from opentelemetry.sdk.trace.export import BatchSpanProcessor |
|
|
| service_name = os.environ.get("OTEL_SERVICE_NAME", "data360-mcp") |
| resource = Resource.create({SERVICE_NAME: service_name}) |
| provider = TracerProvider(resource=resource) |
|
|
| if console: |
| from opentelemetry.sdk.trace.export import ConsoleSpanExporter |
|
|
| provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter())) |
| _logger.info("OpenTelemetry console span export enabled (MCP_OTEL_CONSOLE).") |
|
|
| if endpoint: |
| try: |
| use_http = endpoint.startswith(("http://", "https://")) |
| proto = os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL", "").lower() |
| if proto in ("http/protobuf", "http/json"): |
| use_http = True |
| if use_http: |
| from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( |
| OTLPSpanExporter, |
| ) |
| else: |
| from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( |
| OTLPSpanExporter, |
| ) |
|
|
| exporter = OTLPSpanExporter() |
| provider.add_span_processor(BatchSpanProcessor(exporter)) |
| _logger.info( |
| "OpenTelemetry OTLP trace export enabled (endpoint from OTEL_EXPORTER_OTLP_*)." |
| ) |
| except Exception: |
| _logger.exception("Failed to configure OTLP trace exporter; traces may be incomplete.") |
|
|
| trace.set_tracer_provider(provider) |
|
|
|
|
| def instrument_httpx_outbound() -> None: |
| """Patch httpx so all AsyncClient/Client requests emit dependency spans.""" |
| global _HTTPX_INSTRUMENTED |
| if _HTTPX_INSTRUMENTED: |
| return |
| try: |
| from opentelemetry.instrumentation.httpx import ( |
| HTTPXClientInstrumentor, |
| ) |
|
|
| HTTPXClientInstrumentor().instrument( |
| request_hook=_httpx_request_hook, |
| response_hook=_httpx_response_hook, |
| ) |
| _HTTPX_INSTRUMENTED = True |
| _logger.info("HTTPX OpenTelemetry instrumentation enabled.") |
| except ImportError: |
| _logger.warning("opentelemetry-instrumentation-httpx not installed.") |
|
|