From b543440aca3aa774810a5ae6aec7bdc33ce936cf Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Wed, 23 Sep 2026 23:02:22 -0600 Subject: [PATCH 1/2] Report usage to trace A command-line run now reports a startup event (tag version) and an ideas-written event after the session's ideas are saved, through the vendored trace-client-python 0.2.0. The program key ships in src/usage_reporting.py and in the settings.json block written on first launch, with a one-line notice. Opt out with usage_reporting.enabled: false in settings.json, TRACE_USAGE_REPORTING=off or DO_NOT_TRACK=1. Direct main() calls (the test suite) report nothing and the CI end-to-end run sets TRACE_USAGE_REPORTING=off. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012tRTngcEmZUaiHKcX2ErSS --- .github/workflows/tests.yml | 8 +- .gitignore | 3 + README.md | 36 ++++- src/collide.py | 10 +- src/trace_client.py | 218 ++++++++++++++++++++++++++ src/usage_reporting.py | 127 +++++++++++++++ tests/test_collide.py | 20 +++ tests/test_trace_client.py | 284 ++++++++++++++++++++++++++++++++++ tests/test_usage_reporting.py | 216 ++++++++++++++++++++++++++ 9 files changed, 917 insertions(+), 5 deletions(-) create mode 100644 src/trace_client.py create mode 100644 src/usage_reporting.py create mode 100644 tests/test_trace_client.py create mode 100644 tests/test_usage_reporting.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 81f799e..f06b9e8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,8 +20,8 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Compile both modules - run: python -m py_compile src/collide.py src/ideaCollisionGenerator.py + - name: Compile every module + run: python -m py_compile src/collide.py src/ideaCollisionGenerator.py src/usage_reporting.py src/trace_client.py - name: Run the test suite run: python -m unittest discover -s tests -v @@ -29,6 +29,10 @@ jobs: # the suite patches every prompt, so the real stdin path documented in # README.md is exercised here instead - name: Run the program end to end + # a CI run is not a person using Collide: without this the run would + # report to the real trace service on every push + env: + TRACE_USAGE_REPORTING: "off" run: | printf 'k%s\n' 1 2 3 4 5 6 7 8 9 10 > fixture.txt printf 'idea %s\n' 1 2 3 4 5 >> fixture.txt diff --git a/.gitignore b/.gitignore index 1029acc..e5b14e7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ __pycache__/ # generated idea files, but keep the committed sample ideas/*.txt !ideas/example.txt + +# written on first launch by src/usage_reporting.py; holds the usage-reporting opt-out +settings.json diff --git a/README.md b/README.md index 0bd03ff..f940dca 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ shown in turn, and the idea entered for it is written to a timestamped file. ## Requirements - Python 3.8 or later -- No third-party dependencies — only `datetime`, `random`, and `os` from the standard library +- No third-party dependencies — only the standard library (the vendored usage-reporting client in `src/trace_client.py` included) ## Usage @@ -79,6 +79,40 @@ commands on every push to `main` and on every pull request, against Python 3.8 and 3.13: `py_compile` over both modules, the test suite, and one full run of the program with a 15-line fixture on stdin. +## Usage reporting + +Usage reporting is on by default: Collide sends its name (`Collide`), its version and the events +`startup` (when the program starts) and `ideas-written` (when a session's ideas have been saved) +to [trace](https://github.com/Stephenson-Software/trace) at `https://trace.danielstephenson.dev`. +Nothing about you, your machine, your IP address, the keywords or the ideas is sent. The report +is made from a background thread, never blocks the program, and is dropped silently if the +service is unreachable. + +The first launch writes a `settings.json` at the repository root and prints a one-line notice. +To turn reporting off, any one of these is enough: + +- `"usage_reporting": {"enabled": false}` in `settings.json`: + + ```json + { + "usage_reporting": { + "enabled": false + } + } + ``` + +- the environment variable `TRACE_USAGE_REPORTING=off` (also `false`, `0`, `no`), which turns off + every program that reports to trace +- the environment variable `DO_NOT_TRACK=1` (see [consoledonottrack.com](https://consoledonottrack.com)) + +The environment variables win over `settings.json`. The `endpoint` and `key` entries in the same +block select where reports go and the key they are sent with. The client is `src/trace_client.py`, +vendored from [trace-client-python](https://github.com/Stephenson-Software/trace-client-python); +the settings handling is in `src/usage_reporting.py`. Only a command-line run reports; the test +suite's direct `main()` calls do not, and the CI run sets `TRACE_USAGE_REPORTING=off`. + +Details: https://github.com/Stephenson-Software/trace#usage-reporting + ## License See [LICENSE](LICENSE). diff --git a/src/collide.py b/src/collide.py index 5e8abe5..a30c29a 100644 --- a/src/collide.py +++ b/src/collide.py @@ -1,11 +1,17 @@ from ideaCollisionGenerator import IdeaCollisionGenerator +from usage_reporting import startUsageReporting -def main(): +def main(reportUsage=False): + # usage reporting is started only from the command line (below), so a + # direct main() call - the test suite's, for one - reports nothing + usage = startUsageReporting() if reportUsage else None generator = IdeaCollisionGenerator() generator.getKeywords() generator.createPairs() generator.promptForIdeas() generator.writeToFile() + if usage is not None: + usage.report("ideas-written") if __name__ == "__main__": - main() \ No newline at end of file + main(reportUsage=True) \ No newline at end of file diff --git a/src/trace_client.py b/src/trace_client.py new file mode 100644 index 0000000..77bb90a --- /dev/null +++ b/src/trace_client.py @@ -0,0 +1,218 @@ +"""trace-client 0.2.0 -- https://github.com/Stephenson-Software/trace-client-python + +One call to report that a program was used. Copy this file into a project as +is, or vendor the package; either way there is nothing else to add. Standard +library only; Python 3.8+. + +MIT licensed. Keep this header when vendoring so the file can be found again. + +Vendored into Collide unmodified apart from this note. +""" +from __future__ import annotations + +import json +import logging +import os +import queue +import threading +import urllib.error +import urllib.request +from typing import Dict, Mapping, Optional + +__version__ = "0.2.0" + +_LOG = logging.getLogger("trace") + +#: Environment variables that turn reporting off for every program using a +#: trace client, checked before the program's own setting. Set +#: ``TRACE_USAGE_REPORTING=off`` (also ``false``, ``0``, ``no``; case does not +#: matter) or ``DO_NOT_TRACK=1`` (also ``true``, ``yes``; see +#: https://consoledonottrack.com). +ENV_TRACE_USAGE_REPORTING = "TRACE_USAGE_REPORTING" +ENV_DO_NOT_TRACK = "DO_NOT_TRACK" +_OFF_VALUES = frozenset(("off", "false", "0", "no")) +_DO_NOT_TRACK_VALUES = frozenset(("1", "true", "yes")) + +#: The values :attr:`TraceClient.disabled_reason` can take. First match wins. +REASON_ENVIRONMENT = "environment" +REASON_CONFIG = "config" +REASON_NO_KEY = "no key" + + +def environment_opts_out(environ: Optional[Mapping[str, str]] = None) -> bool: + """Whether the environment asks for usage reporting to be off, via + ``TRACE_USAGE_REPORTING=off`` or ``DO_NOT_TRACK=1``. Only the listed + values count; anything else (including an empty value) leaves the + program's own setting in charge.""" + env = os.environ if environ is None else environ + if env.get(ENV_TRACE_USAGE_REPORTING, "").strip().lower() in _OFF_VALUES: + return True + if env.get(ENV_DO_NOT_TRACK, "").strip().lower() in _DO_NOT_TRACK_VALUES: + return True + return False + + +class TraceClient: + """Reports usage events to a trace server, and never gets in the way of + the program doing the reporting. + + Three properties hold for every call to :meth:`report`: + + * **It returns immediately.** The HTTP call happens on a single daemon + thread owned by this client. A game loop can report from its main + thread without a frame ever waiting on the network. + * **It never raises.** A server that is down, slow, or rejecting the key + is a dropped report, not an exception in the host program. Failures + are logged at DEBUG on the ``trace`` logger and otherwise not at all. + * **It is bounded.** At most :attr:`QUEUE_CAPACITY` reports wait to be + sent; beyond that, new reports are dropped rather than accumulated. + A trace server that is unreachable for a week costs a few kilobytes, + not the host's memory. + + Reporting is opt-out: ``enabled=False``, or no key, yields a client that + does nothing and costs nothing. So does the environment: the constructor + checks ``TRACE_USAGE_REPORTING=off`` and ``DO_NOT_TRACK=1`` before it + looks at ``enabled``, so a user can switch off every trace-reporting + program at once. :attr:`disabled_reason` says which of those applied + (``"environment"``, ``"config"`` or ``"no key"``; ``None`` when on) so + the program can say so in its notice. Programs that run on other + people's machines should expose that switch in their settings and say + so once, pointing at + https://github.com/Stephenson-Software/trace#usage-reporting. + + :: + + trace = TraceClient("https://trace.example.org", "roam", + key=settings.usage_key, enabled=settings.usage_reporting) + trace.report("startup", tags={"version": __version__}) + ... + trace.close() # on shutdown + """ + + QUEUE_CAPACITY = 256 + TIMEOUT_SECONDS = 5.0 + + def __init__(self, base_url: str, application: str, *, key: Optional[str] = None, + enabled: bool = True) -> None: + if not base_url or not base_url.strip(): + raise ValueError("base_url is required") + if not application or not application.strip(): + raise ValueError("application is required") + self._endpoint = base_url.strip().rstrip("/") + "/api/metrics" + self._application = application.strip() + self._key = (key or "").strip() + self._queue: Optional["queue.Queue[Optional[bytes]]"] = None + self._thread: Optional[threading.Thread] = None + #: Why this client reports nothing: ``"environment"`` (the + #: ``TRACE_USAGE_REPORTING`` / ``DO_NOT_TRACK`` variables), + #: ``"config"`` (``enabled=False``) or ``"no key"``; ``None`` when it + #: reports. Decided once, here, in that order of precedence. + self.disabled_reason: Optional[str] = None + if environment_opts_out(): + self.disabled_reason = REASON_ENVIRONMENT + elif not enabled: + self.disabled_reason = REASON_CONFIG + elif not self._key: + self.disabled_reason = REASON_NO_KEY + if self.disabled_reason is None: + self._queue = queue.Queue(maxsize=self.QUEUE_CAPACITY) + self._thread = threading.Thread(target=self._drain, name="trace-client/" + self._application, + daemon=True) + self._thread.start() + + @classmethod + def disabled(cls) -> "TraceClient": + """A client that reports nothing. Useful as a default before settings are read.""" + return cls("http://disabled.invalid", "disabled", enabled=False) + + @property + def enabled(self) -> bool: + """Whether :meth:`report` will actually send anything. ``False`` after + :meth:`close` too; :attr:`disabled_reason` keeps the reason it was + built off, if it was.""" + return self._queue is not None + + def report(self, name: str, value: Optional[float] = None, + tags: Optional[Mapping[str, str]] = None) -> None: + """Report that ``name`` happened, with an optional numeric value and + optional string tags. Returns immediately; see the class docstring.""" + if self._queue is None or not name or not name.strip(): + return + try: + body = _json(self._application, name, value, tags) + self._queue.put_nowait(body) + except queue.Full: + _LOG.debug("[trace] queue full, dropped %s", name) + except Exception as failure: # noqa: BLE001 - a report must never be the reason a program stops + _LOG.debug("[trace] could not queue %s: %s", name, failure) + + def close(self, timeout: float = TIMEOUT_SECONDS) -> None: + """Stop the sending thread, giving reports already queued up to + ``timeout`` seconds in total to be sent first. A program that reports + and then exits within milliseconds -- a CLI, a short script -- would + otherwise lose its one event to the race between queueing it and the + sender thread picking it up. The bound still holds: an unreachable + server costs at most ``timeout``, never a hang. Safe to call more than + once, and on a disabled client.""" + if self._queue is None or self._thread is None: + return + q, thread = self._queue, self._thread + self._queue = None # report() is a no-op from here on + try: + q.put_nowait(None) # sentinel behind whatever is queued + except queue.Full: + # A full queue means 256 reports are waiting; the sentinel would be + # the 257th. Drop the oldest to make room -- one lost report beats a + # thread that never stops. + try: + q.get_nowait() + q.put_nowait(None) + except (queue.Empty, queue.Full): + pass + thread.join(timeout) + + # -- internals -------------------------------------------------------- + + def _drain(self) -> None: + q = self._queue + assert q is not None + while True: + body = q.get() + if body is None: + return + self._send(body) + + def _send(self, body: bytes) -> None: + request = urllib.request.Request( + self._endpoint, data=body, method="POST", + headers={ + "Content-Type": "application/json; charset=utf-8", + "Authorization": "Bearer " + self._key, + "User-Agent": "trace-client-python/%s (%s)" % (__version__, self._application), + }) + try: + with urllib.request.urlopen(request, timeout=self.TIMEOUT_SECONDS) as response: + status = response.status + response.read() + except urllib.error.HTTPError as answered: + status = answered.code + try: + answered.read() + except Exception: # noqa: BLE001 + pass + except Exception as failure: # noqa: BLE001 - see report() + _LOG.debug("[trace] could not deliver %s: %s", body, failure) + return + if status != 201: + _LOG.debug("[trace] trace server answered %s for %s", status, body) + + +def _json(application: str, name: str, value: Optional[float], tags: Optional[Mapping[str, str]]) -> bytes: + payload: Dict[str, object] = {"application": application, "name": name} + if value is not None and value == value and value not in (float("inf"), float("-inf")): + payload["value"] = value + if tags: + clean = {str(k): str(v) for k, v in tags.items() if k is not None and v is not None} + if clean: + payload["tags"] = clean + return json.dumps(payload, separators=(",", ":")).encode("utf-8") diff --git a/src/usage_reporting.py b/src/usage_reporting.py new file mode 100644 index 0000000..68c37bd --- /dev/null +++ b/src/usage_reporting.py @@ -0,0 +1,127 @@ +"""Reports that Collide was used to the trace service, and nothing else. + +What is sent: the program's name (``Collide``) and version with a ``startup`` +event, and an ideas-written event when a session's ideas are saved. Nothing about you, your machine, the keywords or the ideas. + +Reporting is on by default. The first launch writes a ``usage_reporting`` +block to ``settings.json`` and prints a one-line notice saying so and how to +turn it off; setting ``enabled`` to ``false`` there does. So do the +``TRACE_USAGE_REPORTING=off`` and ``DO_NOT_TRACK=1`` environment variables, +which every trace client honours and which win over the settings file because +the client checks them first. Every call returns immediately and never raises: +the network happens on a daemon thread owned by the vendored client in +``trace_client.py``. Details: https://github.com/Stephenson-Software/trace#usage-reporting +""" +import atexit +import json +import os + +from trace_client import TraceClient, environment_opts_out + +# @author Daniel McCoy Stephenson +# @since September 24th, 2026 + +APPLICATION = "Collide" +SETTINGS_SECTION = "usage_reporting" +DEFAULT_ENDPOINT = "https://trace.danielstephenson.dev" +# The program key Collide ships with. Keys identify a program rather than +# guard anything (trace's ADR 0001), so it is kept here in the open. +DEFAULT_KEY = "U5oB2v6sYlgEC9xayQys8irU0lJR6dfawIv3E4SF_DI" +VERSION = "0.1.0" + +_HERE = os.path.dirname(os.path.abspath(__file__)) +SETTINGS_FILE = os.path.normpath(os.path.join(_HERE, "..", "settings.json")) + +DETAILS_URL = "https://github.com/Stephenson-Software/trace#usage-reporting" + +FIRST_RUN_NOTICE = ( + "Usage reporting is on: Collide sends its name and version at startup and an ideas-written event when a session is saved to " + "https://trace.danielstephenson.dev - nothing about you, your machine, the keywords or the ideas. " + 'Turn it off with "usage_reporting": {"enabled": false} in settings.json, or for every ' + "trace-reporting program with the environment variable TRACE_USAGE_REPORTING=off. " + "Details: " + DETAILS_URL +) + +# Shown on the first launch instead when TRACE_USAGE_REPORTING=off or DO_NOT_TRACK=1 is +# already set: the settings block is still written, but saying reporting is on would mislead. +FIRST_RUN_NOTICE_OFF_BY_ENVIRONMENT = "Usage reporting is off (environment). Details: " + DETAILS_URL + + +def firstRunNotice(): + """The line the first launch prints: FIRST_RUN_NOTICE, unless the environment has opted out.""" + if environment_opts_out(): + return FIRST_RUN_NOTICE_OFF_BY_ENVIRONMENT + return FIRST_RUN_NOTICE + + +def defaultSettings(): + """The usage_reporting block written to the settings file on the first launch.""" + return {"enabled": True, "endpoint": DEFAULT_ENDPOINT, "key": DEFAULT_KEY} + + +def loadSettings(settingsFile=SETTINGS_FILE, log=print): + """Read the usage_reporting block from the settings file, writing the default block + (and printing the one-time notice) when the file does not have one yet. + + Returns the usage_reporting settings, or None if the settings file exists but cannot be + read, in which case nothing is reported and the file is left alone. + """ + settings = {} + if os.path.exists(settingsFile): + try: + with open(settingsFile, "r") as f: + settings = json.load(f) + if not isinstance(settings, dict): + raise ValueError("settings file is not a JSON object") + except (OSError, ValueError) as e: + log("Could not read %s (%s); usage reporting is off until it is fixed." % (settingsFile, e)) + return None + + section = settings.get(SETTINGS_SECTION) + if isinstance(section, dict): + return section + + settings[SETTINGS_SECTION] = defaultSettings() + log(firstRunNotice()) + try: + with open(settingsFile, "w") as f: + json.dump(settings, f, indent=2) + f.write("\n") + except OSError as e: + log("Could not write %s (%s); the notice above will be shown again next time." % (settingsFile, e)) + return settings[SETTINGS_SECTION] + + +def buildClient(section): + """A TraceClient for the given usage_reporting settings; disabled when they are None. + + Always built through the client's constructor otherwise, which puts + TRACE_USAGE_REPORTING / DO_NOT_TRACK ahead of ``enabled`` and records why it is off in + ``disabled_reason``. A missing endpoint or key falls back to the shipped default. + """ + if section is None: + return TraceClient.disabled() + try: + return TraceClient( + str(section.get("endpoint") or DEFAULT_ENDPOINT), + APPLICATION, + key=str(section.get("key") or DEFAULT_KEY), + enabled=bool(section.get("enabled", True)), + ) + except Exception: + return TraceClient.disabled() + + +def startUsageReporting(settingsFile=SETTINGS_FILE, log=print): + """Read the settings, build the client and report the startup event. + + Never raises; the client is closed (sending what is queued, bounded by its timeout) + when the interpreter exits. Returns the client so further events can be reported. + """ + try: + client = buildClient(loadSettings(settingsFile, log)) + except Exception: + return TraceClient.disabled() + client.report("startup", tags={"version": VERSION}) + atexit.register(client.close) + return client diff --git a/tests/test_collide.py b/tests/test_collide.py index e58771d..be70315 100644 --- a/tests/test_collide.py +++ b/tests/test_collide.py @@ -55,6 +55,26 @@ def testMainRunsTheFourSteps(self): lines = f.readlines() self.assertEqual(len(lines), 5) + def testDirectMainCallDoesNotStartUsageReporting(self): + with mock.patch("builtins.input", lambda prompt="": "keyword"): + with contextlib.redirect_stdout(io.StringIO()): + import collide + with mock.patch("collide.startUsageReporting") as start: + collide.main() + + start.assert_not_called() + + def testCommandLineRunReportsIdeasWrittenAfterSaving(self): + with mock.patch("builtins.input", lambda prompt="": "keyword"): + with contextlib.redirect_stdout(io.StringIO()): + import collide + with mock.patch("collide.startUsageReporting") as start: + collide.main(reportUsage=True) + + start.assert_called_once_with() + start.return_value.report.assert_called_once_with("ideas-written") + self.assertEqual(len(os.listdir("ideas")), 1) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_trace_client.py b/tests/test_trace_client.py new file mode 100644 index 0000000..69fc06a --- /dev/null +++ b/tests/test_trace_client.py @@ -0,0 +1,284 @@ +"""Drives the client against a real HTTP server on a loopback port -- the +standard library's own, so the tests have no more dependencies than the +client does.""" +import json +import logging +import os +import threading +import time +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest import mock + +import sys + +# the vendored client is src/trace_client.py +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")) + +from trace_client import TraceClient, environment_opts_out # noqa: E402 + +_ENV_VARS = ("TRACE_USAGE_REPORTING", "DO_NOT_TRACK") + + +class _Capture: + def __init__(self): + self.requests = [] + self.reply_status = 201 + self.arrived = threading.Event() + self.release = threading.Event() + self.release.set() # by default answer at once + + +def _server(capture): + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) + capture.release.wait(10) + capture.requests.append({ + "path": self.path, + "authorization": self.headers.get("Authorization"), + "content_type": self.headers.get("Content-Type"), + "user_agent": self.headers.get("User-Agent"), + "body": body.decode("utf-8"), + }) + self.send_response(capture.reply_status) + self.send_header("Content-Length", "0") + self.end_headers() + capture.arrived.set() + + def log_message(self, *args): # keep test output quiet + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + + +class TraceClientTest(unittest.TestCase): + def setUp(self): + # The machine running the tests may itself have opted out; every + # test starts from a clean environment and sets what it needs. + scrubbed = {k: v for k, v in os.environ.items() if k not in _ENV_VARS} + patcher = mock.patch.dict(os.environ, scrubbed, clear=True) + patcher.start() + self.addCleanup(patcher.stop) + self.capture = _Capture() + self.server = _server(self.capture) + self.base_url = "http://127.0.0.1:%d" % self.server.server_address[1] + self.log = [] + handler = logging.Handler() + handler.emit = lambda record: self.log.append(record) + self.handler = handler + logging.getLogger("trace").addHandler(handler) + logging.getLogger("trace").setLevel(logging.DEBUG) + + def tearDown(self): + self.server.shutdown() + logging.getLogger("trace").removeHandler(self.handler) + + def test_report_posts_the_event_to_the_metrics_endpoint_with_the_key(self): + client = TraceClient(self.base_url + "/", "MyGame", key="k-123") + client.report("startup") + self.assertTrue(self.capture.arrived.wait(5), "the report should reach the server") + request = self.capture.requests[0] + self.assertEqual("/api/metrics", request["path"], "a trailing slash on the base URL must not double up") + self.assertEqual("Bearer k-123", request["authorization"]) + self.assertTrue(request["content_type"].startswith("application/json")) + self.assertEqual({"application": "MyGame", "name": "startup"}, json.loads(request["body"])) + client.close() + + def test_report_carries_value_and_tags_when_given(self): + client = TraceClient(self.base_url, "MyGame", key="k") + client.report("world-load", 2.5, {"seed": "42", "size": 'the "big" one'}) + self.assertTrue(self.capture.arrived.wait(5)) + self.assertEqual({"application": "MyGame", "name": "world-load", "value": 2.5, + "tags": {"seed": "42", "size": 'the "big" one'}}, + json.loads(self.capture.requests[0]["body"])) + client.close() + + def test_report_returns_before_the_server_answers(self): + self.capture.release.clear() # a server that never replies + client = TraceClient(self.base_url, "MyGame", key="k") + before = time.monotonic() + client.report("startup") + elapsed = time.monotonic() - before + self.assertLess(elapsed, 1.0, "report() took %.3fs; it must not wait on the network" % elapsed) + self.capture.release.set() + client.close() + + def test_report_does_not_raise_when_nothing_is_listening(self): + probe = _server(_Capture()) + dead_port = probe.server_address[1] + probe.shutdown() + probe.server_close() + client = TraceClient("http://127.0.0.1:%d" % dead_port, "MyGame", key="k") + client.report("startup") # must not raise + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not any("could not deliver" in r.getMessage() for r in self.log): + time.sleep(0.05) + client.close() + self.assertTrue(any("could not deliver" in r.getMessage() for r in self.log), [r.getMessage() for r in self.log]) + self.assertTrue(all(r.levelno == logging.DEBUG for r in self.log)) + + def test_report_does_not_raise_when_the_server_rejects_the_key(self): + self.capture.reply_status = 401 + client = TraceClient(self.base_url, "MyGame", key="revoked") + client.report("startup") + self.assertTrue(self.capture.arrived.wait(5)) + client.close() + self.assertTrue(any("answered 401" in r.getMessage() for r in self.log), [r.getMessage() for r in self.log]) + + def test_disabled_client_sends_nothing(self): + for client in (TraceClient(self.base_url, "MyGame", key="k", enabled=False), + TraceClient(self.base_url, "MyGame"), + TraceClient(self.base_url, "MyGame", key=" "), + TraceClient.disabled()): + self.assertFalse(client.enabled) + client.report("startup") + client.close() + self.assertFalse(self.capture.arrived.wait(0.3), "nothing should have been sent") + self.assertEqual([], self.capture.requests) + + def test_disabled_reason_is_none_when_the_client_reports(self): + client = TraceClient(self.base_url, "MyGame", key="k") + self.assertTrue(client.enabled) + self.assertIsNone(client.disabled_reason) + client.close() + self.assertFalse(client.enabled, "close() stops reporting") + self.assertIsNone(client.disabled_reason, "but the reason describes how it was built") + + def test_disabled_reason_names_the_config_flag_or_the_missing_key(self): + self.assertEqual("config", TraceClient(self.base_url, "MyGame", key="k", enabled=False).disabled_reason) + self.assertEqual("config", TraceClient.disabled().disabled_reason) + self.assertEqual("no key", TraceClient(self.base_url, "MyGame").disabled_reason) + self.assertEqual("no key", TraceClient(self.base_url, "MyGame", key=" ").disabled_reason) + self.assertEqual("config", TraceClient(self.base_url, "MyGame", enabled=False).disabled_reason, + "the config flag is checked before the key") + + def _assert_environment_disables(self, variable, value): + with mock.patch.dict(os.environ, {variable: value}): + self.assertTrue(environment_opts_out(), "%s=%r should opt out" % (variable, value)) + client = TraceClient(self.base_url, "MyGame", key="k") + self.assertFalse(client.enabled, "%s=%r should disable the client" % (variable, value)) + self.assertEqual("environment", client.disabled_reason) + client.report("startup") + client.close() + self.assertFalse(self.capture.arrived.wait(0.2), "%s=%r: nothing should have been sent" % (variable, value)) + self.assertEqual([], self.capture.requests) + + def test_TRACE_USAGE_REPORTING_off_disables_reporting_for_every_accepted_value(self): + for value in ("off", "false", "0", "no", "OFF", "False", "No", " off "): + self._assert_environment_disables("TRACE_USAGE_REPORTING", value) + + def test_DO_NOT_TRACK_disables_reporting_for_every_accepted_value(self): + for value in ("1", "true", "yes", "TRUE", "Yes", " 1 "): + self._assert_environment_disables("DO_NOT_TRACK", value) + + def test_other_environment_values_leave_the_program_setting_in_charge(self): + for variable, value in (("TRACE_USAGE_REPORTING", "on"), ("TRACE_USAGE_REPORTING", ""), + ("TRACE_USAGE_REPORTING", "disabled"), ("TRACE_USAGE_REPORTING", "1"), + ("DO_NOT_TRACK", "0"), ("DO_NOT_TRACK", ""), ("DO_NOT_TRACK", "false"), + ("DO_NOT_TRACK", "off")): + with mock.patch.dict(os.environ, {variable: value}): + self.assertFalse(environment_opts_out(), "%s=%r is not an opt-out" % (variable, value)) + client = TraceClient(self.base_url, "MyGame", key="k") + self.assertTrue(client.enabled, "%s=%r must not disable the client" % (variable, value)) + self.assertIsNone(client.disabled_reason) + client.close() + self.assertFalse(environment_opts_out(), "an unset variable is not an opt-out") + + def test_environment_wins_over_the_config_flag_and_over_the_key(self): + # enabled=True with a key, and the environment still says no. + with mock.patch.dict(os.environ, {"TRACE_USAGE_REPORTING": "off"}): + client = TraceClient(self.base_url, "MyGame", key="k", enabled=True) + self.assertEqual("environment", client.disabled_reason) + client.close() + # enabled=False AND the environment: the environment is the reason. + with mock.patch.dict(os.environ, {"DO_NOT_TRACK": "1"}): + self.assertEqual("environment", + TraceClient(self.base_url, "MyGame", key="k", enabled=False).disabled_reason) + self.assertEqual("environment", TraceClient(self.base_url, "MyGame").disabled_reason, + "the environment is checked before the key too") + # Once the variable is gone, the program's own setting is back in charge. + client = TraceClient(self.base_url, "MyGame", key="k") + self.assertTrue(client.enabled) + client.report("startup") + self.assertTrue(self.capture.arrived.wait(5)) + client.close() + + def test_environment_opts_out_accepts_an_explicit_mapping(self): + self.assertTrue(environment_opts_out({"TRACE_USAGE_REPORTING": "off"})) + self.assertTrue(environment_opts_out({"DO_NOT_TRACK": "yes"})) + self.assertFalse(environment_opts_out({})) + self.assertFalse(environment_opts_out({"DO_NOT_TRACK": "0", "TRACE_USAGE_REPORTING": "on"})) + + def test_user_agent_names_the_client_version(self): + from trace_client import __version__ + self.assertEqual("0.2.0", __version__) + client = TraceClient(self.base_url, "MyGame", key="k") + client.report("startup") + self.assertTrue(self.capture.arrived.wait(5)) + client.close() + self.assertEqual("trace-client-python/0.2.0 (MyGame)", self.capture.requests[0]["user_agent"]) + + def test_report_ignores_a_blank_name(self): + client = TraceClient(self.base_url, "MyGame", key="k") + client.report("") + client.report(" ") + client.close() + self.assertFalse(self.capture.arrived.wait(0.3)) + + def test_constructor_rejects_a_missing_base_url_or_application(self): + for base_url, application in ((None, "MyGame"), (" ", "MyGame"), ("http://x", None), ("http://x", "")): + with self.assertRaises(ValueError): + TraceClient(base_url, application) + + def test_json_drops_nan_and_none_tags(self): + from trace_client import _json + body = json.loads(_json("App", "n", float("nan"), {"ok": "line\nbreak", "none": None, None: "x"})) + self.assertEqual({"application": "App", "name": "n", "tags": {"ok": "line\nbreak"}}, body) + + def test_queue_is_bounded_and_drops_rather_than_grows(self): + self.capture.release.clear() # hold the sender on the first report + client = TraceClient(self.base_url, "MyGame", key="k") + flood = TraceClient.QUEUE_CAPACITY * 3 + for _ in range(flood): + client.report("flood") + dropped = sum(1 for r in self.log if "queue full" in r.getMessage()) + self.assertGreaterEqual(dropped, flood - TraceClient.QUEUE_CAPACITY - 1, + "an unbounded queue would have accepted all %d" % flood) + self.capture.release.set() + client.close() + + def test_close_sends_what_was_just_queued_before_stopping(self): + # A CLI reports once and exits at once. Without draining, the event + # races the sender thread and is lost a good fraction of the time; + # 30 back-to-back report()+close() pairs make that fraction visible. + for i in range(30): + client = TraceClient(self.base_url, "MyCli", key="k") + client.report("startup", tags={"run": str(i)}) + client.close() + self.assertEqual(30, len(self.capture.requests), "every report()+close() pair must deliver") + + def test_close_still_returns_within_the_timeout_when_the_server_hangs(self): + self.capture.release.clear() # never answers + client = TraceClient(self.base_url, "MyCli", key="k") + client.report("startup") + before = time.monotonic() + client.close(timeout=1.0) + self.assertLess(time.monotonic() - before, 2.0, "draining must be bounded by the timeout") + self.capture.release.set() + + def test_close_is_prompt_and_idempotent(self): + client = TraceClient(self.base_url, "MyGame", key="k") + client.report("startup") + before = time.monotonic() + client.close() + client.close() + self.assertLess(time.monotonic() - before, TraceClient.TIMEOUT_SECONDS + 1) + self.assertFalse(client.enabled) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_usage_reporting.py b/tests/test_usage_reporting.py new file mode 100644 index 0000000..201a7bd --- /dev/null +++ b/tests/test_usage_reporting.py @@ -0,0 +1,216 @@ +"""Tests for the usage-reporting wiring: the settings block, the one-time notice, the +opt-outs, and the startup event arriving at a loopback stub. Nothing here contacts the +real service.""" +import json +import os +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest.mock import patch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")) + +import usage_reporting # noqa: E402 +from usage_reporting import ( # noqa: E402 + APPLICATION, + DEFAULT_ENDPOINT, + DEFAULT_KEY, + DETAILS_URL, + FIRST_RUN_NOTICE, + FIRST_RUN_NOTICE_OFF_BY_ENVIRONMENT, + SETTINGS_FILE, + VERSION, + buildClient, + loadSettings, + startUsageReporting, +) + +_ENV_VARS = ("TRACE_USAGE_REPORTING", "DO_NOT_TRACK") + + +def _scrubEnvironment(test): + """The machine running the tests may itself have opted out of usage reporting; every + test starts from a clean environment and sets what it needs.""" + scrubbed = {k: v for k, v in os.environ.items() if k not in _ENV_VARS} + patcher = patch.dict(os.environ, scrubbed, clear=True) + patcher.start() + test.addCleanup(patcher.stop) + + +def _stubServer(requests, arrived): + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + requests.append({ + "path": self.path, + "authorization": self.headers.get("Authorization"), + "body": json.loads(self.rfile.read(length).decode("utf-8")), + }) + self.send_response(201) + self.send_header("Content-Length", "0") + self.end_headers() + arrived.set() + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + + +class TestUsageReportingSettings(unittest.TestCase): + def setUp(self): + _scrubEnvironment(self) + self.tempDir = tempfile.TemporaryDirectory() + self.addCleanup(self.tempDir.cleanup) + self.settingsFile = os.path.join(self.tempDir.name, "settings.json") + self.logged = [] + + def log(self, message): + self.logged.append(message) + + def readSettingsFile(self): + with open(self.settingsFile, "r") as f: + return json.load(f) + + def test_application_name_and_shipped_key(self): + self.assertEqual("Collide", APPLICATION) + self.assertEqual(43, len(DEFAULT_KEY)) + self.assertEqual("https://trace.danielstephenson.dev", DEFAULT_ENDPOINT) + + def test_settings_file_is_settings_json_at_the_repository_root(self): + self.assertEqual(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "settings.json"), SETTINGS_FILE) + + def test_first_run_writes_defaults_and_shows_the_notice_once(self): + section = loadSettings(self.settingsFile, self.log) + + self.assertEqual({"enabled": True, "endpoint": DEFAULT_ENDPOINT, "key": DEFAULT_KEY}, section) + self.assertEqual([FIRST_RUN_NOTICE], self.logged) + self.assertEqual({"usage_reporting": section}, self.readSettingsFile()) + + self.logged.clear() + self.assertEqual(section, loadSettings(self.settingsFile, self.log)) + self.assertEqual([], self.logged, "the notice must not be shown on the second run") + + def test_notice_says_it_is_on_and_names_every_opt_out(self): + self.assertTrue(FIRST_RUN_NOTICE.startswith("Usage reporting is on: Collide sends")) + self.assertIn("https://trace.danielstephenson.dev", FIRST_RUN_NOTICE) + self.assertIn('"enabled": false', FIRST_RUN_NOTICE) + self.assertIn("TRACE_USAGE_REPORTING=off", FIRST_RUN_NOTICE) + self.assertIn(DETAILS_URL, FIRST_RUN_NOTICE) + self.assertEqual("https://github.com/Stephenson-Software/trace#usage-reporting", DETAILS_URL) + self.assertNotIn("\n", FIRST_RUN_NOTICE) + + def test_first_run_under_an_environment_opt_out_says_reporting_is_off(self): + with patch.dict(os.environ, {"DO_NOT_TRACK": "1"}): + loadSettings(self.settingsFile, self.log) + + self.assertEqual([FIRST_RUN_NOTICE_OFF_BY_ENVIRONMENT], self.logged) + self.assertIn(DETAILS_URL, FIRST_RUN_NOTICE_OFF_BY_ENVIRONMENT) + + def test_existing_settings_without_the_block_are_preserved(self): + with open(self.settingsFile, "w") as f: + json.dump({"other": {"kept": 1}}, f) + + loadSettings(self.settingsFile, self.log) + + written = self.readSettingsFile() + self.assertEqual({"kept": 1}, written["other"]) + self.assertTrue(written["usage_reporting"]["enabled"]) + + def test_opt_out_is_respected_and_not_rewritten(self): + with open(self.settingsFile, "w") as f: + json.dump({"usage_reporting": {"enabled": False}}, f) + + client = buildClient(loadSettings(self.settingsFile, self.log)) + + self.assertFalse(client.enabled) + self.assertEqual("config", client.disabled_reason) + self.assertEqual([], self.logged) + self.assertEqual({"usage_reporting": {"enabled": False}}, self.readSettingsFile()) + + def test_unreadable_settings_file_disables_reporting_without_raising(self): + with open(self.settingsFile, "w") as f: + f.write("{not json") + + section = loadSettings(self.settingsFile, self.log) + + self.assertIsNone(section) + self.assertFalse(buildClient(section).enabled) + self.assertIn("usage reporting is off", self.logged[0]) + with open(self.settingsFile, "r") as f: + self.assertEqual("{not json", f.read(), "a broken settings file must not be overwritten") + + def test_missing_endpoint_and_key_fall_back_to_the_shipped_defaults(self): + client = buildClient({"enabled": True}) + + self.assertTrue(client.enabled) + self.assertEqual(DEFAULT_ENDPOINT + "/api/metrics", client._endpoint) + self.assertEqual(DEFAULT_KEY, client._key) + client.close() # nothing was reported, so nothing is sent + + +class TestStartupEvent(unittest.TestCase): + def setUp(self): + _scrubEnvironment(self) + self.tempDir = tempfile.TemporaryDirectory() + self.addCleanup(self.tempDir.cleanup) + self.settingsFile = os.path.join(self.tempDir.name, "settings.json") + self.requests = [] + self.arrived = threading.Event() + self.server = _stubServer(self.requests, self.arrived) + self.addCleanup(self.server.shutdown) + self.endpoint = "http://127.0.0.1:%d" % self.server.server_address[1] + + def writeSettings(self, enabled): + with open(self.settingsFile, "w") as f: + json.dump({"usage_reporting": {"enabled": enabled, "endpoint": self.endpoint, "key": "test-key"}}, f) + + def test_startup_event_reaches_the_configured_endpoint_with_name_and_version(self): + self.writeSettings(True) + + with patch("usage_reporting.atexit"): + client = startUsageReporting(self.settingsFile, lambda message: None) + self.addCleanup(client.close) + + self.assertTrue(self.arrived.wait(5), "the startup event should reach the stub server") + request = self.requests[0] + self.assertEqual("/api/metrics", request["path"]) + self.assertEqual("Bearer test-key", request["authorization"]) + self.assertEqual({"application": "Collide", "name": "startup", "tags": {"version": VERSION}}, + request["body"]) + + def test_opted_out_startup_sends_nothing(self): + self.writeSettings(False) + + with patch("usage_reporting.atexit"): + client = startUsageReporting(self.settingsFile, lambda message: None) + + self.assertFalse(client.enabled) + self.assertFalse(self.arrived.wait(0.3)) + + def test_environment_opt_out_wins_over_enabled_settings(self): + self.writeSettings(True) + + for variable, value in (("DO_NOT_TRACK", "1"), ("TRACE_USAGE_REPORTING", "off")): + with self.subTest(variable=variable): + with patch("usage_reporting.atexit"), patch.dict(os.environ, {variable: value}): + client = startUsageReporting(self.settingsFile, lambda message: None) + + self.assertFalse(client.enabled) + self.assertEqual("environment", client.disabled_reason) + self.assertFalse(self.arrived.wait(0.3)) + self.assertEqual([], self.requests) + + def test_start_never_raises_even_if_settings_loading_fails(self): + with patch("usage_reporting.loadSettings", side_effect=RuntimeError("boom")): + client = startUsageReporting(self.settingsFile, lambda message: None) + + self.assertFalse(client.enabled) + + +if __name__ == "__main__": + unittest.main() From 23a0c7a7caeda24f53b62264527b23d1277288c5 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Wed, 23 Sep 2026 23:09:02 -0600 Subject: [PATCH 2/2] Close the stub server's socket after each usage-reporting test Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012tRTngcEmZUaiHKcX2ErSS --- tests/test_usage_reporting.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_usage_reporting.py b/tests/test_usage_reporting.py index 201a7bd..69d7ce6 100644 --- a/tests/test_usage_reporting.py +++ b/tests/test_usage_reporting.py @@ -162,6 +162,7 @@ def setUp(self): self.requests = [] self.arrived = threading.Event() self.server = _stubServer(self.requests, self.arrived) + self.addCleanup(self.server.server_close) self.addCleanup(self.server.shutdown) self.endpoint = "http://127.0.0.1:%d" % self.server.server_address[1]