Files
hermes-agent/tests/hermes_cli/test_shared_metrics_sender_e2e.py
Ben Barclay a69a9c351d feat(telemetry): transmit the stable install_id as-is
Product-owner decision, 2026-08-27: the analytical need is stable
cross-window identity (retention curves, longitudinal install
behaviour), which the rotating pseudonym destroyed by design. The
feature has not shipped - zero consented users, zero production
transmissions - so identity semantics can change without breaking any
promise made to a user; existing (dev-only) consent windows carry
forward unchanged.

Removed in full rather than weakened in place:
- shared_metrics_identity.py (salt generation/rotation, HMAC-SHA256
  derivation, payload substitution) and its 19-test file.
- The sender's derivation step. _freeze_identity keeps its validation
  role (unreadable/non-object/id-less payloads still reject rather than
  block the queue) and now records the raw install_id in
  sent_install_id; _body rewrites the payload's install_id from that
  frozen column, keeping byte-identical resends anchored to one
  recorded value.

Consent surface updated in the same change: the setup wizard now states
plainly that packages carry the stable profile-scoped install ID (a
random UUID, no personal information, reset by deleting the
shared-metrics directory). No consent was ever collected under the old
wording in any shipped build.

Docs A.2/A.3 rewritten as decision records rather than silently
edited: A.2 records what is transmitted now and states the
consequences plainly (indefinite cross-package correlation is the
designed behaviour); A.3 records why rotation existed and why its
removal was accepted. The main-body "must not reuse the persistent
local identifier by default" escape hatch is exercised, not deleted:
that paragraph required exactly this product decision, which has now
been made. A.6's deletion note updated: install_id is now itself the
lookup key, so a future delete-on-request needs only a service-side
API, not a mapping.

Tests: the two privacy assertions invert deliberately
(test_the_stable_install_id_is_transmitted_as_is and the e2e wire
variant); freezing/byte-identical-retry coverage unchanged. Staging
E2E script now asserts transmitted == install_id.

258 targeted tests pass; ruff + footguns clean; both staging E2E
harnesses green with the raw id observed on the wire (202s).
2026-08-28 15:22:16 +10:00

283 lines
10 KiB
Python

"""End-to-end test: the real sender against a real HTTP server.
Everything else stubs the transport. This exercises the actual code path —
urllib, gzip, headers, socket — against a live server on loopback, so a
transport-level mistake that a fake would hide fails here instead.
"""
from __future__ import annotations
import gzip
import json
import sqlite3
import threading
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from hermes_cli.observability.shared_metrics import SharedMetricsStore
from hermes_cli.observability.shared_metrics_sender import SharedMetricsSender
INSTALL_ID = "12a73e97-4de9-4766-830d-9ca1192c0420"
NOW = datetime(2026, 8, 26, 12, 0, tzinfo=timezone.utc)
class Ingest(BaseHTTPRequestHandler):
"""A stand-in for the ingest service that records what it receives."""
received: list = []
script: list = []
def do_POST(self): # noqa: N802 - stdlib naming
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length)
if self.headers.get("Content-Encoding") == "gzip":
body = gzip.decompress(raw)
else:
body = raw
type(self).received.append(
{
"headers": {k.lower(): v for k, v in self.headers.items()},
"body": json.loads(body.decode("utf-8")),
# Keep the RAW request bytes: comparing only the parsed body
# would not notice a non-deterministic transport encoding.
"raw": raw,
"raw_len": len(raw),
"decoded_len": len(body),
}
)
status, payload, extra = (
type(self).script.pop(0) if type(self).script else (202, {}, {})
)
encoded = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
for key, value in extra.items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(encoded)
def log_message(self, format, *args): # noqa: A002 - stdlib signature
pass
@pytest.fixture
def server():
Ingest.received = []
Ingest.script = []
httpd = HTTPServer(("127.0.0.1", 0), Ingest)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
yield httpd
httpd.shutdown()
httpd.server_close()
@pytest.fixture
def store(tmp_path):
built = SharedMetricsStore(
database_path=tmp_path / "metrics.sqlite3",
outbox_directory=tmp_path / "outbox",
)
# Open a consent window covering the fixture packages; the interval gate
# fails closed without one, and this file tests transport, not consent.
from datetime import datetime, timezone
from hermes_cli.observability.shared_metrics_sender import (
reconcile_send_consent,
)
from hermes_cli.sqlite_util import write_txn
with built._connection() as connection:
with write_txn(connection):
reconcile_send_consent(
connection, True, now=datetime(2026, 8, 20, tzinfo=timezone.utc)
)
reconcile_send_consent(
connection, True, now=datetime(2026, 10, 1, tzinfo=timezone.utc)
)
return built
def _endpoint(server):
host, port = server.server_address
return f"http://{host}:{port}/v1/telemetry"
def _add(store, package_id, day="2026-08-26", metrics=1):
payload = {
"schema_version": "hermes.shared_metrics.v2",
"package_id": package_id,
"install_id": INSTALL_ID,
"generated_at": f"{day}T01:00:00Z",
"period_start": f"{day}T00:00:00Z",
"period_end": f"{day}T23:59:59Z",
"resource": {
"hermes_version": "0.20.5",
"os_family": "macos",
"architecture": "arm64",
"install_method": "git",
},
"metrics": [
{
"name": f"hermes.metric.{i}",
"type": "counter",
"dimensions": {"outcome": "ok"},
"value": i,
}
for i in range(metrics)
],
}
with store._connection() as connection:
connection.execute(
"""
INSERT INTO package_outbox(
package_id, period_start, period_end, payload_json,
created_at, exported_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(
package_id,
f"{day}T00:00:00Z",
f"{day}T23:59:59Z",
json.dumps(payload),
f"{day}T01:00:00Z",
f"{day}T01:00:01Z",
),
)
return payload
def _sender(store, server):
return SharedMetricsSender(
store, _endpoint(server), sleep=lambda _s: None, now=lambda: NOW
)
class TestRealTransport:
def test_a_package_is_delivered_and_marked_sent(self, store, server):
_add(store, "pkg-1")
outcome = _sender(store, server).send_pending()
assert outcome.sent == 1
assert len(Ingest.received) == 1
assert Ingest.received[0]["body"]["package_id"] == "pkg-1"
with store._connection() as connection:
state = connection.execute(
"SELECT send_state FROM package_outbox WHERE package_id = 'pkg-1'"
).fetchone()[0]
assert state == "sent"
def test_the_stable_install_id_crosses_the_wire_as_is(self, store, server):
"""Product decision 2026-08-27: the raw install_id is transmitted."""
_add(store, "pkg-1", metrics=40)
_sender(store, server).send_pending()
assert Ingest.received[0]["body"]["install_id"] == INSTALL_ID
def test_content_type_is_json(self, store, server):
_add(store, "pkg-1")
_sender(store, server).send_pending()
assert Ingest.received[0]["headers"]["content-type"] == "application/json"
def test_a_realistic_package_is_gzipped_over_the_wire(self, store, server):
# ~40 metrics matches the real outbox's larger packages.
_add(store, "pkg-1", metrics=120)
_sender(store, server).send_pending()
record = Ingest.received[0]
assert record["headers"].get("content-encoding") == "gzip"
assert record["raw_len"] < record["decoded_len"]
def test_the_server_can_parse_what_we_send(self, store, server):
"""Proves the bytes are valid JSON after transport and decompression."""
original = _add(store, "pkg-1", metrics=120)
_sender(store, server).send_pending()
received = Ingest.received[0]["body"]
assert received["metrics"] == original["metrics"]
assert received["resource"] == original["resource"]
def test_400_is_permanent(self, store, server):
_add(store, "pkg-1")
Ingest.script = [(400, {"error": "invalid_envelope"}, {})]
outcome = _sender(store, server).send_pending()
assert outcome.rejected == 1
assert len(Ingest.received) == 1
def test_429_is_honoured(self, store, server):
_add(store, "pkg-1")
Ingest.script = [(429, {"error": "rate_limited"}, {"Retry-After": "90"})]
outcome = _sender(store, server).send_pending()
assert outcome.deferred == 1
with store._connection() as connection:
retry_at = connection.execute(
"SELECT next_attempt_at FROM package_outbox WHERE package_id = 'pkg-1'"
).fetchone()[0]
assert retry_at == "2026-08-26T12:01:30Z"
def test_5xx_retries_then_succeeds(self, store, server):
_add(store, "pkg-1")
Ingest.script = [
(503, {"error": "storage_unavailable"}, {}),
(202, {"package_id": "pkg-1"}, {}),
]
outcome = _sender(store, server).send_pending()
assert outcome.sent == 1
assert len(Ingest.received) == 2
def test_a_retry_sends_identical_bytes(self, store, server):
_add(store, "pkg-1", metrics=5)
Ingest.script = [(503, {}, {}), (202, {}, {})]
_sender(store, server).send_pending()
first, second = Ingest.received
assert first["body"] == second["body"]
assert first["raw"] == second["raw"], (
"the raw request bytes must match, not just the parsed body"
)
def test_a_gzipped_retry_is_byte_identical_on_the_wire(self, store, server):
"""gzip embeds an mtime by default, which would break this."""
_add(store, "pkg-1", metrics=200)
Ingest.script = [(503, {}, {}), (202, {}, {})]
_sender(store, server).send_pending()
first, second = Ingest.received
assert first["headers"].get("content-encoding") == "gzip"
assert first["raw"] == second["raw"]
def test_several_packages_in_one_pass(self, store, server):
for i in range(5):
_add(store, f"pkg-{i}")
outcome = _sender(store, server).send_pending()
assert outcome.sent == 5
assert len(Ingest.received) == 5
def test_the_outbox_directory_is_untouched(self, store, server, tmp_path):
_add(store, "pkg-1")
marker = store.outbox_directory / "pkg-1.json"
marker.write_text('{"kept": true}')
_sender(store, server).send_pending()
assert marker.exists()
assert json.loads(marker.read_text()) == {"kept": True}
def test_a_dead_server_defers_without_raising(self, store, server):
_add(store, "pkg-1")
host, port = server.server_address
server.shutdown()
server.server_close()
sender = SharedMetricsSender(
store,
f"http://{host}:{port}/v1/telemetry",
sleep=lambda _s: None,
now=lambda: NOW,
)
outcome = sender.send_pending()
assert outcome.deferred == 1
with store._connection() as connection:
state, error = connection.execute(
"SELECT send_state, last_error FROM package_outbox"
" WHERE package_id = 'pkg-1'"
).fetchone()
assert state == "pending"
assert error