headroom/headroom/observability/metrics.py
JD Davis 2dc7e4ab27
test: add fluent Headroom harness (#2650)
## Description

Adds `headroom.testing`, a fluent, contractual test harness for building
Headroom scenarios and suites that can be simulated locally,
orchestrated, deployed through the proxy, and handed off to
`headroom-bench` / `agent-evals` with bench-native manifests.

Closes #

## Type of Change

- [x] 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)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Add `headroom.testing.Headroom` fluent scenario builder with
provider/platform/configuration facets such as `WithBedrock`,
`OnAppleSilicon`, `Configure`, `WithCompression`, `WithCCR`,
`WithCache`, `WithPrefixFreeze`, `WithReadMaturation`, and `WithMemory`.
- Add contractual coverage over the current `HeadroomConfig` and
`ProxyConfig` dataclass surfaces, including full JSON-ready proxy
deployment payloads.
- Add no-key local simulations, scenario/suite orchestration, guarantee
evaluation, deployment plans, and a local proxy lifecycle context
manager.
- Add `headroom-bench` handoff artifacts, including
`agent_evals.models.RunManifest`-compatible JSON without taking a
runtime dependency on `agent-evals`.
- Add demonstration tests for providers, feature facets, manifests,
suites, guarantees, deployment payloads, and the no-key simulation path.
- Fix unversioned OTEL meter lookup typing so `mypy headroom` remains
green on current `main`.

## Testing

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

### Test Output

```text
python -m ruff check .
All checks passed!

python -m mypy headroom
headroom\proxy\server.py:1680: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
headroom\proxy\server.py:1691: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
Success: no issues found in 512 source files

python -m pytest tests/test_cli/test_subprocess_utf8_encoding.py tests/test_testing_harness.py -q
24 passed, 1 warning in 4.68s
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, branch
`feat/headroom-test-harness` rebased on `headroomlabs-ai/main`.
- Exact command / steps: built a
`Headroom.WithOpenAI().WithCompression(mode="cache",
kompress=False).Build()` scenario and entered
`scenario.deploy_local(port=19192, timeout_s=20)`.
- Observed result: proxy launched, `/readyz` succeeded, handle returned
`http://127.0.0.1:19192`, `OPENAI_BASE_URL=http://127.0.0.1:19192/v1`,
and context-manager teardown completed.
- Exact command / steps: emitted
`scenario.agent_evals_manifest(...).to_dict()` and validated it with the
current cloned `headroom-bench` `agent_evals.models.RunManifest`
pydantic model.
- Observed result: validation succeeded with arms `a0_direct`,
`a1_passthrough`, and `b_headroom` for provider `openai`.
- Not tested: upstream-provider API calls requiring real
OpenAI/Anthropic/Bedrock keys; phase-1 validation intentionally stays
no-key/local.

## 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
- [x] 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
- [x] 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.

## Additional Notes

The pytest warning shown above is the existing OpenAI pricing-data
staleness warning from cost estimation. The harness does not call
upstream providers during local simulation.
2026-07-29 09:17:25 -07:00

602 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""OpenTelemetry-backed operational metrics for Headroom."""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from threading import Lock
from typing import Any, Literal
from opentelemetry import metrics
from opentelemetry.metrics import CallbackOptions, Observation
from headroom._version import get_version
logger = logging.getLogger(__name__)
MetricExporter = Literal["console", "otlp_http"]
_SCOPE_NAME = "headroom"
_DEFAULT_EXPORT_INTERVAL_MS = 10000
_MILLISECONDS_TO_SECONDS = 1000.0
_metrics_lock = Lock()
_global_metrics: HeadroomOtelMetrics | None = None
_owned_meter_provider: Any | None = None
_owned_metrics_config: OTelMetricsConfig | None = None
def _headroom_version() -> str:
return get_version()
def _parse_bool(raw: str | None, default: bool = False) -> bool:
if raw is None:
return default
value = raw.strip().lower()
if value in {"1", "true", "yes", "on"}:
return True
if value in {"0", "false", "no", "off"}:
return False
return default
def _parse_int(raw: str | None, default: int) -> int:
if raw is None:
return default
try:
value = int(raw.strip())
except ValueError:
return default
return value if value > 0 else default
def _parse_key_value_pairs(raw: str | None) -> dict[str, str]:
if raw is None:
return {}
pairs: dict[str, str] = {}
for item in raw.split(","):
part = item.strip()
if not part or "=" not in part:
continue
key, value = part.split("=", 1)
key = key.strip()
value = value.strip()
if key and value:
pairs[key] = value
return pairs
@dataclass(slots=True)
class OTelMetricsConfig:
"""Configuration for Headroom-managed OTEL metric export."""
enabled: bool = False
service_name: str = "headroom"
exporter: MetricExporter = "otlp_http"
endpoint: str | None = None
headers: dict[str, str] = field(default_factory=dict)
export_interval_millis: int = _DEFAULT_EXPORT_INTERVAL_MS
resource_attributes: dict[str, str] = field(default_factory=dict)
@classmethod
def from_env(cls, *, default_service_name: str = "headroom") -> OTelMetricsConfig:
exporter_raw = (
os.environ.get("HEADROOM_OTEL_METRICS_EXPORTER", "otlp_http")
.strip()
.lower()
.replace("-", "_")
)
if exporter_raw not in {"console", "otlp_http"}:
logger.warning(
"Unknown HEADROOM_OTEL_METRICS_EXPORTER=%s; falling back to otlp_http",
exporter_raw,
)
exporter_raw = "otlp_http"
return cls(
enabled=_parse_bool(os.environ.get("HEADROOM_OTEL_METRICS_ENABLED"), default=False),
service_name=os.environ.get("HEADROOM_OTEL_SERVICE_NAME", default_service_name).strip()
or default_service_name,
exporter=exporter_raw, # type: ignore[arg-type]
endpoint=os.environ.get("HEADROOM_OTEL_METRICS_ENDPOINT") or None,
headers=_parse_key_value_pairs(os.environ.get("HEADROOM_OTEL_METRICS_HEADERS")),
export_interval_millis=_parse_int(
os.environ.get("HEADROOM_OTEL_METRICS_EXPORT_INTERVAL_MS"),
_DEFAULT_EXPORT_INTERVAL_MS,
),
resource_attributes=_parse_key_value_pairs(
os.environ.get("HEADROOM_OTEL_RESOURCE_ATTRIBUTES")
),
)
def status(self) -> dict[str, Any]:
return {
"configured": True,
"enabled": self.enabled,
"service_name": self.service_name,
"exporter": self.exporter,
"endpoint": self.endpoint,
"resource_attributes": dict(self.resource_attributes),
}
class HeadroomOtelMetrics:
"""Shared OTEL metrics facade for Headroom operations."""
def __init__(self, meter_provider: Any | None = None):
self._meter_provider = meter_provider
if meter_provider is None:
self._meter = metrics.get_meter(_SCOPE_NAME, _headroom_version())
else:
self._meter = meter_provider.get_meter(_SCOPE_NAME, _headroom_version())
self._proxy_requests = self._meter.create_counter(
"headroom.proxy.requests",
description="Proxy requests handled by Headroom.",
unit="1",
)
self._proxy_cached_requests = self._meter.create_counter(
"headroom.proxy.requests.cached",
description="Proxy requests served with provider cache participation.",
unit="1",
)
self._proxy_failed_requests = self._meter.create_counter(
"headroom.proxy.requests.failed",
description="Proxy requests that failed.",
unit="1",
)
self._proxy_rate_limited_requests = self._meter.create_counter(
"headroom.proxy.requests.rate_limited",
description="Proxy requests rejected by rate limiting.",
unit="1",
)
self._proxy_input_tokens = self._meter.create_counter(
"headroom.proxy.tokens.input",
description="Input tokens received by the proxy.",
unit="1",
)
self._proxy_output_tokens = self._meter.create_counter(
"headroom.proxy.tokens.output",
description="Output tokens returned by upstream providers.",
unit="1",
)
self._proxy_saved_tokens = self._meter.create_counter(
"headroom.proxy.tokens.saved",
description="Input tokens saved by Headroom compression.",
unit="1",
)
self._proxy_cache_read_tokens = self._meter.create_counter(
"headroom.proxy.cache.read_tokens",
description="Provider cache read tokens observed by the proxy.",
unit="1",
)
self._proxy_cache_write_tokens = self._meter.create_counter(
"headroom.proxy.cache.write_tokens",
description="Provider cache write tokens observed by the proxy.",
unit="1",
)
self._proxy_cache_write_ttl_tokens = self._meter.create_counter(
"headroom.proxy.cache.write_ttl_tokens",
description="Provider cache write tokens by observed TTL bucket.",
unit="1",
)
self._proxy_uncached_input_tokens = self._meter.create_counter(
"headroom.proxy.cache.uncached_input_tokens",
description="Proxy input tokens not served from provider cache.",
unit="1",
)
self._proxy_cache_busts = self._meter.create_counter(
"headroom.proxy.cache.busts",
description="Requests that lost provider cache efficiency.",
unit="1",
)
self._proxy_cache_bust_tokens_lost = self._meter.create_counter(
"headroom.proxy.cache.bust_tokens_lost",
description="Tokens that lost provider cache discount because of compression.",
unit="1",
)
self._proxy_latency = self._meter.create_histogram(
"headroom.proxy.request.duration",
description="End-to-end proxy request duration.",
unit="s",
)
self._proxy_overhead = self._meter.create_histogram(
"headroom.proxy.overhead.duration",
description="Time spent inside Headroom optimization logic.",
unit="s",
)
self._proxy_ttfb = self._meter.create_histogram(
"headroom.proxy.ttfb.duration",
description="Upstream time to first byte observed by Headroom.",
unit="s",
)
self._compression_runs = self._meter.create_counter(
"headroom.compression.runs",
description="Compression pipeline runs executed by Headroom.",
unit="1",
)
self._compression_failures = self._meter.create_counter(
"headroom.compression.failures",
description="Compression operations that failed before producing a result.",
unit="1",
)
self._compression_input_tokens = self._meter.create_counter(
"headroom.compression.tokens.input",
description="Input tokens analyzed by Headroom compression.",
unit="1",
)
self._compression_output_tokens = self._meter.create_counter(
"headroom.compression.tokens.output",
description="Output tokens produced by Headroom compression.",
unit="1",
)
self._compression_saved_tokens = self._meter.create_counter(
"headroom.compression.tokens.saved",
description="Tokens removed by Headroom compression.",
unit="1",
)
self._compression_duration = self._meter.create_histogram(
"headroom.compression.pipeline.duration",
description="Compression pipeline execution duration.",
unit="s",
)
self._compression_stage_duration = self._meter.create_histogram(
"headroom.compression.stage.duration",
description="Per-stage compression timing emitted by the pipeline.",
unit="s",
)
self._compression_transforms = self._meter.create_counter(
"headroom.compression.transforms",
description="Transforms applied during compression.",
unit="1",
)
self._waste_signal_tokens = self._meter.create_counter(
"headroom.compression.waste.tokens",
description="Waste tokens detected in compressed inputs.",
unit="1",
)
# Backing values updated by record_subscription_window()
self._sub_5h_util_val: float = 0.0
self._sub_7d_util_val: float = 0.0
self._sub_5h_reset_val: float = 0.0
self._sub_7d_reset_val: float = 0.0
self._sub_overage_val: float = 0.0
# Subscription window gauges (Anthropic OAuth accounts)
def _cb_5h_util(opts: CallbackOptions) -> list[Observation]:
return [Observation(self._sub_5h_util_val)]
def _cb_7d_util(opts: CallbackOptions) -> list[Observation]:
return [Observation(self._sub_7d_util_val)]
def _cb_5h_reset(opts: CallbackOptions) -> list[Observation]:
return [Observation(self._sub_5h_reset_val)]
def _cb_7d_reset(opts: CallbackOptions) -> list[Observation]:
return [Observation(self._sub_7d_reset_val)]
def _cb_overage(opts: CallbackOptions) -> list[Observation]:
return [Observation(self._sub_overage_val)]
self._meter.create_observable_gauge(
"headroom.subscription.5h_utilization_pct",
description="Anthropic 5-hour rate-limit window utilisation (0100%).",
unit="1",
callbacks=[_cb_5h_util],
)
self._meter.create_observable_gauge(
"headroom.subscription.7d_utilization_pct",
description="Anthropic 7-day rate-limit window utilisation (0100%).",
unit="1",
callbacks=[_cb_7d_util],
)
self._meter.create_observable_gauge(
"headroom.subscription.5h_seconds_to_reset",
description="Seconds until the Anthropic 5-hour window resets.",
unit="s",
callbacks=[_cb_5h_reset],
)
self._meter.create_observable_gauge(
"headroom.subscription.7d_seconds_to_reset",
description="Seconds until the Anthropic 7-day window resets.",
unit="s",
callbacks=[_cb_7d_reset],
)
self._meter.create_observable_gauge(
"headroom.subscription.overage_usd",
description="Anthropic extra-usage (overage) credits consumed in USD.",
unit="USD",
callbacks=[_cb_overage],
)
def get_meter(self, name: str, version: str | None = None) -> Any:
"""Return a meter backed by Headroom's configured metric provider.
Optional integrations can use this to create their own instruments
without creating a second exporter or meter provider.
"""
if self._meter_provider is None:
if version is None:
return metrics.get_meter(name)
return metrics.get_meter(name, version)
if version is None:
return self._meter_provider.get_meter(name)
return self._meter_provider.get_meter(name, version)
@staticmethod
def _attrs(**attrs: Any) -> dict[str, Any]:
filtered: dict[str, Any] = {}
for key, value in attrs.items():
if value is None or value == "":
continue
filtered[key] = value
return filtered
def record_proxy_request(
self,
*,
provider: str,
model: str,
input_tokens: int,
output_tokens: int,
tokens_saved: int,
latency_ms: float,
cached: bool = False,
overhead_ms: float = 0.0,
ttfb_ms: float = 0.0,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
cache_write_5m_tokens: int = 0,
cache_write_1h_tokens: int = 0,
uncached_input_tokens: int = 0,
) -> None:
attrs = self._attrs(provider=provider, model=model, cached=cached)
self._proxy_requests.add(1, attrs)
if cached:
self._proxy_cached_requests.add(1, attrs)
self._proxy_input_tokens.add(max(input_tokens, 0), attrs)
self._proxy_output_tokens.add(max(output_tokens, 0), attrs)
self._proxy_saved_tokens.add(max(tokens_saved, 0), attrs)
self._proxy_latency.record(max(latency_ms, 0.0) / _MILLISECONDS_TO_SECONDS, attrs)
if overhead_ms > 0:
self._proxy_overhead.record(overhead_ms / _MILLISECONDS_TO_SECONDS, attrs)
if ttfb_ms > 0:
self._proxy_ttfb.record(ttfb_ms / _MILLISECONDS_TO_SECONDS, attrs)
if cache_read_tokens > 0:
self._proxy_cache_read_tokens.add(cache_read_tokens, attrs)
if cache_write_tokens > 0:
self._proxy_cache_write_tokens.add(cache_write_tokens, attrs)
if uncached_input_tokens > 0:
self._proxy_uncached_input_tokens.add(uncached_input_tokens, attrs)
if cache_write_5m_tokens > 0:
self._proxy_cache_write_ttl_tokens.add(
cache_write_5m_tokens,
self._attrs(provider=provider, model=model, ttl="5m"),
)
if cache_write_1h_tokens > 0:
self._proxy_cache_write_ttl_tokens.add(
cache_write_1h_tokens,
self._attrs(provider=provider, model=model, ttl="1h"),
)
def record_proxy_failed(self, *, provider: str | None = None, model: str | None = None) -> None:
self._proxy_failed_requests.add(1, self._attrs(provider=provider, model=model))
def record_proxy_rate_limited(
self,
*,
provider: str | None = None,
model: str | None = None,
) -> None:
self._proxy_rate_limited_requests.add(1, self._attrs(provider=provider, model=model))
def record_proxy_cache_bust(self, *, tokens_lost: int) -> None:
self._proxy_cache_busts.add(1)
self._proxy_cache_bust_tokens_lost.add(max(tokens_lost, 0))
def record_pipeline_run(
self,
*,
model: str,
provider: str | None,
tokens_before: int,
tokens_after: int,
duration_ms: float,
timing: dict[str, float] | None = None,
transforms_applied: list[str] | None = None,
waste_signals: dict[str, int] | None = None,
) -> None:
attrs = self._attrs(model=model, provider=provider)
tokens_saved = max(tokens_before - tokens_after, 0)
self._compression_runs.add(1, attrs)
self._compression_input_tokens.add(max(tokens_before, 0), attrs)
self._compression_output_tokens.add(max(tokens_after, 0), attrs)
self._compression_saved_tokens.add(tokens_saved, attrs)
self._compression_duration.record(max(duration_ms, 0.0) / _MILLISECONDS_TO_SECONDS, attrs)
if transforms_applied:
for transform in transforms_applied:
self._compression_transforms.add(
1, self._attrs(model=model, provider=provider, transform=transform)
)
if timing:
for stage, stage_ms in timing.items():
if stage == "pipeline_total" or stage.startswith("_"):
continue
self._compression_stage_duration.record(
max(stage_ms, 0.0) / _MILLISECONDS_TO_SECONDS,
self._attrs(model=model, provider=provider, stage=stage),
)
if waste_signals:
for signal_name, token_count in waste_signals.items():
if token_count > 0:
self._waste_signal_tokens.add(
token_count,
self._attrs(model=model, provider=provider, signal=signal_name),
)
def record_compression_failure(
self,
*,
model: str,
operation: str,
error_type: str,
) -> None:
self._compression_failures.add(
1,
self._attrs(model=model, operation=operation, error_type=error_type),
)
def record_subscription_window(self, state: dict[str, Any]) -> None:
"""Update OTEL subscription gauge backing values from the tracker state dict."""
latest = state.get("latest") or {}
five_hour = latest.get("five_hour") or {}
if five_hour:
self._sub_5h_util_val = float(five_hour.get("utilization_pct", 0.0))
self._sub_5h_reset_val = float(five_hour.get("seconds_to_reset") or 0.0)
seven_day = latest.get("seven_day") or {}
if seven_day:
self._sub_7d_util_val = float(seven_day.get("utilization_pct", 0.0))
self._sub_7d_reset_val = float(seven_day.get("seconds_to_reset") or 0.0)
extra = latest.get("extra_usage") or {}
if extra.get("is_enabled"):
self._sub_overage_val = float(extra.get("used_credits_usd") or 0.0)
def get_otel_metrics() -> HeadroomOtelMetrics:
global _global_metrics
if _global_metrics is None:
with _metrics_lock:
if _global_metrics is None:
_global_metrics = HeadroomOtelMetrics()
return _global_metrics
def get_otel_meter(name: str, version: str | None = None) -> Any:
"""Return a meter backed by Headroom's configured OTEL metric provider.
This is intended for optional integrations that need to create their own
instruments while sharing Headroom's configured exporter and lifecycle.
When OTEL metrics are disabled, the returned meter is the standard no-op
compatible meter from the OpenTelemetry API.
"""
return get_otel_metrics().get_meter(name, version)
def set_otel_metrics(otel_metrics: HeadroomOtelMetrics) -> HeadroomOtelMetrics:
global _global_metrics
with _metrics_lock:
_global_metrics = otel_metrics
return otel_metrics
def configure_otel_metrics(config: OTelMetricsConfig | None = None) -> HeadroomOtelMetrics:
global _global_metrics
global _owned_meter_provider
global _owned_metrics_config
resolved = config or OTelMetricsConfig()
if not resolved.enabled:
return get_otel_metrics()
try:
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import (
ConsoleMetricExporter,
PeriodicExportingMetricReader,
)
from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_VERSION, Resource
except ImportError:
logger.warning(
"OpenTelemetry SDK/exporter packages are not installed. "
"Install headroom-ai[otel] to enable managed OTEL metric export."
)
return get_otel_metrics()
exporter: Any
if resolved.exporter == "console":
exporter = ConsoleMetricExporter()
else:
exporter_kwargs: dict[str, Any] = {}
if resolved.endpoint is not None:
exporter_kwargs["endpoint"] = resolved.endpoint
if resolved.headers:
exporter_kwargs["headers"] = resolved.headers
exporter = OTLPMetricExporter(**exporter_kwargs)
reader = PeriodicExportingMetricReader(
exporter,
export_interval_millis=resolved.export_interval_millis,
)
resource = Resource.create(
{
SERVICE_NAME: resolved.service_name,
SERVICE_VERSION: _headroom_version(),
**resolved.resource_attributes,
}
)
meter_provider = MeterProvider(resource=resource, metric_readers=[reader])
otel_metrics = HeadroomOtelMetrics(meter_provider=meter_provider)
previous_provider = None
with _metrics_lock:
previous_provider = _owned_meter_provider
_owned_meter_provider = meter_provider
_owned_metrics_config = resolved
_global_metrics = otel_metrics
if previous_provider is not None:
try:
previous_provider.shutdown()
except Exception:
logger.debug("Failed to shut down previous OTEL metrics provider", exc_info=True)
return otel_metrics
def get_otel_metrics_status() -> dict[str, Any]:
with _metrics_lock:
if _owned_metrics_config is not None:
return _owned_metrics_config.status()
return OTelMetricsConfig.from_env(default_service_name="headroom-proxy").status()
def shutdown_otel_metrics() -> None:
global _global_metrics
global _owned_meter_provider
global _owned_metrics_config
provider = None
with _metrics_lock:
provider = _owned_meter_provider
_owned_meter_provider = None
_owned_metrics_config = None
_global_metrics = None
if provider is not None:
try:
provider.shutdown()
except Exception:
logger.debug("Failed to shut down OTEL metrics provider", exc_info=True)
def reset_otel_metrics() -> None:
shutdown_otel_metrics()