Coverage for qreward / utils / scheduler / telemetry.py: 97.44%
68 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-23 19:32 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-23 19:32 +0000
1"""Optional OpenTelemetry integration for schedule metrics export.
3All OTel imports are guarded by try/except so this module works
4even when opentelemetry packages are not installed.
5"""
7import os
8import logging
9from typing import Any, Optional
11logger = logging.getLogger(__name__)
13_OTEL_AVAILABLE = False
14_meter_mod: Any = None
15_trace_mod: Any = None
17try:
18 from opentelemetry import metrics as _meter_mod # type: ignore[no-redef]
19 from opentelemetry import trace as _trace_mod # type: ignore[no-redef]
20 _OTEL_AVAILABLE = True
21except ImportError:
22 pass
24_ENV_ENABLED_KEY = "QREWARD_OTEL_ENABLED"
27class TelemetryExporter:
28 """Exports ScheduleMetrics to OpenTelemetry counters, histograms and spans.
30 When OTel SDK is not installed or disabled via environment variable,
31 all methods are no-ops.
33 Example:
34 >>> exporter = TelemetryExporter()
35 >>> exporter.record(metrics) # records counters + histogram
36 >>> with exporter.span("my_func") as span:
37 ... span.set_attribute("retry_count", 3)
38 """
40 def __init__(
41 self,
42 meter_name: str = "qreward.scheduler",
43 tracer_name: str = "qreward.scheduler",
44 ) -> None:
45 self._enabled = self._check_enabled()
46 self._meter: Any = None
47 self._tracer: Any = None
48 self._total_calls_counter: Any = None
49 self._success_counter: Any = None
50 self._failure_counter: Any = None
51 self._latency_histogram: Any = None
53 if self._enabled:
54 self._meter = _meter_mod.get_meter(meter_name)
55 self._tracer = _trace_mod.get_tracer(tracer_name)
56 self._total_calls_counter = self._meter.create_counter(
57 "qreward.schedule.total_calls",
58 description="Total number of schedule invocations",
59 )
60 self._success_counter = self._meter.create_counter(
61 "qreward.schedule.success_count",
62 description="Number of successful schedule completions",
63 )
64 self._failure_counter = self._meter.create_counter(
65 "qreward.schedule.failure_count",
66 description="Number of failed schedule attempts",
67 )
68 self._latency_histogram = self._meter.create_histogram(
69 "qreward.schedule.latency_ms",
70 unit="ms",
71 description="Schedule invocation latency in milliseconds",
72 )
74 @classmethod
75 def is_available(cls) -> bool:
76 """Check if OpenTelemetry SDK is installed and enabled."""
77 return _OTEL_AVAILABLE and cls._check_enabled()
79 @staticmethod
80 def _check_enabled() -> bool:
81 """Check if OTel is available and not disabled by env var."""
82 if not _OTEL_AVAILABLE:
83 return False
84 env_val = os.environ.get(_ENV_ENABLED_KEY, "true").lower()
85 return env_val not in ("false", "0", "no", "off")
87 def record(self, metrics: Any) -> None:
88 """Record schedule metrics to OTel counters and histogram.
90 Args:
91 metrics: A ScheduleMetrics instance.
92 """
93 if not self._enabled:
94 return
95 self._total_calls_counter.add(metrics.total_calls)
96 self._success_counter.add(metrics.success_count)
97 self._failure_counter.add(metrics.failure_count)
98 self._latency_histogram.record(metrics.total_latency_ms)
100 def start_span(
101 self, name: str, attributes: Optional[dict[str, Any]] = None
102 ) -> Any:
103 """Start a new OTel span.
105 Args:
106 name: Span name (typically the decorated function name).
107 attributes: Optional span attributes.
109 Returns:
110 A span object, or a no-op context if OTel is disabled.
111 """
112 if not self._enabled or self._tracer is None:
113 return _NoOpSpan()
114 return self._tracer.start_span(name, attributes=attributes)
116 def end_span(self, span: Any, metrics: Any) -> None:
117 """End a span and attach metrics as attributes.
119 Args:
120 span: The span to end.
121 metrics: A ScheduleMetrics instance.
122 """
123 if not self._enabled or isinstance(span, _NoOpSpan):
124 return
125 span.set_attribute("qreward.total_calls", metrics.total_calls)
126 span.set_attribute(
127 "qreward.success_count", metrics.success_count
128 )
129 span.set_attribute(
130 "qreward.failure_count", metrics.failure_count
131 )
132 span.set_attribute("qreward.retry_count", metrics.retry_count)
133 span.set_attribute(
134 "qreward.total_latency_ms", metrics.total_latency_ms
135 )
136 span.end()
139class _NoOpSpan:
140 """No-op span used when OTel is not available."""
142 def set_attribute(self, key: str, value: Any) -> None:
143 pass
145 def end(self) -> None:
146 pass
148 def __enter__(self) -> "_NoOpSpan":
149 return self
151 def __exit__(self, *args: Any) -> None:
152 pass