"""OpenTelemetry tracing integration. Provides OTel-compatible tracing when opentelemetry SDK is installed. Falls back gracefully to no-op when unavailable. """ from __future__ import annotations import os from contextlib import contextmanager from typing import Any, Generator from fusionagi._logger import logger _tracer: Any = None _initialized = False class NoOpSpan: """No-op span for when OTel is unavailable.""" def set_attribute(self, key: str, value: Any) -> None: pass def set_status(self, status: Any) -> None: pass def record_exception(self, exception: Exception) -> None: pass def end(self) -> None: pass def __enter__(self) -> "NoOpSpan": return self def __exit__(self, *args: Any) -> None: pass class NoOpTracer: """No-op tracer for when OTel is unavailable.""" def start_span(self, name: str, **kwargs: Any) -> NoOpSpan: return NoOpSpan() @contextmanager def start_as_current_span(self, name: str, **kwargs: Any) -> Generator[NoOpSpan, None, None]: yield NoOpSpan() def init_otel(service_name: str = "fusionagi") -> Any: """Initialize OpenTelemetry tracing. Configures OTLP exporter if ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set. Falls back to no-op tracer if opentelemetry is not installed. Args: service_name: Service name for traces. Returns: Configured tracer instance. """ global _tracer, _initialized if _initialized: return _tracer _initialized = True try: from opentelemetry import trace from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor resource = Resource.create({"service.name": service_name}) provider = TracerProvider(resource=resource) endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") if endpoint: from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter exporter = OTLPSpanExporter(endpoint=endpoint) provider.add_span_processor(BatchSpanProcessor(exporter)) logger.info("OTel: OTLP exporter configured", extra={"endpoint": endpoint}) else: logger.info("OTel: no OTLP endpoint configured, using in-memory tracing") trace.set_tracer_provider(provider) _tracer = trace.get_tracer(service_name) logger.info("OTel: tracing initialized", extra={"service": service_name}) except ImportError: logger.info("OTel: opentelemetry not installed, using no-op tracer") _tracer = NoOpTracer() return _tracer def get_tracer() -> Any: """Return the global tracer (initializes on first call).""" global _tracer if _tracer is None: init_otel() return _tracer @contextmanager def trace_span(name: str, attributes: dict[str, Any] | None = None) -> Generator[Any, None, None]: """Context manager for creating a traced span. Args: name: Span name. attributes: Optional span attributes. Yields: Active span (OTel or NoOp). """ tracer = get_tracer() with tracer.start_as_current_span(name) as span: if attributes: for k, v in attributes.items(): span.set_attribute(k, str(v) if not isinstance(v, (str, int, float, bool)) else v) yield span