headroom/tests/test_observability_metrics.py
Devanshi Vyas 1588f5e041
feat: expose configured OTEL meters to integrations (#2519)
## Description

Expose a small public observability API that lets optional integrations
create
OpenTelemetry instruments using Headroom's configured meter provider.

Without this API, an integration must either rely on observability
internals or
create a second provider and exporter. `get_otel_meter(name, version)`
keeps
configuration, export, and shutdown ownership inside Headroom while
allowing
integration-specific instruments to use their own instrumentation scope.

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Add `HeadroomOtelMetrics.get_meter(name, version)` to obtain a meter
from the
  provider already owned by the Headroom metrics facade.
- Add and publicly export `headroom.observability.get_otel_meter(...)`.
- Preserve no-op-compatible OpenTelemetry behavior when Headroom-managed
metric
  export is not configured.
- Add a focused test proving integration instruments are collected by
the same
  configured provider.
- Add no dependencies and make no changes to existing metrics or
configuration.

## Testing

<!-- Check what you actually ran, then paste the real command output
below. -->

- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_observability_metrics.py -q
collected 6 items
tests/test_observability_metrics.py ......                               [100%]
6 passed in 4.72s

$ uv run ruff check .
All checks passed!

$ uv run ruff format --check .
1331 files already formatted
```

## Real Behavior Proof

- Environment: macOS (Darwin), Python 3.12.13, Headroom `0.33.0-dev`,
  OpenTelemetry SDK `1.39.1`, console metric exporter.
- Exact command / steps: Run the command below:
  ```bash
uv run python -c 'from headroom.observability import OTelMetricsConfig,
configure_otel_metrics, get_otel_meter, shutdown_otel_metrics;
configure_otel_metrics(OTelMetricsConfig(enabled=True,
exporter="console", service_name="headroom-integration-proof",
export_interval_millis=60000)); get_otel_meter("example.integration",
"1.0.0").create_counter("example.integration.events").add(3, {"source":
"extension-api"}); shutdown_otel_metrics()'
  ```

- Observed result: Headroom's console exporter emitted
  `example.integration.events` with value `3`, attribute
  `source="extension-api"`, instrumentation scope `example.integration`
version `1.0.0`, and resource service name `headroom-integration-proof`.
  This demonstrates that the public accessor participates in Headroom's
  configured provider and shutdown lifecycle.
- Not tested: network OTLP export, the complete repository test suite,
`mypy`,
  or Python versions other than 3.12 in this final validation.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

N/A — this change has no user-interface surface.
2026-07-28 15:33:08 -07:00

238 lines
7.3 KiB
Python

"""Tests for OTEL-backed operational observability."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import pytest
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
from headroom.observability import (
HeadroomOtelMetrics,
get_otel_meter,
reset_otel_metrics,
set_otel_metrics,
)
from headroom.proxy.prometheus_metrics import PrometheusMetrics
from headroom.transforms.pipeline import TransformPipeline
def _collect_metrics(reader: InMemoryMetricReader) -> dict[str, Any]:
data = reader.get_metrics_data()
collected: dict[str, Any] = {}
for resource_metric in data.resource_metrics:
for scope_metric in resource_metric.scope_metrics:
for metric in scope_metric.metrics:
collected[metric.name] = metric
return collected
def _find_point(metric: Any, **expected_attributes: Any) -> Any:
for point in metric.data.data_points:
if all(point.attributes.get(key) == value for key, value in expected_attributes.items()):
return point
raise AssertionError(f"No datapoint matched attributes: {expected_attributes}")
def test_headroom_otel_metrics_records_proxy_and_pipeline_metrics() -> None:
reader = InMemoryMetricReader()
provider = MeterProvider(metric_readers=[reader])
otel_metrics = HeadroomOtelMetrics(meter_provider=provider)
otel_metrics.record_proxy_request(
provider="anthropic",
model="claude-opus-4-6",
input_tokens=120,
output_tokens=30,
tokens_saved=45,
latency_ms=18.5,
cached=True,
overhead_ms=4.0,
ttfb_ms=12.0,
cache_read_tokens=25,
cache_write_tokens=35,
cache_write_5m_tokens=10,
cache_write_1h_tokens=25,
uncached_input_tokens=60,
)
otel_metrics.record_proxy_cache_bust(tokens_lost=7)
otel_metrics.record_pipeline_run(
model="claude-opus-4-6",
provider="anthropic",
tokens_before=120,
tokens_after=75,
duration_ms=6.5,
timing={"_deep_copy": 0.2, "router": 3.5, "pipeline_total": 6.5},
transforms_applied=["router:smart_crusher:0.35"],
waste_signals={"json_bloat": 12},
)
metrics = _collect_metrics(reader)
requests = metrics["headroom.proxy.requests"]
request_point = _find_point(
requests,
provider="anthropic",
model="claude-opus-4-6",
cached=True,
)
assert request_point.value == 1
latency = metrics["headroom.proxy.request.duration"]
latency_point = _find_point(
latency,
provider="anthropic",
model="claude-opus-4-6",
cached=True,
)
assert latency_point.count == 1
assert latency_point.sum == pytest.approx(0.0185)
ttl_tokens = metrics["headroom.proxy.cache.write_ttl_tokens"]
five_minute_ttl = _find_point(
ttl_tokens,
provider="anthropic",
model="claude-opus-4-6",
ttl="5m",
)
assert five_minute_ttl.value == 10
compression_runs = metrics["headroom.compression.runs"]
compression_point = _find_point(
compression_runs,
provider="anthropic",
model="claude-opus-4-6",
)
assert compression_point.value == 1
stage_duration = metrics["headroom.compression.stage.duration"]
router_stage = _find_point(
stage_duration,
provider="anthropic",
model="claude-opus-4-6",
stage="router",
)
assert router_stage.count == 1
assert router_stage.sum == pytest.approx(0.0035)
assert len(stage_duration.data.data_points) == 1
waste_tokens = metrics["headroom.compression.waste.tokens"]
waste_point = _find_point(
waste_tokens,
provider="anthropic",
model="claude-opus-4-6",
signal="json_bloat",
)
assert waste_point.value == 12
def test_get_otel_meter_uses_headrooms_configured_provider() -> None:
reader = InMemoryMetricReader()
provider = MeterProvider(metric_readers=[reader])
set_otel_metrics(HeadroomOtelMetrics(meter_provider=provider))
try:
meter = get_otel_meter("example.integration", "1.0.0")
meter.create_counter("example.integration.events").add(1, {"source": "test"})
metric = _collect_metrics(reader)["example.integration.events"]
point = _find_point(metric, source="test")
assert point.value == 1
finally:
reset_otel_metrics()
@dataclass
class _SpyMetrics:
pipeline_calls: list[dict[str, Any]] = field(default_factory=list)
def record_pipeline_run(self, **kwargs: Any) -> None:
self.pipeline_calls.append(kwargs)
@dataclass
class _SpyProxyMetrics:
failed_calls: list[dict[str, Any]] = field(default_factory=list)
rate_limited_calls: list[dict[str, Any]] = field(default_factory=list)
def record_proxy_failed(self, **kwargs: Any) -> None:
self.failed_calls.append(kwargs)
def record_proxy_rate_limited(self, **kwargs: Any) -> None:
self.rate_limited_calls.append(kwargs)
def test_transform_pipeline_simulate_skips_metric_recording() -> None:
spy = _SpyMetrics()
set_otel_metrics(spy) # type: ignore[arg-type]
try:
pipeline = TransformPipeline(transforms=[])
messages = [{"role": "user", "content": "hello world"}]
pipeline.apply(messages, model="gpt-4o", model_limit=1024)
assert len(spy.pipeline_calls) == 1
pipeline.simulate(messages, model="gpt-4o", model_limit=1024)
assert len(spy.pipeline_calls) == 1
finally:
reset_otel_metrics()
def test_proxy_failure_and_rate_limit_metrics_include_provider_labels() -> None:
reader = InMemoryMetricReader()
provider = MeterProvider(metric_readers=[reader])
otel_metrics = HeadroomOtelMetrics(meter_provider=provider)
otel_metrics.record_proxy_failed(provider="openai")
otel_metrics.record_proxy_rate_limited(provider="anthropic", model="claude-sonnet")
metrics = _collect_metrics(reader)
failed_point = _find_point(metrics["headroom.proxy.requests.failed"], provider="openai")
assert failed_point.value == 1
rate_limited_point = _find_point(
metrics["headroom.proxy.requests.rate_limited"],
provider="anthropic",
model="claude-sonnet",
)
assert rate_limited_point.value == 1
@pytest.mark.asyncio
async def test_prometheus_metrics_reads_late_configured_otel_metrics() -> None:
spy = _SpyProxyMetrics()
metrics = PrometheusMetrics()
set_otel_metrics(spy) # type: ignore[arg-type]
try:
await metrics.record_failed(provider="openai")
await metrics.record_rate_limited(provider="anthropic", model="claude-sonnet")
assert spy.failed_calls == [{"provider": "openai", "model": None}]
assert spy.rate_limited_calls == [{"provider": "anthropic", "model": "claude-sonnet"}]
finally:
reset_otel_metrics()
@pytest.mark.asyncio
async def test_prometheus_metrics_clamps_negative_token_savings() -> None:
metrics = PrometheusMetrics()
await metrics.record_request(
provider="openai",
model="openai-compatible",
input_tokens=100,
output_tokens=5,
tokens_saved=-25,
latency_ms=1.0,
)
assert metrics.tokens_saved_total == 0
assert metrics.savings_history[-1][1] == 0