Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 24 additions & 60 deletions src/celeste/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down
31 changes: 31 additions & 0 deletions tests/unit_tests/test_telemetry_noop.py
Original file line number Diff line number Diff line change
@@ -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
Loading