From 107575a7292a15619da70d9765d6727e693b4094 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Wed, 17 Jun 2026 10:04:13 +0200 Subject: [PATCH] refactor(telemetry): collapse 6 OpenTelemetry no-op stubs into one proxy When opentelemetry-api is not installed, telemetry fell back to six hand-written no-op stand-ins (_NoOpSpan / _NoOpTracer / _NoOpStatus / _NoOpStatusCode / _NoOpHistogram / _NoOpMeter). Replace them with a single _NoOp proxy that returns itself for every attribute access and call, covering span/tracer/meter/histogram usage plus Status(StatusCode.ERROR, ...) construction and the StatusCode.ERROR attribute. _noop_use_span stays separate: it must yield the passed-in span, which a self-returning proxy would not. Add test_telemetry_noop.py exercising every call shape the telemetry module uses against the proxy; the import-fallback path previously had no test coverage. No behavior change. The real-OpenTelemetry path is untouched. --- src/celeste/telemetry.py | 84 +++++++------------------ tests/unit_tests/test_telemetry_noop.py | 31 +++++++++ 2 files changed, 55 insertions(+), 60 deletions(-) create mode 100644 tests/unit_tests/test_telemetry_noop.py diff --git a/src/celeste/telemetry.py b/src/celeste/telemetry.py index 1230c08..1cb3f66 100644 --- a/src/celeste/telemetry.py +++ b/src/celeste/telemetry.py @@ -48,39 +48,33 @@ } -class _NoOpSpan: - """Span stand-in used when ``opentelemetry-api`` is not installed.""" +class _NoOp: + """No-op stand-in for opentelemetry-api objects when it is not installed. - def set_attribute(self, key: str, value: Any) -> None: - """Discard the attribute.""" - - def set_attributes(self, attributes: dict[str, Any]) -> None: - """Discard the attribute mapping.""" - - def set_status(self, *args: Any, **kwargs: Any) -> None: - """Discard the status update.""" - - def record_exception(self, *args: Any, **kwargs: Any) -> None: - """Discard the exception.""" + Returns itself for every attribute access and call, so spans, tracers, meters, + histograms, Status and StatusCode all degrade to silent no-ops. + """ - def add_event(self, *args: Any, **kwargs: Any) -> None: - """Discard the span event.""" + ERROR = "ERROR" # satisfies StatusCode.ERROR - def end(self) -> None: - """Discard the end signal.""" + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Accept and discard any construction arguments.""" + def __call__(self, *args: Any, **kwargs: Any) -> "_NoOp": + """Return self for any call (e.g. start_as_current_span, create_histogram).""" + return self -class _NoOpTracer: - """Tracer stand-in used when ``opentelemetry-api`` is not installed.""" + def __getattr__(self, name: str) -> "_NoOp": + """Return self for any attribute (span/meter/histogram methods).""" + return self - @contextmanager - def start_as_current_span(self, name: str, **kwargs: Any) -> Iterator[_NoOpSpan]: - """Yield a no-op span as a context manager.""" - yield _NoOpSpan() + def __enter__(self) -> "_NoOp": + """Enter the no-op span context.""" + return self - def start_span(self, name: str, **kwargs: Any) -> _NoOpSpan: - """Return a detached no-op span.""" - return _NoOpSpan() + def __exit__(self, *args: Any) -> None: + """Exit the no-op span context.""" + return None @contextmanager @@ -89,36 +83,6 @@ def _noop_use_span(span: Any, end_on_exit: bool = False) -> Iterator[Any]: yield span -class _NoOpStatus: - """Status stand-in used when ``opentelemetry-api`` is not installed.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Discard the status arguments.""" - - -class _NoOpStatusCode: - """StatusCode stand-in used when ``opentelemetry-api`` is not installed.""" - - ERROR = "ERROR" - - -class _NoOpHistogram: - """Histogram stand-in used when ``opentelemetry-api`` is not installed.""" - - def record(self, value: float, attributes: dict[str, Any] | None = None) -> None: - """Discard the metric record.""" - - -class _NoOpMeter: - """Meter stand-in used when ``opentelemetry-api`` is not installed.""" - - def create_histogram( - self, name: str, unit: str = "", description: str = "" - ) -> _NoOpHistogram: - """Return a no-op histogram.""" - return _NoOpHistogram() - - try: from opentelemetry import metrics as _otel_metrics from opentelemetry import trace as _otel_trace @@ -131,11 +95,11 @@ def create_histogram( StatusCode: Any = _OtelStatusCode meter: Any = _otel_metrics.get_meter("celeste") except ImportError: - tracer = _NoOpTracer() + tracer = _NoOp() use_span = _noop_use_span - Status = _NoOpStatus - StatusCode = _NoOpStatusCode - meter = _NoOpMeter() + Status = _NoOp + StatusCode = _NoOp + meter = _NoOp() _token_usage_histogram: Any = meter.create_histogram( diff --git a/tests/unit_tests/test_telemetry_noop.py b/tests/unit_tests/test_telemetry_noop.py new file mode 100644 index 0000000..d776dda --- /dev/null +++ b/tests/unit_tests/test_telemetry_noop.py @@ -0,0 +1,31 @@ +"""Tests for the no-op telemetry proxy used when opentelemetry-api is absent.""" + +from celeste.telemetry import _NoOp, _noop_use_span + + +def test_noop_proxy_supports_all_call_shapes() -> None: + """The _NoOp proxy must satisfy every span/meter/Status call site used in telemetry.""" + noop = _NoOp() + + # tracer.start_as_current_span(...) used as a context manager yielding a span + with noop.start_as_current_span("op") as span: + span.set_attribute("k", "v") + span.set_attributes({"k": "v"}) + span.add_event("e", {"a": 1}) + span.record_exception(Exception("boom")) + span.set_status(_NoOp(_NoOp.ERROR, "msg")) # Status(StatusCode.ERROR, msg) + span.end() + + # StatusCode.ERROR is read as a real class attribute + assert _NoOp.ERROR == "ERROR" + + # meter.create_histogram(...).record(...) chains through the proxy + histogram = noop.create_histogram(name="n", unit="{token}", description="d") + histogram.record(1.0, {"gen_ai.token.type": "input"}) + + +def test_noop_use_span_yields_the_passed_span() -> None: + """_noop_use_span must yield the span it was given, not a no-op stand-in.""" + sentinel = object() + with _noop_use_span(sentinel) as span: + assert span is sentinel