chopratejas commited on
Commit
09dcef6
·
1 Parent(s): 6556b99

Add telemetry beacon, fix Starlette 0.41+ crash, bump to 0.5.4

Browse files

- Add anonymous telemetry beacon: proxy sends aggregate-only stats
(tokens saved, compression ratios, cache hit rates) to Supabase
every 5 minutes. No prompts, no content, no PII. Opt out with
--no-telemetry or HEADROOM_TELEMETRY=off.

- Fix Starlette 0.41+ TypeError: migrate from deprecated on_event
decorators to lifespan context manager. Fixes crash with
"Router.__init__() got an unexpected keyword argument 'on_startup'".

- Bump version to 0.5.4.

headroom/__init__.py CHANGED
@@ -153,7 +153,7 @@ from .transforms import (
153
  TransformPipeline,
154
  )
155
 
156
- __version__ = "0.5.2"
157
 
158
  __all__ = [
159
  # Main client
 
153
  TransformPipeline,
154
  )
155
 
156
+ __version__ = "0.5.4"
157
 
158
  __all__ = [
159
  # Main client
headroom/cli/proxy.py CHANGED
@@ -133,6 +133,11 @@ from .main import main
133
  default=None,
134
  help="AWS profile name for Bedrock (default: use default credentials)",
135
  )
 
 
 
 
 
136
  @click.pass_context
137
  def proxy(
138
  ctx: click.Context,
@@ -166,6 +171,7 @@ def proxy(
166
  region: str,
167
  bedrock_region: str | None,
168
  bedrock_profile: str | None,
 
169
  ) -> None:
170
  """Start the optimization proxy server.
171
 
@@ -199,7 +205,11 @@ def proxy(
199
  effective_anyllm_provider = os.environ.get("HEADROOM_ANYLLM_PROVIDER") or anyllm_provider
200
 
201
  # Resolve mode: CLI flag > env var > default
202
- effective_mode = mode or os.environ.get("HEADROOM_MODE", "token_headroom")
 
 
 
 
203
 
204
  # License key for managed/enterprise deployments (optional)
205
  license_key = os.environ.get("HEADROOM_LICENSE_KEY")
 
133
  default=None,
134
  help="AWS profile name for Bedrock (default: use default credentials)",
135
  )
136
+ @click.option(
137
+ "--no-telemetry",
138
+ is_flag=True,
139
+ help="Disable anonymous usage telemetry (env: HEADROOM_TELEMETRY=off)",
140
+ )
141
  @click.pass_context
142
  def proxy(
143
  ctx: click.Context,
 
171
  region: str,
172
  bedrock_region: str | None,
173
  bedrock_profile: str | None,
174
+ no_telemetry: bool,
175
  ) -> None:
176
  """Start the optimization proxy server.
177
 
 
205
  effective_anyllm_provider = os.environ.get("HEADROOM_ANYLLM_PROVIDER") or anyllm_provider
206
 
207
  # Resolve mode: CLI flag > env var > default
208
+ effective_mode: str = mode or os.environ.get("HEADROOM_MODE") or "token_headroom"
209
+
210
+ # Telemetry opt-out: --no-telemetry flag sets the env var
211
+ if no_telemetry:
212
+ os.environ["HEADROOM_TELEMETRY"] = "off"
213
 
214
  # License key for managed/enterprise deployments (optional)
215
  license_key = os.environ.get("HEADROOM_LICENSE_KEY")
headroom/proxy/server.py CHANGED
@@ -6867,45 +6867,58 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
6867
  if not FASTAPI_AVAILABLE:
6868
  raise ImportError("FastAPI required. Install: pip install fastapi uvicorn httpx")
6869
 
 
 
6870
  config = config or ProxyConfig()
6871
 
6872
- app = FastAPI(
6873
- title="Headroom Proxy",
6874
- description="Production-ready LLM optimization proxy",
6875
- version=__version__,
6876
- )
6877
 
6878
- # CORS
6879
- app.add_middleware(
6880
- CORSMiddleware,
6881
- allow_origins=["*"],
6882
- allow_credentials=True,
6883
- allow_methods=["*"],
6884
- allow_headers=["*"],
6885
- )
6886
 
6887
- proxy = HeadroomProxy(config)
 
 
 
 
6888
 
6889
- @app.on_event("startup")
6890
- async def startup():
 
6891
  await proxy.startup()
6892
- # Start background task for periodic TOIN stats logging
6893
  asyncio.create_task(_log_toin_stats_periodically())
6894
- # Start usage reporter (license validation + phone-home)
6895
  if proxy.usage_reporter:
6896
  await proxy.usage_reporter.start(proxy)
6897
- # Start traffic learner background save worker
6898
  if proxy.traffic_learner:
6899
  await proxy.traffic_learner.start()
 
6900
 
6901
- @app.on_event("shutdown")
6902
- async def shutdown():
 
 
6903
  if proxy.usage_reporter:
6904
  await proxy.usage_reporter.stop()
6905
  if proxy.traffic_learner:
6906
  await proxy.traffic_learner.stop()
6907
  await proxy.shutdown()
6908
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6909
  # Health & Metrics
6910
  @app.get("/health")
6911
  async def health():
 
6867
  if not FASTAPI_AVAILABLE:
6868
  raise ImportError("FastAPI required. Install: pip install fastapi uvicorn httpx")
6869
 
6870
+ from contextlib import asynccontextmanager
6871
+
6872
  config = config or ProxyConfig()
6873
 
6874
+ proxy = HeadroomProxy(config)
 
 
 
 
6875
 
6876
+ # Telemetry beacon (anonymous aggregate stats)
6877
+ from headroom.telemetry.beacon import TelemetryBeacon
 
 
 
 
 
 
6878
 
6879
+ _beacon = TelemetryBeacon(
6880
+ port=config.port if hasattr(config, "port") else 8787,
6881
+ sdk="proxy",
6882
+ backend=config.backend if hasattr(config, "backend") else "anthropic",
6883
+ )
6884
 
6885
+ @asynccontextmanager
6886
+ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
6887
+ # Startup
6888
  await proxy.startup()
 
6889
  asyncio.create_task(_log_toin_stats_periodically())
 
6890
  if proxy.usage_reporter:
6891
  await proxy.usage_reporter.start(proxy)
 
6892
  if proxy.traffic_learner:
6893
  await proxy.traffic_learner.start()
6894
+ await _beacon.start()
6895
 
6896
+ yield
6897
+
6898
+ # Shutdown
6899
+ await _beacon.stop()
6900
  if proxy.usage_reporter:
6901
  await proxy.usage_reporter.stop()
6902
  if proxy.traffic_learner:
6903
  await proxy.traffic_learner.stop()
6904
  await proxy.shutdown()
6905
 
6906
+ app = FastAPI(
6907
+ title="Headroom Proxy",
6908
+ description="Production-ready LLM optimization proxy",
6909
+ version=__version__,
6910
+ lifespan=lifespan,
6911
+ )
6912
+
6913
+ # CORS
6914
+ app.add_middleware(
6915
+ CORSMiddleware,
6916
+ allow_origins=["*"],
6917
+ allow_credentials=True,
6918
+ allow_methods=["*"],
6919
+ allow_headers=["*"],
6920
+ )
6921
+
6922
  # Health & Metrics
6923
  @app.get("/health")
6924
  async def health():
headroom/telemetry/beacon.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Anonymous usage telemetry beacon for Headroom.
2
+
3
+ Sends aggregate-only stats (tokens saved, compression ratios, cache hit rates)
4
+ to help improve Headroom. No prompts, no content, no PII.
5
+
6
+ On by default. Opt out with:
7
+ HEADROOM_TELEMETRY=off headroom proxy
8
+ headroom proxy --no-telemetry
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import os
16
+ import platform
17
+ import sys
18
+ import time
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Supabase endpoint (write-only: anon key can INSERT, not read/update/delete)
23
+ _SUPABASE_URL = "https://dtlllcsudcoasebbamcq.supabase.co"
24
+ _SUPABASE_KEY = "sb_publishable_kHcSIX2Ip0_m0C3WuwZlaQ_33my7qya"
25
+ _TABLE = "proxy_telemetry"
26
+ _ENDPOINT = f"{_SUPABASE_URL}/rest/v1/{_TABLE}"
27
+
28
+ # Report every 5 minutes
29
+ _INTERVAL_SECONDS = 300
30
+
31
+
32
+ def is_telemetry_enabled() -> bool:
33
+ """Check if telemetry is enabled (on by default, opt out with env var)."""
34
+ val = os.environ.get("HEADROOM_TELEMETRY", "on").lower().strip()
35
+ return val not in ("off", "false", "0", "no", "disable", "disabled")
36
+
37
+
38
+ class TelemetryBeacon:
39
+ """Periodically sends anonymous aggregate stats to Supabase."""
40
+
41
+ def __init__(self, port: int = 8787, sdk: str = "proxy", backend: str = "anthropic") -> None:
42
+ self._port = port
43
+ self._sdk = sdk
44
+ self._backend = backend
45
+ self._task: asyncio.Task[None] | None = None
46
+ self._start_time = time.time()
47
+
48
+ async def start(self) -> None:
49
+ """Start the periodic beacon. Call from proxy startup."""
50
+ if not is_telemetry_enabled():
51
+ logger.debug("Telemetry disabled (HEADROOM_TELEMETRY=off)")
52
+ return
53
+ self._task = asyncio.create_task(self._loop())
54
+ logger.info(
55
+ "Telemetry: ENABLED (anonymous aggregate stats, opt out: HEADROOM_TELEMETRY=off)"
56
+ )
57
+
58
+ async def stop(self) -> None:
59
+ """Stop and send one final report. Call from proxy shutdown."""
60
+ if self._task:
61
+ self._task.cancel()
62
+ self._task = None
63
+ # Final report
64
+ if is_telemetry_enabled():
65
+ await self._report()
66
+
67
+ async def _loop(self) -> None:
68
+ """Background loop: wait, report, repeat."""
69
+ # Wait 60 seconds before first report
70
+ await asyncio.sleep(60)
71
+ while True:
72
+ try:
73
+ await self._report()
74
+ except Exception:
75
+ pass # Never crash the proxy for telemetry
76
+ await asyncio.sleep(_INTERVAL_SECONDS)
77
+
78
+ async def _report(self) -> None:
79
+ """Fetch stats from local /stats endpoint and POST to Supabase."""
80
+ try:
81
+ import httpx
82
+ except ImportError:
83
+ return
84
+
85
+ # Fetch stats from our own proxy
86
+ try:
87
+ async with httpx.AsyncClient(timeout=5.0) as client:
88
+ resp = await client.get(f"http://127.0.0.1:{self._port}/stats")
89
+ if resp.status_code != 200:
90
+ return
91
+ stats = resp.json()
92
+ except Exception:
93
+ return
94
+
95
+ tokens = stats.get("tokens", {})
96
+ requests = stats.get("requests", {})
97
+ cache = stats.get("prefix_cache", {}).get("totals", {})
98
+ cost = stats.get("cost", {})
99
+ models_by = requests.get("by_model", {})
100
+ models = [m for m in models_by.keys() if not m.startswith("passthrough:")]
101
+
102
+ session_minutes = max(1, int((time.time() - self._start_time) / 60))
103
+
104
+ try:
105
+ from headroom import __version__ as headroom_version
106
+ except Exception:
107
+ headroom_version = "unknown"
108
+
109
+ payload = {
110
+ "headroom_version": headroom_version,
111
+ "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
112
+ "os": f"{platform.system()} {platform.machine()}",
113
+ "sdk": self._sdk,
114
+ "backend": self._backend,
115
+ "tokens_saved": tokens.get("saved", 0),
116
+ "requests": requests.get("total", 0),
117
+ "compression_percent": tokens.get("savings_percent", 0),
118
+ "cache_hit_rate": cache.get("hit_rate", 0),
119
+ "cost_saved_usd": cost.get("savings_usd", 0),
120
+ "cache_saved_usd": cache.get("cache_savings_usd", 0),
121
+ "session_minutes": session_minutes,
122
+ "models_used": models,
123
+ }
124
+
125
+ try:
126
+ async with httpx.AsyncClient(timeout=10.0) as client:
127
+ await client.post(
128
+ _ENDPOINT,
129
+ json=payload,
130
+ headers={
131
+ "apikey": _SUPABASE_KEY,
132
+ "Authorization": f"Bearer {_SUPABASE_KEY}",
133
+ "Content-Type": "application/json",
134
+ "Prefer": "return=minimal",
135
+ },
136
+ )
137
+ except Exception:
138
+ pass # Fire and forget
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
 
5
  [project]
6
  name = "headroom-ai"
7
- version = "0.5.3"
8
  description = "The Context Optimization Layer for LLM Applications - Cut costs by 50-90%"
9
  readme = "README.md"
10
  license = "Apache-2.0"
 
4
 
5
  [project]
6
  name = "headroom-ai"
7
+ version = "0.5.4"
8
  description = "The Context Optimization Layer for LLM Applications - Cut costs by 50-90%"
9
  readme = "README.md"
10
  license = "Apache-2.0"