From de311f7da4e012b1bc889f4fcab73a4e01f9262f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 04:05:49 +0000 Subject: [PATCH] feat: add anonymous telemetry warning across all user-facing paths - Add is_telemetry_warn_enabled() feature flag (HEADROOM_TELEMETRY_WARN=off to suppress at build/pack time) and format_telemetry_notice() helper to beacon.py - Export new helpers from headroom.telemetry.__init__ - Proxy CLI startup banner now shows Telemetry: ENABLED/DISABLED with opt-out instructions when enabled - Log telemetry status at proxy server startup() so it appears in log stream - wrap CLI _launch_tool() and the bespoke claude wrap command both call _print_telemetry_notice() so users see the notice before the tool launches - /stats endpoint now includes anon_telemetry_shipping boolean flag - Dashboard header shows amber Anon Telemetry indicator chip when anon_telemetry_shipping is true (theme-matching, with tooltip showing opt-out) - 30 new tests covering all paths; all 82 tests pass Agent-Logs-Url: https://github.com/JerrettDavis/headroom/sessions/880a7bb3-3ad9-49f4-a0b6-3ffdde233e48 Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- headroom/cli/proxy.py | 12 + headroom/cli/wrap.py | 16 ++ headroom/dashboard/templates/dashboard.html | 12 + headroom/proxy/server.py | 11 + headroom/telemetry/__init__.py | 9 + headroom/telemetry/beacon.py | 33 ++- tests/test_telemetry_warning.py | 263 ++++++++++++++++++++ 7 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 tests/test_telemetry_warning.py diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index c343fb8e6..1a06bcb27 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -333,6 +333,17 @@ Memory (Multi-Provider): - Database: {config.memory_db_path} """ + from headroom.telemetry.beacon import is_telemetry_enabled + + # Build telemetry section for the startup banner + if is_telemetry_enabled(): + telemetry_line = ( + " Telemetry: ENABLED (anonymous aggregate stats)\n" + " Disable: HEADROOM_TELEMETRY=off or headroom proxy --no-telemetry" + ) + else: + telemetry_line = " Telemetry: DISABLED" + click.echo(f""" ╔═══════════════════════════════════════════════════════════════════════╗ ║ HEADROOM PROXY ║ @@ -348,6 +359,7 @@ Starting proxy server... Rate Limit: {"ENABLED" if config.rate_limit_enabled else "DISABLED"} Memory: {memory_status} License: {license_status} +{telemetry_line} {backend_section} Routing: /v1/messages → {anthropic_url} diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index a0306accf..eb21e5dcd 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -35,6 +35,20 @@ import click from .main import main + +def _print_telemetry_notice() -> None: + """Print a telemetry notice when anonymous telemetry is enabled. + + Respects the HEADROOM_TELEMETRY and HEADROOM_TELEMETRY_WARN feature flags. + Does nothing when telemetry or warnings are disabled. + """ + from headroom.telemetry.beacon import format_telemetry_notice + + notice = format_telemetry_notice(prefix=" ") + if notice: + click.echo(notice) + + # Proxy health check (reused from evals/suite_runner.py pattern) @@ -434,6 +448,7 @@ def _launch_tool( click.echo(f" {var}") if args: click.echo(f" Extra args: {' '.join(args)}") + _print_telemetry_notice() click.echo() result = subprocess.run([binary, *args], env=env) @@ -735,6 +750,7 @@ def claude( click.echo(f" ANTHROPIC_BASE_URL=http://127.0.0.1:{port}") if claude_args: click.echo(f" Extra args: {' '.join(claude_args)}") + _print_telemetry_notice() click.echo() env = os.environ.copy() diff --git a/headroom/dashboard/templates/dashboard.html b/headroom/dashboard/templates/dashboard.html index 68f12319f..05b2d9a19 100644 --- a/headroom/dashboard/templates/dashboard.html +++ b/headroom/dashboard/templates/dashboard.html @@ -51,6 +51,18 @@ Historical +
Status diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index b94336c4e..1767a480d 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -127,6 +127,7 @@ from headroom.proxy.rate_limiter import TokenBucketRateLimiter # noqa: F401 from headroom.proxy.request_logger import RequestLogger # noqa: F401 from headroom.proxy.semantic_cache import SemanticCache # noqa: F401 from headroom.telemetry import get_telemetry_collector +from headroom.telemetry.beacon import is_telemetry_enabled from headroom.telemetry.toin import get_toin from headroom.transforms import ( CacheAligner, @@ -616,6 +617,15 @@ class HeadroomProxy( logger.info("CCR: DISABLED") logger.info(f"Savings history: {self.metrics.savings_tracker.storage_path}") + # Log anonymous telemetry status so operators can see it in the log stream + if is_telemetry_enabled(): + logger.info( + "Anonymous telemetry: ENABLED (aggregate stats only — no prompts or content). " + "Opt out: HEADROOM_TELEMETRY=off or --no-telemetry" + ) + else: + logger.info("Anonymous telemetry: DISABLED") + async def shutdown(self): """Cleanup async resources.""" if self.http_client: @@ -1169,6 +1179,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: "ccr_retrievals": compression_stats.get("total_retrievals", 0), }, "compression_cache": compression_cache_stats, + "anon_telemetry_shipping": is_telemetry_enabled(), "telemetry": { "enabled": telemetry_stats.get("enabled", False), "total_compressions": telemetry_stats.get("total_compressions", 0), diff --git a/headroom/telemetry/__init__.py b/headroom/telemetry/__init__.py index 9abc14c01..b772f7429 100644 --- a/headroom/telemetry/__init__.py +++ b/headroom/telemetry/__init__.py @@ -47,6 +47,11 @@ TOIN (Tool Output Intelligence Network): toin.record_retrieval(sig_hash, retrieval_type, query, query_fields) """ +from .beacon import ( + format_telemetry_notice, + is_telemetry_enabled, + is_telemetry_warn_enabled, +) from .collector import ( TelemetryCollector, TelemetryConfig, @@ -70,6 +75,10 @@ from .toin import ( ) __all__ = [ + # Beacon helpers + "format_telemetry_notice", + "is_telemetry_enabled", + "is_telemetry_warn_enabled", # Collector "TelemetryCollector", "TelemetryConfig", diff --git a/headroom/telemetry/beacon.py b/headroom/telemetry/beacon.py index 33eef3117..bd9923acb 100644 --- a/headroom/telemetry/beacon.py +++ b/headroom/telemetry/beacon.py @@ -39,10 +39,41 @@ _ENDPOINT = f"{_SUPABASE_URL}/rest/v1/{_TABLE}?on_conflict=session_id" _INTERVAL_SECONDS = 300 +_OFF_VALUES = frozenset(("off", "false", "0", "no", "disable", "disabled")) + + def is_telemetry_enabled() -> bool: """Check if telemetry is enabled (on by default, opt out with env var).""" val = os.environ.get("HEADROOM_TELEMETRY", "on").lower().strip() - return val not in ("off", "false", "0", "no", "disable", "disabled") + return val not in _OFF_VALUES + + +def is_telemetry_warn_enabled() -> bool: + """Check if telemetry warnings are enabled (feature flag, on by default). + + Set HEADROOM_TELEMETRY_WARN=off to suppress startup/wrap notices. + This is a build/pack-time feature flag intended for operators who want + to disable the notice without disabling telemetry itself. + """ + val = os.environ.get("HEADROOM_TELEMETRY_WARN", "on").lower().strip() + return val not in _OFF_VALUES + + +def format_telemetry_notice(*, prefix: str = "") -> str: + """Return a single-line telemetry notice suitable for CLI output. + + Args: + prefix: Optional leading whitespace / box-drawing prefix. + + Returns an empty string when telemetry or warnings are disabled so callers + can unconditionally include the result in their output. + """ + if not is_telemetry_enabled() or not is_telemetry_warn_enabled(): + return "" + return ( + f"{prefix}Telemetry: ENABLED (anonymous aggregate stats) | " + "Disable: HEADROOM_TELEMETRY=off or --no-telemetry" + ) class TelemetryBeacon: diff --git a/tests/test_telemetry_warning.py b/tests/test_telemetry_warning.py new file mode 100644 index 000000000..8a8a5238e --- /dev/null +++ b/tests/test_telemetry_warning.py @@ -0,0 +1,263 @@ +"""Tests for anonymous telemetry warning feature. + +Covers: +- is_telemetry_warn_enabled() feature flag +- format_telemetry_notice() helper +- proxy CLI banner includes telemetry status +- wrap CLI prints telemetry notice +- /stats endpoint exposes anon_telemetry_shipping flag +""" + +from unittest.mock import patch + +import pytest + +click = pytest.importorskip("click") +from click.testing import CliRunner # noqa: E402 + +from headroom.telemetry.beacon import ( # noqa: E402 + format_telemetry_notice, + is_telemetry_warn_enabled, +) + +# --------------------------------------------------------------------------- +# is_telemetry_warn_enabled +# --------------------------------------------------------------------------- + + +class TestIsTelemetryWarnEnabled: + """Tests for the HEADROOM_TELEMETRY_WARN feature flag.""" + + def test_enabled_by_default(self, monkeypatch): + monkeypatch.delenv("HEADROOM_TELEMETRY_WARN", raising=False) + assert is_telemetry_warn_enabled() is True + + @pytest.mark.parametrize("value", ["off", "OFF", "false", "0", "no", "disable", "disabled"]) + def test_disabled_by_env_var(self, monkeypatch, value): + monkeypatch.setenv("HEADROOM_TELEMETRY_WARN", value) + assert is_telemetry_warn_enabled() is False + + @pytest.mark.parametrize("value", ["on", "ON", "1", "yes", "true"]) + def test_enabled_by_truthy_env_var(self, monkeypatch, value): + monkeypatch.setenv("HEADROOM_TELEMETRY_WARN", value) + assert is_telemetry_warn_enabled() is True + + +# --------------------------------------------------------------------------- +# format_telemetry_notice +# --------------------------------------------------------------------------- + + +class TestFormatTelemetryNotice: + """Tests for format_telemetry_notice().""" + + def test_returns_notice_when_telemetry_on(self, monkeypatch): + monkeypatch.setenv("HEADROOM_TELEMETRY", "on") + monkeypatch.delenv("HEADROOM_TELEMETRY_WARN", raising=False) + notice = format_telemetry_notice() + assert notice != "" + assert "ENABLED" in notice + assert "HEADROOM_TELEMETRY=off" in notice + assert "--no-telemetry" in notice + + def test_empty_when_telemetry_off(self, monkeypatch): + monkeypatch.setenv("HEADROOM_TELEMETRY", "off") + monkeypatch.delenv("HEADROOM_TELEMETRY_WARN", raising=False) + assert format_telemetry_notice() == "" + + def test_empty_when_warn_flag_off(self, monkeypatch): + monkeypatch.setenv("HEADROOM_TELEMETRY", "on") + monkeypatch.setenv("HEADROOM_TELEMETRY_WARN", "off") + assert format_telemetry_notice() == "" + + def test_prefix_is_applied(self, monkeypatch): + monkeypatch.setenv("HEADROOM_TELEMETRY", "on") + monkeypatch.delenv("HEADROOM_TELEMETRY_WARN", raising=False) + notice = format_telemetry_notice(prefix=" ") + assert notice.startswith(" ") + + def test_no_prefix_by_default(self, monkeypatch): + monkeypatch.setenv("HEADROOM_TELEMETRY", "on") + monkeypatch.delenv("HEADROOM_TELEMETRY_WARN", raising=False) + notice = format_telemetry_notice() + # Default prefix is "" so the string should start with "Telemetry" + assert notice.startswith("Telemetry:") + + +# --------------------------------------------------------------------------- +# proxy CLI banner +# --------------------------------------------------------------------------- + + +class TestProxyCLITelemetryBanner: + """Proxy CLI startup banner must include telemetry status.""" + + @pytest.fixture + def runner(self): + return CliRunner() + + def test_banner_shows_telemetry_enabled(self, runner, monkeypatch): + monkeypatch.delenv("HEADROOM_TELEMETRY", raising=False) + + from headroom.cli.main import main + + with patch("headroom.proxy.server.run_server", side_effect=SystemExit(0)): + result = runner.invoke(main, ["proxy"]) + + assert "Telemetry:" in result.output + assert "ENABLED" in result.output + + def test_banner_shows_telemetry_disabled(self, runner, monkeypatch): + monkeypatch.setenv("HEADROOM_TELEMETRY", "off") + + from headroom.cli.main import main + + with patch("headroom.proxy.server.run_server", side_effect=SystemExit(0)): + result = runner.invoke(main, ["proxy"]) + + assert "Telemetry:" in result.output + assert "DISABLED" in result.output + + def test_no_telemetry_flag_disables(self, runner, monkeypatch): + monkeypatch.delenv("HEADROOM_TELEMETRY", raising=False) + + from headroom.cli.main import main + + with patch("headroom.proxy.server.run_server", side_effect=SystemExit(0)): + result = runner.invoke(main, ["proxy", "--no-telemetry"]) + + assert "Telemetry:" in result.output + assert "DISABLED" in result.output + + def test_banner_shows_opt_out_instructions_when_enabled(self, runner, monkeypatch): + monkeypatch.delenv("HEADROOM_TELEMETRY", raising=False) + + from headroom.cli.main import main + + with patch("headroom.proxy.server.run_server", side_effect=SystemExit(0)): + result = runner.invoke(main, ["proxy"]) + + assert "HEADROOM_TELEMETRY=off" in result.output or "--no-telemetry" in result.output + + +# --------------------------------------------------------------------------- +# wrap CLI telemetry notice +# --------------------------------------------------------------------------- + + +class TestWrapCLITelemetryNotice: + """_print_telemetry_notice() is called from wrap commands.""" + + def test_print_notice_outputs_when_telemetry_on(self, monkeypatch, capsys): + monkeypatch.setenv("HEADROOM_TELEMETRY", "on") + monkeypatch.delenv("HEADROOM_TELEMETRY_WARN", raising=False) + + from headroom.cli.wrap import _print_telemetry_notice + + _print_telemetry_notice() + captured = capsys.readouterr() + assert "Telemetry" in captured.out + assert "HEADROOM_TELEMETRY=off" in captured.out + + def test_print_notice_silent_when_telemetry_off(self, monkeypatch, capsys): + monkeypatch.setenv("HEADROOM_TELEMETRY", "off") + + from headroom.cli.wrap import _print_telemetry_notice + + _print_telemetry_notice() + captured = capsys.readouterr() + assert captured.out == "" + + def test_print_notice_silent_when_warn_flag_off(self, monkeypatch, capsys): + monkeypatch.setenv("HEADROOM_TELEMETRY", "on") + monkeypatch.setenv("HEADROOM_TELEMETRY_WARN", "off") + + from headroom.cli.wrap import _print_telemetry_notice + + _print_telemetry_notice() + captured = capsys.readouterr() + assert captured.out == "" + + +# --------------------------------------------------------------------------- +# /stats endpoint – anon_telemetry_shipping flag +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestStatsEndpointTelemetryFlag: + """The /stats endpoint must expose anon_telemetry_shipping.""" + + pytest.importorskip("fastapi") + + async def test_stats_includes_anon_telemetry_shipping_true(self, monkeypatch): + monkeypatch.delenv("HEADROOM_TELEMETRY", raising=False) + from headroom.proxy.server import ProxyConfig, create_app + + app = create_app( + ProxyConfig( + cache_enabled=False, + rate_limit_enabled=False, + cost_tracking_enabled=False, + ) + ) + + from httpx import ASGITransport, AsyncClient + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.get("/stats") + + assert resp.status_code == 200 + data = resp.json() + assert "anon_telemetry_shipping" in data + assert data["anon_telemetry_shipping"] is True + + async def test_stats_includes_anon_telemetry_shipping_false(self, monkeypatch): + monkeypatch.setenv("HEADROOM_TELEMETRY", "off") + from headroom.proxy.server import ProxyConfig, create_app + + app = create_app( + ProxyConfig( + cache_enabled=False, + rate_limit_enabled=False, + cost_tracking_enabled=False, + ) + ) + + from httpx import ASGITransport, AsyncClient + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.get("/stats") + + assert resp.status_code == 200 + data = resp.json() + assert "anon_telemetry_shipping" in data + assert data["anon_telemetry_shipping"] is False + + +# --------------------------------------------------------------------------- +# telemetry __init__ exports +# --------------------------------------------------------------------------- + + +class TestTelemetryModuleExports: + """New helpers must be exported from headroom.telemetry.""" + + def test_is_telemetry_warn_enabled_exported(self): + from headroom.telemetry import is_telemetry_warn_enabled as fn + + assert callable(fn) + + def test_is_telemetry_enabled_exported(self): + from headroom.telemetry import is_telemetry_enabled as fn + + assert callable(fn) + + def test_format_telemetry_notice_exported(self): + from headroom.telemetry import format_telemetry_notice as fn + + assert callable(fn)