feat: expose proxy OTEL metrics and Langfuse status

Wire the proxy's operational metrics facade into the new observability
layer, expand built-in Prometheus export, surface OTEL and Langfuse status
in /stats, and document the split between anonymous telemetry, OTEL metrics,
and Langfuse traces.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
JerrettDavis 2026-04-09 21:20:46 -05:00
parent ebd482c0a0
commit be6aa14110
9 changed files with 657 additions and 96 deletions

View file

@ -145,28 +145,113 @@ curl http://localhost:8787/metrics
```
```prometheus
# HELP headroom_requests_total Total requests processed
headroom_requests_total{mode="optimize"} 1234
# HELP headroom_requests_total Total number of requests
headroom_requests_total 1234
# HELP headroom_tokens_saved_total Total tokens saved
# HELP headroom_latency_ms_count Count of observed request latencies
headroom_latency_ms_count 1234
# HELP headroom_tokens_saved_total Tokens saved by optimization
headroom_tokens_saved_total 5678900
# HELP headroom_compression_ratio Compression ratio histogram
headroom_compression_ratio_bucket{le="0.5"} 890
headroom_compression_ratio_bucket{le="0.7"} 1100
headroom_compression_ratio_bucket{le="0.9"} 1200
# HELP headroom_requests_by_provider Requests by provider
headroom_requests_by_provider{provider="anthropic"} 800
headroom_requests_by_provider{provider="openai"} 434
# HELP headroom_latency_seconds Request latency histogram
headroom_latency_seconds_bucket{le="0.01"} 800
headroom_latency_seconds_bucket{le="0.1"} 1150
# HELP headroom_transform_timing_ms_sum Sum of transform timing in milliseconds
headroom_transform_timing_ms_sum{transform="router"} 5123.7
# HELP headroom_cache_hits_total Cache hit counter
headroom_cache_hits_total 456
# HELP headroom_cache_misses_total Cache miss counter
headroom_cache_misses_total 778
# HELP headroom_cache_write_ttl_tokens_total Provider cache write tokens by observed TTL bucket
headroom_cache_write_ttl_tokens_total{provider="anthropic",ttl="5m"} 20000
headroom_cache_write_ttl_tokens_total{provider="anthropic",ttl="1h"} 50000
```
The built-in Prometheus endpoint exposes the proxy's in-memory operational state, including:
- request counters
- token totals and savings
- latency / overhead / TTFB summaries
- per-provider and per-model request counts
- per-stage pipeline timing
- waste signal token totals
- provider cache read/write and TTL-bucket counters
- cache bust counters
### OTEL Metrics
Headroom now emits the same operational events through a shared OTEL metrics facade.
There are two integration modes:
1. **Ambient OTEL app setup** - if your application already configures a global OTEL meter provider, Headroom records into that provider automatically.
2. **Headroom-managed export** - if you want the proxy to configure its own OTEL metrics exporter, install:
```bash
pip install "headroom-ai[proxy,otel]"
```
Then set:
```bash
HEADROOM_OTEL_METRICS_ENABLED=1
HEADROOM_OTEL_METRICS_EXPORTER=otlp_http
HEADROOM_OTEL_METRICS_ENDPOINT=http://127.0.0.1:4318/v1/metrics
HEADROOM_OTEL_SERVICE_NAME=headroom-proxy
HEADROOM_OTEL_RESOURCE_ATTRIBUTES=deployment.environment=dev,service.namespace=headroom
```
For local validation without a collector:
```bash
HEADROOM_OTEL_METRICS_ENABLED=1
HEADROOM_OTEL_METRICS_EXPORTER=console
headroom proxy
```
The proxy's `/stats` response now includes an `otel` block that reports whether Headroom is managing an OTEL exporter for the current process.
Headroom's managed OTEL exporters are intentionally scoped to Headroom's own instrumentation. If you already manage global OTEL providers in your app, keep using those and let Headroom record into the ambient providers instead of enabling `HEADROOM_OTEL_*`.
### OTEL Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `HEADROOM_OTEL_METRICS_ENABLED` | `0` | Enables Headroom-managed OTEL metric export |
| `HEADROOM_OTEL_METRICS_EXPORTER` | `otlp_http` | Exporter type: `otlp_http` or `console` |
| `HEADROOM_OTEL_METRICS_ENDPOINT` | unset | OTLP HTTP metrics endpoint |
| `HEADROOM_OTEL_METRICS_HEADERS` | unset | Comma-separated `key=value` headers for OTLP export |
| `HEADROOM_OTEL_METRICS_EXPORT_INTERVAL_MS` | `10000` | Periodic export interval in milliseconds |
| `HEADROOM_OTEL_SERVICE_NAME` | `headroom-proxy` in proxy mode | OTEL `service.name` |
| `HEADROOM_OTEL_RESOURCE_ATTRIBUTES` | unset | Comma-separated resource attributes |
### Anonymous Telemetry vs OTEL
Headroom has two separate systems:
- `HEADROOM_TELEMETRY` / `--no-telemetry` controls the privacy-preserving anonymous data-flywheel beacon and TOIN-related aggregate reporting.
- `HEADROOM_OTEL_*` controls operational OTEL metric export.
They are independent by design so you can disable the anonymous beacon while keeping OTEL metrics enabled, or vice versa.
### Langfuse
Langfuse fits next to this implementation as a **trace backend**, not as a metrics backend.
- Headroom metrics continue to go to `/metrics` and/or your OTEL metrics exporter.
- Langfuse receives OTLP traces for Headroom's compression pipeline.
- Headroom's `/stats` response includes a `langfuse` block when Headroom is managing Langfuse trace export for the process.
Enable it with:
```bash
HEADROOM_LANGFUSE_ENABLED=1
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_BASE_URL=https://cloud.langfuse.com
```
For self-hosted Langfuse, set `LANGFUSE_BASE_URL` to your instance URL.
### Health Check
```bash
@ -293,19 +378,19 @@ Example Grafana dashboard configuration for Prometheus metrics:
"targets": [{"expr": "headroom_tokens_saved_total"}]
},
{
"title": "Compression Ratio",
"title": "Average Request Latency (ms)",
"type": "gauge",
"targets": [{"expr": "histogram_quantile(0.5, headroom_compression_ratio_bucket)"}]
"targets": [{"expr": "headroom_latency_ms_sum / clamp_min(headroom_latency_ms_count, 1)"}]
},
{
"title": "Request Latency (p99)",
"title": "Max Request Latency (ms)",
"type": "graph",
"targets": [{"expr": "histogram_quantile(0.99, headroom_latency_seconds_bucket)"}]
"targets": [{"expr": "headroom_latency_ms_max"}]
},
{
"title": "Cache Hit Rate",
"title": "Provider Cache Hit Rate",
"type": "gauge",
"targets": [{"expr": "headroom_cache_hits_total / (headroom_cache_hits_total + headroom_cache_misses_total)"}]
"targets": [{"expr": "headroom_provider_cache_hit_requests_total / clamp_min(headroom_provider_cache_requests_total, 1)"}]
}
]
}

View file

@ -21,7 +21,29 @@ headroom proxy \
--budget 100.0
```
Telemetry is enabled by default. Opt out with `HEADROOM_TELEMETRY=off` or `headroom proxy --no-telemetry`. Downstream apps can set `HEADROOM_SDK=headroom-app` to override the anonymous telemetry `sdk` label; the default remains `proxy`.
Anonymous aggregate telemetry is enabled by default. Opt out with `HEADROOM_TELEMETRY=off` or `headroom proxy --no-telemetry`. Downstream apps can set `HEADROOM_SDK=headroom-app` to override the anonymous telemetry `sdk` label; the default remains `proxy`.
Operational OTEL metrics are configured separately and are **off by default**. Install `headroom-ai[proxy,otel]` and set:
```bash
HEADROOM_OTEL_METRICS_ENABLED=1
HEADROOM_OTEL_METRICS_EXPORTER=otlp_http
HEADROOM_OTEL_METRICS_ENDPOINT=http://127.0.0.1:4318/v1/metrics
HEADROOM_OTEL_SERVICE_NAME=headroom-proxy
```
Use `HEADROOM_OTEL_METRICS_EXPORTER=console` for local smoke testing. `HEADROOM_TELEMETRY` controls the anonymous data-flywheel beacon only; it does not disable or enable OTEL export.
Langfuse can be enabled alongside this OTEL path for **trace ingestion**. Langfuse does **not** ingest OTEL metrics, so Headroom keeps metrics and Langfuse traces as complementary signals:
```bash
HEADROOM_LANGFUSE_ENABLED=1
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_BASE_URL=https://cloud.langfuse.com
```
When configured, Headroom emits OTLP traces for the shared compression pipeline to Langfuse while continuing to expose metrics through `/metrics` and OTEL metric exporters.
## Command Line Options
@ -174,6 +196,8 @@ curl "http://localhost:8787/stats-history?format=csv&series=monthly"
curl http://localhost:8787/metrics
```
`/metrics` remains the built-in Prometheus-formatted operational view. The proxy now also emits the same operational events through the OTEL facade when OTEL metrics are configured.
### LLM APIs
The proxy supports both Anthropic and OpenAI API formats:

View file

@ -389,7 +389,7 @@ class AnthropicHandlerMixin:
rate_key = f"{api_key[:16]}:{client_ip}" if api_key else client_ip
allowed, wait_seconds = await self.rate_limiter.check_request(rate_key)
if not allowed:
await self.metrics.record_rate_limited()
await self.metrics.record_rate_limited(provider="anthropic")
raise HTTPException(
status_code=429,
detail=f"Rate limited. Retry after {wait_seconds:.1f}s",
@ -1443,7 +1443,7 @@ class AnthropicHandlerMixin:
)
except Exception as e:
await self.metrics.record_failed()
await self.metrics.record_failed(provider="anthropic")
# Log full error details internally for debugging
logger.error(f"[{request_id}] Request failed: {type(e).__name__}: {e}")
@ -1721,7 +1721,7 @@ class AnthropicHandlerMixin:
)
except Exception as e:
await self.metrics.record_failed()
await self.metrics.record_failed(provider="anthropic")
logger.error(f"[{request_id}] Batch request failed: {type(e).__name__}: {e}")
return JSONResponse(
status_code=502,

View file

@ -810,7 +810,7 @@ class BatchHandlerMixin:
except Exception as e:
logger.error(f"[{request_id}] Batch creation failed: {type(e).__name__}: {e}")
await self.metrics.record_failed()
await self.metrics.record_failed(provider="batch")
return JSONResponse(
status_code=500,
content={

View file

@ -175,7 +175,7 @@ class GeminiHandlerMixin:
rate_key = headers.get("x-goog-api-key", "default")[:20]
allowed, wait_seconds = await self.rate_limiter.check_request(rate_key)
if not allowed:
await self.metrics.record_rate_limited()
await self.metrics.record_rate_limited(provider="gemini")
raise HTTPException(
status_code=429,
detail=f"Rate limited. Retry after {wait_seconds:.1f}s",
@ -406,7 +406,7 @@ class GeminiHandlerMixin:
headers=response_headers,
)
except Exception as e:
await self.metrics.record_failed()
await self.metrics.record_failed(provider="gemini")
logger.error(f"[{request_id}] Gemini request failed: {type(e).__name__}: {e}")
return JSONResponse(
status_code=502,
@ -650,7 +650,7 @@ class GeminiHandlerMixin:
headers=response_headers,
)
except Exception as e:
await self.metrics.record_failed()
await self.metrics.record_failed(provider="gemini")
logger.error(f"[{request_id}] Gemini countTokens failed: {type(e).__name__}: {e}")
return JSONResponse(
status_code=502,

View file

@ -213,7 +213,7 @@ class OpenAIHandlerMixin:
rate_key = headers.get("authorization", "default")[:20]
allowed, wait_seconds = await self.rate_limiter.check_request(rate_key)
if not allowed:
await self.metrics.record_rate_limited()
await self.metrics.record_rate_limited(provider="openai")
raise HTTPException(
status_code=429,
detail=f"Rate limited. Retry after {wait_seconds:.1f}s",
@ -689,7 +689,7 @@ class OpenAIHandlerMixin:
headers=response_headers,
)
except Exception as e:
await self.metrics.record_failed()
await self.metrics.record_failed(provider="openai")
# Log full error details internally for debugging
logger.error(f"[{request_id}] OpenAI request failed: {type(e).__name__}: {e}")
# Return sanitized error message to client (don't expose internal details)
@ -800,7 +800,7 @@ class OpenAIHandlerMixin:
rate_key = headers.get("authorization", "default")[:20]
allowed, wait_seconds = await self.rate_limiter.check_request(rate_key)
if not allowed:
await self.metrics.record_rate_limited()
await self.metrics.record_rate_limited(provider="openai")
raise HTTPException(
status_code=429,
detail=f"Rate limited. Retry after {wait_seconds:.1f}s",
@ -950,7 +950,7 @@ class OpenAIHandlerMixin:
headers=response_headers,
)
except Exception as e:
await self.metrics.record_failed()
await self.metrics.record_failed(provider="openai")
logger.error(f"[{request_id}] OpenAI responses request failed: {type(e).__name__}: {e}")
return JSONResponse(
status_code=502,

View file

@ -16,13 +16,48 @@ from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from headroom.observability import HeadroomOtelMetrics
from headroom.proxy.cost import CostTracker
from headroom.observability import get_otel_metrics
from headroom.proxy.savings_tracker import SavingsTracker
logger = logging.getLogger("headroom.proxy")
def _escape_label_value(value: str) -> str:
return value.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
def _format_labels(labels: dict[str, str] | None = None) -> str:
if not labels:
return ""
rendered = ",".join(
f'{key}="{_escape_label_value(str(value))}"' for key, value in sorted(labels.items())
)
return f"{{{rendered}}}"
def _append_metric(
lines: list[str],
*,
name: str,
metric_type: str,
help_text: str,
value: int | float,
labels: dict[str, str] | None = None,
) -> None:
lines.extend(
[
f"# HELP {name} {help_text}",
f"# TYPE {name} {metric_type}",
f"{name}{_format_labels(labels)} {value}",
"",
]
)
class PrometheusMetrics:
"""Prometheus-compatible metrics."""
@ -30,6 +65,7 @@ class PrometheusMetrics:
self,
savings_tracker: SavingsTracker | None = None,
cost_tracker: CostTracker | None = None,
otel_metrics: HeadroomOtelMetrics | None = None,
):
self.requests_total = 0
self.requests_by_provider: dict[str, int] = defaultdict(int)
@ -115,6 +151,10 @@ class PrometheusMetrics:
)
self._lock = asyncio.Lock()
self._otel_metrics = otel_metrics
def _get_otel_metrics(self) -> HeadroomOtelMetrics:
return self._otel_metrics or get_otel_metrics()
def _current_savings_tracker_totals(self) -> tuple[int, float]:
total_input_tokens = self._savings_tracker_input_tokens_offset + self.tokens_input_total
@ -260,77 +300,365 @@ class PrometheusMetrics:
total_input_cost_usd=total_input_cost_usd,
)
self._get_otel_metrics().record_proxy_request(
provider=provider,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
tokens_saved=tokens_saved,
latency_ms=latency_ms,
cached=cached,
overhead_ms=overhead_ms,
ttfb_ms=ttfb_ms,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
cache_write_5m_tokens=cache_write_5m_tokens,
cache_write_1h_tokens=cache_write_1h_tokens,
uncached_input_tokens=uncached_input_tokens,
)
async def record_cache_bust(self, tokens_lost: int) -> None:
"""Record tokens that lost their cache discount due to compression."""
async with self._lock:
self.cache_bust_tokens_lost += tokens_lost
self.cache_bust_count += 1
self._get_otel_metrics().record_proxy_cache_bust(tokens_lost=tokens_lost)
async def record_rate_limited(self):
async def record_rate_limited(self, *, provider: str | None = None, model: str | None = None):
async with self._lock:
self.requests_rate_limited += 1
self._get_otel_metrics().record_proxy_rate_limited(provider=provider, model=model)
async def record_failed(self):
async def record_failed(self, *, provider: str | None = None, model: str | None = None):
async with self._lock:
self.requests_failed += 1
self._get_otel_metrics().record_proxy_failed(provider=provider, model=model)
async def export(self) -> str:
"""Export metrics in Prometheus format."""
async with self._lock:
lines = [
"# HELP headroom_requests_total Total number of requests",
"# TYPE headroom_requests_total counter",
f"headroom_requests_total {self.requests_total}",
"",
"# HELP headroom_requests_cached_total Cached request count",
"# TYPE headroom_requests_cached_total counter",
f"headroom_requests_cached_total {self.requests_cached}",
"",
"# HELP headroom_requests_rate_limited_total Rate limited requests",
"# TYPE headroom_requests_rate_limited_total counter",
f"headroom_requests_rate_limited_total {self.requests_rate_limited}",
"",
"# HELP headroom_requests_failed_total Failed requests",
"# TYPE headroom_requests_failed_total counter",
f"headroom_requests_failed_total {self.requests_failed}",
"",
"# HELP headroom_tokens_input_total Total input tokens",
"# TYPE headroom_tokens_input_total counter",
f"headroom_tokens_input_total {self.tokens_input_total}",
"",
"# HELP headroom_tokens_output_total Total output tokens",
"# TYPE headroom_tokens_output_total counter",
f"headroom_tokens_output_total {self.tokens_output_total}",
"",
"# HELP headroom_tokens_saved_total Tokens saved by optimization",
"# TYPE headroom_tokens_saved_total counter",
f"headroom_tokens_saved_total {self.tokens_saved_total}",
"",
"# HELP headroom_latency_ms_sum Sum of request latencies",
"# TYPE headroom_latency_ms_sum counter",
f"headroom_latency_ms_sum {self.latency_sum_ms:.2f}",
]
lines: list[str] = []
_append_metric(
lines,
name="headroom_requests_total",
metric_type="counter",
help_text="Total number of requests",
value=self.requests_total,
)
_append_metric(
lines,
name="headroom_requests_cached_total",
metric_type="counter",
help_text="Cached request count",
value=self.requests_cached,
)
_append_metric(
lines,
name="headroom_requests_rate_limited_total",
metric_type="counter",
help_text="Rate limited requests",
value=self.requests_rate_limited,
)
_append_metric(
lines,
name="headroom_requests_failed_total",
metric_type="counter",
help_text="Failed requests",
value=self.requests_failed,
)
_append_metric(
lines,
name="headroom_tokens_input_total",
metric_type="counter",
help_text="Total input tokens",
value=self.tokens_input_total,
)
_append_metric(
lines,
name="headroom_tokens_output_total",
metric_type="counter",
help_text="Total output tokens",
value=self.tokens_output_total,
)
_append_metric(
lines,
name="headroom_tokens_saved_total",
metric_type="counter",
help_text="Tokens saved by optimization",
value=self.tokens_saved_total,
)
_append_metric(
lines,
name="headroom_latency_ms_sum",
metric_type="counter",
help_text="Sum of request latencies in milliseconds",
value=round(self.latency_sum_ms, 2),
)
_append_metric(
lines,
name="headroom_latency_ms_count",
metric_type="counter",
help_text="Count of observed request latencies",
value=self.latency_count,
)
_append_metric(
lines,
name="headroom_latency_ms_min",
metric_type="gauge",
help_text="Minimum observed request latency in milliseconds",
value=0 if self.latency_count == 0 else round(self.latency_min_ms, 2),
)
_append_metric(
lines,
name="headroom_latency_ms_max",
metric_type="gauge",
help_text="Maximum observed request latency in milliseconds",
value=round(self.latency_max_ms, 2),
)
_append_metric(
lines,
name="headroom_overhead_ms_sum",
metric_type="counter",
help_text="Sum of Headroom processing overhead in milliseconds",
value=round(self.overhead_sum_ms, 2),
)
_append_metric(
lines,
name="headroom_overhead_ms_count",
metric_type="counter",
help_text="Count of observed Headroom overhead samples",
value=self.overhead_count,
)
_append_metric(
lines,
name="headroom_overhead_ms_min",
metric_type="gauge",
help_text="Minimum observed Headroom overhead in milliseconds",
value=0 if self.overhead_count == 0 else round(self.overhead_min_ms, 2),
)
_append_metric(
lines,
name="headroom_overhead_ms_max",
metric_type="gauge",
help_text="Maximum observed Headroom overhead in milliseconds",
value=round(self.overhead_max_ms, 2),
)
_append_metric(
lines,
name="headroom_ttfb_ms_sum",
metric_type="counter",
help_text="Sum of time to first byte in milliseconds",
value=round(self.ttfb_sum_ms, 2),
)
_append_metric(
lines,
name="headroom_ttfb_ms_count",
metric_type="counter",
help_text="Count of observed time-to-first-byte samples",
value=self.ttfb_count,
)
_append_metric(
lines,
name="headroom_ttfb_ms_min",
metric_type="gauge",
help_text="Minimum observed time to first byte in milliseconds",
value=0 if self.ttfb_count == 0 else round(self.ttfb_min_ms, 2),
)
_append_metric(
lines,
name="headroom_ttfb_ms_max",
metric_type="gauge",
help_text="Maximum observed time to first byte in milliseconds",
value=round(self.ttfb_max_ms, 2),
)
_append_metric(
lines,
name="headroom_cache_bust_total",
metric_type="counter",
help_text="Requests that lost provider cache efficiency because of compression",
value=self.cache_bust_count,
)
_append_metric(
lines,
name="headroom_cache_bust_tokens_lost_total",
metric_type="counter",
help_text="Tokens that lost provider cache discount because of compression",
value=self.cache_bust_tokens_lost,
)
# Per-provider metrics
lines.extend(
[
"",
"# HELP headroom_requests_by_provider Requests by provider",
"# TYPE headroom_requests_by_provider counter",
]
)
for provider, count in self.requests_by_provider.items():
lines.append(f'headroom_requests_by_provider{{provider="{provider}"}} {count}')
lines.append("")
# Per-model metrics
lines.extend(
[
"",
"# HELP headroom_requests_by_model Requests by model",
"# TYPE headroom_requests_by_model counter",
]
)
for model, count in self.requests_by_model.items():
lines.append(f'headroom_requests_by_model{{model="{model}"}} {count}')
lines.append("")
if self.transform_timing_sum:
lines.extend(
[
"# HELP headroom_transform_timing_ms_sum Sum of transform timing in milliseconds",
"# TYPE headroom_transform_timing_ms_sum counter",
]
)
for name, total in self.transform_timing_sum.items():
lines.append(
f'headroom_transform_timing_ms_sum{{transform="{_escape_label_value(name)}"}} {round(total, 2)}'
)
lines.extend(
[
"",
"# HELP headroom_transform_timing_ms_count Count of transform timing samples",
"# TYPE headroom_transform_timing_ms_count counter",
]
)
for name, count in self.transform_timing_count.items():
lines.append(
f'headroom_transform_timing_ms_count{{transform="{_escape_label_value(name)}"}} {count}'
)
lines.extend(
[
"",
"# HELP headroom_transform_timing_ms_max Maximum transform timing in milliseconds",
"# TYPE headroom_transform_timing_ms_max gauge",
]
)
for name, max_value in self.transform_timing_max.items():
lines.append(
f'headroom_transform_timing_ms_max{{transform="{_escape_label_value(name)}"}} {round(max_value, 2)}'
)
lines.append("")
if self.waste_signals_total:
lines.extend(
[
"# HELP headroom_waste_signal_tokens_total Tokens attributed to detected waste signals",
"# TYPE headroom_waste_signal_tokens_total counter",
]
)
for signal_name, token_count in self.waste_signals_total.items():
lines.append(
f'headroom_waste_signal_tokens_total{{signal="{_escape_label_value(signal_name)}"}} {token_count}'
)
lines.append("")
if self.cache_by_provider:
lines.extend(
[
"# HELP headroom_cache_read_tokens_total Provider cache read tokens",
"# TYPE headroom_cache_read_tokens_total counter",
]
)
for provider, stats in self.cache_by_provider.items():
lines.append(
f'headroom_cache_read_tokens_total{{provider="{provider}"}} {stats["cache_read_tokens"]}'
)
lines.extend(
[
"",
"# HELP headroom_cache_write_tokens_total Provider cache write tokens",
"# TYPE headroom_cache_write_tokens_total counter",
]
)
for provider, stats in self.cache_by_provider.items():
lines.append(
f'headroom_cache_write_tokens_total{{provider="{provider}"}} {stats["cache_write_tokens"]}'
)
lines.extend(
[
"",
"# HELP headroom_cache_write_ttl_tokens_total Provider cache write tokens by observed TTL bucket",
"# TYPE headroom_cache_write_ttl_tokens_total counter",
]
)
for provider, stats in self.cache_by_provider.items():
lines.append(
f'headroom_cache_write_ttl_tokens_total{{provider="{provider}",ttl="5m"}} {stats["cache_write_5m_tokens"]}'
)
lines.append(
f'headroom_cache_write_ttl_tokens_total{{provider="{provider}",ttl="1h"}} {stats["cache_write_1h_tokens"]}'
)
lines.extend(
[
"",
"# HELP headroom_cache_write_ttl_requests_total Provider cache write requests by observed TTL bucket",
"# TYPE headroom_cache_write_ttl_requests_total counter",
]
)
for provider, stats in self.cache_by_provider.items():
lines.append(
f'headroom_cache_write_ttl_requests_total{{provider="{provider}",ttl="5m"}} {stats["cache_write_5m_requests"]}'
)
lines.append(
f'headroom_cache_write_ttl_requests_total{{provider="{provider}",ttl="1h"}} {stats["cache_write_1h_requests"]}'
)
lines.extend(
[
"",
"# HELP headroom_uncached_input_tokens_total Input tokens not served from provider cache",
"# TYPE headroom_uncached_input_tokens_total counter",
]
)
for provider, stats in self.cache_by_provider.items():
lines.append(
f'headroom_uncached_input_tokens_total{{provider="{provider}"}} {stats["uncached_input_tokens"]}'
)
lines.extend(
[
"",
"# HELP headroom_provider_cache_requests_total Requests with provider cache observations",
"# TYPE headroom_provider_cache_requests_total counter",
]
)
for provider, stats in self.cache_by_provider.items():
lines.append(
f'headroom_provider_cache_requests_total{{provider="{provider}"}} {stats["requests"]}'
)
lines.extend(
[
"",
"# HELP headroom_provider_cache_hit_requests_total Requests with provider cache reads",
"# TYPE headroom_provider_cache_hit_requests_total counter",
]
)
for provider, stats in self.cache_by_provider.items():
lines.append(
f'headroom_provider_cache_hit_requests_total{{provider="{provider}"}} {stats["hit_requests"]}'
)
lines.extend(
[
"",
"# HELP headroom_provider_cache_bust_total Provider-specific cache bust count",
"# TYPE headroom_provider_cache_bust_total counter",
]
)
for provider, stats in self.cache_by_provider.items():
lines.append(
f'headroom_provider_cache_bust_total{{provider="{provider}"}} {stats["bust_count"]}'
)
lines.extend(
[
"",
"# HELP headroom_provider_cache_bust_write_tokens_total Provider cache write tokens attributed to busts",
"# TYPE headroom_provider_cache_bust_write_tokens_total counter",
]
)
for provider, stats in self.cache_by_provider.items():
lines.append(
f'headroom_provider_cache_bust_write_tokens_total{{provider="{provider}"}} {stats["bust_write_tokens"]}'
)
lines.append("")
return "\n".join(lines)

View file

@ -78,6 +78,16 @@ from headroom.config import (
SmartCrusherConfig,
)
from headroom.dashboard import get_dashboard_html
from headroom.observability import (
LangfuseTracingConfig,
OTelMetricsConfig,
configure_langfuse_tracing,
configure_otel_metrics,
get_langfuse_tracing_status,
get_otel_metrics_status,
shutdown_headroom_tracing,
shutdown_otel_metrics,
)
from headroom.providers import AnthropicProvider, OpenAIProvider
# =============================================================================
@ -824,7 +834,6 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
from contextlib import asynccontextmanager
config = config or ProxyConfig()
proxy = HeadroomProxy(config)
# Telemetry beacon (anonymous aggregate stats).
@ -890,32 +899,40 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
# Startup
await proxy.startup()
asyncio.create_task(_log_toin_stats_periodically())
if proxy.usage_reporter:
await proxy.usage_reporter.start(proxy)
if proxy.traffic_learner:
await proxy.traffic_learner.start()
configure_otel_metrics(OTelMetricsConfig.from_env(default_service_name="headroom-proxy"))
configure_langfuse_tracing(
LangfuseTracingConfig.from_env(default_service_name="headroom-proxy")
)
# Only start beacon if we acquire the lock (first worker wins)
_beacon_is_owner[0] = _try_acquire_beacon_lock()
if _beacon_is_owner[0]:
await _beacon.start()
else:
logger.debug("Beacon: skipping (another worker owns the lock)")
try:
# Startup
await proxy.startup()
asyncio.create_task(_log_toin_stats_periodically())
if proxy.usage_reporter:
await proxy.usage_reporter.start(proxy)
if proxy.traffic_learner:
await proxy.traffic_learner.start()
yield
# Only start beacon if we acquire the lock (first worker wins)
_beacon_is_owner[0] = _try_acquire_beacon_lock()
if _beacon_is_owner[0]:
await _beacon.start()
else:
logger.debug("Beacon: skipping (another worker owns the lock)")
# Shutdown
if _beacon_is_owner[0]:
await _beacon.stop()
_release_beacon_lock()
if proxy.usage_reporter:
await proxy.usage_reporter.stop()
if proxy.traffic_learner:
await proxy.traffic_learner.stop()
await proxy.shutdown()
yield
finally:
# Shutdown
if _beacon_is_owner[0]:
await _beacon.stop()
_release_beacon_lock()
if proxy.usage_reporter:
await proxy.usage_reporter.stop()
if proxy.traffic_learner:
await proxy.traffic_learner.stop()
await proxy.shutdown()
shutdown_headroom_tracing()
shutdown_otel_metrics()
app = FastAPI(
title="Headroom Proxy",
@ -1159,6 +1176,8 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
"avg_compression_ratio": round(telemetry_stats.get("avg_compression_ratio", 0), 4),
"avg_token_reduction": round(telemetry_stats.get("avg_token_reduction", 0), 4),
},
"otel": get_otel_metrics_status(),
"langfuse": get_langfuse_tracing_status(),
"feedback_loop": {
"tools_tracked": feedback_stats.get("tools_tracked", 0),
"total_compressions": feedback_stats.get("total_compressions", 0),

View file

@ -6,6 +6,7 @@ import asyncio
import pytest
from headroom.observability import reset_headroom_tracing, reset_otel_metrics
from headroom.proxy.cost import CostTracker, build_prefix_cache_stats
from headroom.proxy.prometheus_metrics import PrometheusMetrics
@ -78,6 +79,40 @@ def test_prefix_cache_stats_include_observed_ttl_mix() -> None:
assert stats["totals"]["observed_ttl_buckets"]["1h"]["tokens"] == 45
def test_prometheus_metrics_export_includes_extended_fields() -> None:
metrics = PrometheusMetrics()
asyncio.run(
metrics.record_request(
provider="anthropic",
model="claude-opus-4-6",
input_tokens=100,
output_tokens=20,
tokens_saved=5,
latency_ms=12.5,
overhead_ms=3.0,
ttfb_ms=9.0,
pipeline_timing={"router": 4.5},
waste_signals={"json_bloat": 7},
cache_read_tokens=40,
cache_write_tokens=60,
cache_write_5m_tokens=10,
cache_write_1h_tokens=50,
uncached_input_tokens=20,
)
)
asyncio.run(metrics.record_cache_bust(11))
exported = asyncio.run(metrics.export())
assert "headroom_latency_ms_count 1" in exported
assert 'headroom_transform_timing_ms_sum{transform="router"} 4.5' in exported
assert 'headroom_waste_signal_tokens_total{signal="json_bloat"} 7' in exported
assert 'headroom_cache_write_ttl_tokens_total{provider="anthropic",ttl="5m"} 10' in exported
assert 'headroom_provider_cache_hit_requests_total{provider="anthropic"} 1' in exported
assert "headroom_cache_bust_tokens_lost_total 11" in exported
def test_streaming_parser_extracts_anthropic_ttl_bucket_usage() -> None:
from headroom.proxy.server import HeadroomProxy, ProxyConfig
@ -145,3 +180,73 @@ def test_stats_endpoint_reports_observed_ttl_buckets() -> None:
assert anthropic["observed_ttl_buckets"]["5m"]["tokens"] == 20
assert anthropic["observed_ttl_buckets"]["1h"]["tokens"] == 50
assert prefix_cache["totals"]["observed_ttl_mix"]["active_buckets"] == ["5m", "1h"]
def test_stats_endpoint_reports_otel_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from headroom.proxy.server import ProxyConfig, create_app
reset_otel_metrics()
monkeypatch.setenv("HEADROOM_OTEL_METRICS_ENABLED", "1")
monkeypatch.setenv("HEADROOM_OTEL_METRICS_EXPORTER", "console")
app = create_app(
ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
log_requests=False,
ccr_inject_tool=False,
ccr_handle_responses=False,
ccr_context_tracking=False,
)
)
with TestClient(app) as client:
response = client.get("/stats")
assert response.status_code == 200
otel = response.json()["otel"]
assert otel["configured"] is True
assert otel["enabled"] is True
assert otel["service_name"] == "headroom-proxy"
assert otel["exporter"] == "console"
def test_stats_endpoint_reports_langfuse_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from headroom.proxy.server import ProxyConfig, create_app
reset_headroom_tracing()
monkeypatch.setenv("HEADROOM_LANGFUSE_ENABLED", "1")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test")
monkeypatch.setenv("LANGFUSE_BASE_URL", "https://cloud.langfuse.com")
app = create_app(
ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
log_requests=False,
ccr_inject_tool=False,
ccr_handle_responses=False,
ccr_context_tracking=False,
)
)
with TestClient(app) as client:
response = client.get("/stats")
assert response.status_code == 200
langfuse = response.json()["langfuse"]
assert langfuse["configured"] is True
assert langfuse["enabled"] is True
assert langfuse["service_name"] == "headroom-proxy"
assert langfuse["endpoint"] == "https://cloud.langfuse.com/api/public/otel/v1/traces"