"""OpenTelemetry tracer integration with no-op fallback.""" from __future__ import annotations import logging from dataclasses import dataclass from typing import ContextManager logger = logging.getLogger(__name__) @dataclass class TelemetryConfig: """Telemetry configuration.""" enabled: bool = False otlp_endpoint: str = "" service_name: str = "agentkit" sample_rate: float = 1.0 class NoOpSpan: """No-op span when telemetry is disabled.""" def __enter__(self): return self def __exit__(self, *args): pass def set_attribute(self, key: str, value: object) -> None: pass def add_event(self, name: str, attributes: dict[str, object] | None = None) -> None: pass def record_exception(self, exception: Exception) -> None: pass def is_recording(self) -> bool: return False class NoOpTracer: """No-op tracer when telemetry is disabled.""" def start_span( self, name: str, attributes: dict[str, object] | None = None ) -> ContextManager[NoOpSpan]: return NoOpSpan() def start_as_current_span( self, name: str, attributes: dict[str, object] | None = None ) -> ContextManager[NoOpSpan]: return NoOpSpan() class OTelSpan: """Wrapper around OpenTelemetry Span.""" def __init__(self, span: object): self._span = span def __enter__(self): self._span.__enter__() return self def __exit__(self, *args): self._span.__exit__(*args) def set_attribute(self, key: str, value: object) -> None: self._span.set_attribute(key, value) def add_event(self, name: str, attributes: dict[str, object] | None = None) -> None: self._span.add_event(name, attributes or {}) def record_exception(self, exception: Exception) -> None: self._span.record_exception(exception) def is_recording(self) -> bool: return self._span.is_recording() class OTelTracer: """Wrapper around OpenTelemetry Tracer.""" def __init__(self, tracer: object): self._tracer = tracer def start_span(self, name: str, attributes: dict[str, object] | None = None) -> OTelSpan: span = self._tracer.start_span(name, attributes=attributes) return OTelSpan(span) def start_as_current_span( self, name: str, attributes: dict[str, object] | None = None ) -> OTelSpan: span = self._tracer.start_as_current_span(name, attributes=attributes) return OTelSpan(span) # Global tracer instance _tracer: NoOpTracer | OTelTracer = NoOpTracer() def get_tracer() -> NoOpTracer | OTelTracer: """Get the global tracer instance.""" return _tracer def init_telemetry(config: TelemetryConfig) -> None: """Initialize telemetry with the given configuration. U1: OTel deps now required (pyproject.toml). ImportError fallback removed — a missing opentelemetry-* package is now a hard install error, not a silent monitoring blind spot. Runtime errors (network, exporter connect) still fall back to NoOpTracer so app startup never crashes on transient OTLP issues. """ global _tracer if not config.enabled: _tracer = NoOpTracer() logger.info("Telemetry disabled, using no-op tracer") return from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.resources import Resource from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter try: resource = Resource.create({"service.name": config.service_name}) provider = TracerProvider(resource=resource) if config.otlp_endpoint: exporter = OTLPSpanExporter(endpoint=config.otlp_endpoint) provider.add_span_processor(BatchSpanProcessor(exporter)) trace.set_tracer_provider(provider) _tracer = OTelTracer(trace.get_tracer(config.service_name)) logger.info(f"Telemetry initialized with endpoint: {config.otlp_endpoint}") except Exception as e: logger.warning(f"Failed to initialize telemetry: {e}, using no-op tracer") _tracer = NoOpTracer()