From e6578e20717c43b6078d68d8031f5e68321444e2 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 3 Oct 2026 16:47:32 -0600 Subject: [PATCH] Carry a random per-installation ID as the tag install (0.4.0) Ports trace-client-java 0.5.0's installation ID. install_id= passes an explicit ID; install_id_file= loads one from, or writes a new random UUID to, a path the program chooses. Both are resolved only after the opt-outs, so a disabled client never makes up or writes an ID. An event's own install tag wins, and the tag never pushes an event past 32 tags. TraceClient.install_id_from_file(path) never raises: an unreadable or unwritable file yields an in-memory ID. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ztkfamsbEqQfcu76me5SL --- README.md | 63 ++++++++++++++++- pyproject.toml | 2 +- tests/test_trace_client.py | 130 ++++++++++++++++++++++++++++++++++- trace_client/__init__.py | 9 +-- trace_client/trace_client.py | 116 +++++++++++++++++++++++++++++-- 5 files changed, 305 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a7fdce3..ae1e93c 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,66 @@ tagged by hand carried a version. Upgrading is one argument — `TraceClient(base_url, application, __version__, key=..., enabled=...)` — and any `tags={"version": __version__}` passed to `report` can be dropped. +## Every event carries a random installation ID + +Since 0.4.0, every event can also carry the tag `install`: a random ID for +the installation, so the trace server can count **distinct installations** +("active installs in the last 30 days") rather than raw events — the same +idea as trace-client-java's server ID and bStats' `serverUuid`. It is said +out loud here because it is the one thing the client sends that is the same +from one event to the next. + +**What it is.** A random `uuid.uuid4()`. It is not derived from anything — +not a hostname, an IP address, a MAC address, a player, an account or a +path. It identifies no person and no address; all it can say is "these +events came from the same installation". (The trace server still sees the +IP address of every HTTP request, as every web server does.) + +**Where it lives.** Wherever the program says — there is no default location +and no hidden file. Without `install_id=` or `install_id_file=`, no ID is +made up, nothing is written, and no `install` tag is sent. The simplest way +in is a file next to the program's own settings: + +```python +trace = TraceClient("https://trace.danielstephenson.dev", "roam", __version__, + key=settings.usage_reporting_key, + enabled=settings.usage_reporting_enabled, + install_id_file=os.path.join(settings_dir, "trace-install-id")) +``` + +The first time an *enabled* client starts, it writes a new random UUID to +that file (creating parent directories) and reuses it on every later run. +The first line that is an ID (`[A-Za-z0-9_.-]`, at most 255 characters) is +the one used. If the file cannot be read or written, a fresh ID is used in +memory for that run only — the constructor never raises over it, and a file +that exists but cannot be read is never overwritten. + +A program that already keeps its own settings can pass the ID instead: + +```python +trace = TraceClient(url, "roam", __version__, key=key, + install_id=settings.get("install_id")) # None or blank: none sent +``` + +`install_id=` is trimmed, wins over `install_id_file=`, and over 255 +characters raises `ValueError`, like the version. An event that passes its +own `install` tag keeps it, and `install` is never added to an event that +already has 32 tags (the server's limit). `trace.install_id` returns the ID +in use (`None` when disabled or when there is none), so a program can print +it. + +`TraceClient.install_id_from_file(path)` is the same load-or-create step on +its own, for a program that wants the ID for something else. Called +directly, it writes the file whatever the opt-outs say — pass the path as +`install_id_file=` to keep the guarantee below. + +**Resetting it.** Delete the file; the next start writes a new one. Or put +your own value on its first line. + +**Opting out.** Every [opt-out](#turning-it-off) also stops the ID: a +disabled client never generates one, never reads or writes the file, and +sends nothing. + ## What `report` promises | Property | Meaning | @@ -88,7 +148,8 @@ There is no PyPI package yet; the file is the distribution. {"application":"roam","name":"command","value":1.0,"tags":{"name":"home","version":"1.4.0"}} ``` -`value` is omitted when not given; `tags` always holds at least `version`. The server assigns the +`value` is omitted when not given; `tags` always holds at least `version`, and +`install` when the client has an [installation ID](#every-event-carries-a-random-installation-id). The server assigns the timestamp. A `201` is success; anything else is logged at `DEBUG` and dropped. ## Keys diff --git a/pyproject.toml b/pyproject.toml index 942448e..881661b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trace-client" -version = "0.3.0" +version = "0.4.0" description = "One call to report that a program was used, to a trace server. Standard library only, Python 3.8+." readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_trace_client.py b/tests/test_trace_client.py index e1150ea..367a6d8 100644 --- a/tests/test_trace_client.py +++ b/tests/test_trace_client.py @@ -4,13 +4,16 @@ import json import logging import os +import shutil +import tempfile import threading import time import unittest +import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from unittest import mock -from trace_client import MAX_TAG_LENGTH, TraceClient, environment_opts_out +from trace_client import MAX_TAG_LENGTH, MAX_TAGS, TraceClient, environment_opts_out _ENV_VARS = ("TRACE_USAGE_REPORTING", "DO_NOT_TRACK") @@ -211,12 +214,12 @@ def test_environment_opts_out_accepts_an_explicit_mapping(self): def test_user_agent_names_the_client_version(self): from trace_client import __version__ - self.assertEqual("0.3.0", __version__) + self.assertEqual("0.4.0", __version__) client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k") client.report("startup") self.assertTrue(self.capture.arrived.wait(5)) client.close() - self.assertEqual("trace-client-python/0.3.0 (MyGame)", self.capture.requests[0]["user_agent"]) + self.assertEqual("trace-client-python/0.4.0 (MyGame)", self.capture.requests[0]["user_agent"]) def test_report_ignores_a_blank_name(self): client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k") @@ -368,6 +371,127 @@ def test_close_is_prompt_and_idempotent(self): self.assertLess(time.monotonic() - before, TraceClient.TIMEOUT_SECONDS + 1) self.assertFalse(client.enabled) + # -- the per-installation ID ------------------------------------------ + + def _tmpdir(self): + directory = tempfile.mkdtemp(prefix="trace-install-") + self.addCleanup(shutil.rmtree, directory, True) + return directory + + def _sent_tags(self, client, name="startup", tags=None): + client.report(name, tags=tags) + self.assertTrue(self.capture.arrived.wait(5), "the report should reach the server") + client.close() + return json.loads(self.capture.requests[-1]["body"])["tags"] + + def test_no_install_id_and_no_file_sends_no_install_tag(self): + client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k") + self.assertIsNone(client.install_id) + self.assertEqual({"version": "1.2.3"}, self._sent_tags(client)) + + def test_install_id_file_is_created_once_and_reused(self): + path = os.path.join(self._tmpdir(), "nested", "deeper", "install-id") + first = TraceClient(self.base_url, "MyGame", "1.2.3", key="k", install_id_file=path) + self.assertEqual(str(uuid.UUID(first.install_id)), first.install_id, "a random UUID") + with open(path, encoding="utf-8") as stored: + self.assertEqual(first.install_id + "\n", stored.read(), "parent directories are created") + first.close() + second = TraceClient(self.base_url, "MyGame", "1.2.3", key="k", install_id_file=path) + self.assertEqual(first.install_id, second.install_id, "the next run reuses it") + self.assertEqual({"version": "1.2.3", "install": first.install_id}, self._sent_tags(second)) + + def test_install_id_from_file_reads_the_first_valid_line(self): + path = os.path.join(self._tmpdir(), "install-id") + with open(path, "w", encoding="utf-8") as f: + f.write("\n# not an id\n my-own.id_1 \nsecond-id\n") + self.assertEqual("my-own.id_1", TraceClient.install_id_from_file(path)) + with open(path, encoding="utf-8") as f: + self.assertIn("# not an id", f.read(), "a file with an ID is never rewritten") + + def test_install_id_from_file_replaces_a_file_with_no_valid_line(self): + path = os.path.join(self._tmpdir(), "install-id") + with open(path, "w", encoding="utf-8") as f: + f.write("not an id\n" + "x" * (MAX_TAG_LENGTH + 1) + "\n") + made = TraceClient.install_id_from_file(path) + self.assertEqual(made, TraceClient.install_id_from_file(path)) + + def test_unwritable_install_id_file_yields_an_in_memory_id_and_never_raises(self): + # A path under a regular file cannot be created, even as root. + blocker = os.path.join(self._tmpdir(), "a-file") + with open(blocker, "w", encoding="utf-8") as f: + f.write("x") + path = os.path.join(blocker, "install-id") + client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k", install_id_file=path) + self.assertEqual(str(uuid.UUID(client.install_id)), client.install_id) + self.assertFalse(os.path.exists(path)) + self.assertNotEqual(client.install_id, TraceClient.install_id_from_file(path), + "in memory: a new one each process") + self.assertEqual({"version": "1.2.3", "install": client.install_id}, self._sent_tags(client)) + + def test_unreadable_install_id_file_is_left_alone(self): + directory = self._tmpdir() # a directory cannot be read as a file + made = TraceClient.install_id_from_file(directory) + self.assertEqual(str(uuid.UUID(made)), made) + self.assertTrue(os.path.isdir(directory)) + self.assertEqual([], os.listdir(directory)) + + def test_install_id_from_file_never_raises_for_a_bad_path(self): + for path in (None, "", " ", 42): + made = TraceClient.install_id_from_file(path) + self.assertEqual(str(uuid.UUID(made)), made, repr(path)) + + def test_a_disabled_client_never_makes_up_or_writes_an_install_id(self): + directory = self._tmpdir() + path = os.path.join(directory, "install-id") + os.environ["DO_NOT_TRACK"] = "1" + by_environment = TraceClient(self.base_url, "MyGame", "1.2.3", key="k", install_id_file=path, + install_id="explicit") + del os.environ["DO_NOT_TRACK"] + by_config = TraceClient(self.base_url, "MyGame", "1.2.3", key="k", enabled=False, install_id_file=path) + by_no_key = TraceClient(self.base_url, "MyGame", "1.2.3", install_id_file=path) + for client in (by_environment, by_config, by_no_key): + self.assertIsNone(client.install_id, client.disabled_reason) + self.assertEqual([], os.listdir(directory), "nothing is written") + + def test_explicit_install_id_is_trimmed_sent_and_wins_over_the_file(self): + path = os.path.join(self._tmpdir(), "install-id") + client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k", install_id=" abc-123 ", + install_id_file=path) + self.assertEqual("abc-123", client.install_id) + self.assertFalse(os.path.exists(path), "the file is not consulted when an ID is given") + self.assertEqual({"name": "home", "version": "1.2.3", "install": "abc-123"}, + self._sent_tags(client, "command", {"name": "home"})) + + def test_blank_install_id_is_none_and_an_overlong_one_is_rejected(self): + for blank in (None, "", " "): + self.assertIsNone(TraceClient(self.base_url, "MyGame", "1.2.3", key="k", install_id=blank).install_id) + with self.assertRaises(ValueError): + TraceClient(self.base_url, "MyGame", "1.2.3", key="k", install_id="x" * (MAX_TAG_LENGTH + 1)) + with self.assertRaises(ValueError, msg="rejected even on a disabled client, like the version"): + TraceClient(self.base_url, "MyGame", "1.2.3", enabled=False, install_id="x" * (MAX_TAG_LENGTH + 1)) + exact = TraceClient(self.base_url, "MyGame", "1.2.3", key="k", install_id="x" * MAX_TAG_LENGTH) + self.assertEqual("x" * MAX_TAG_LENGTH, exact.install_id) + exact.close() + + def test_an_events_own_install_tag_wins(self): + client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k", install_id="mine") + tags = {"install": "theirs"} + self.assertEqual({"install": "theirs", "version": "1.2.3"}, self._sent_tags(client, tags=tags)) + self.assertEqual({"install": "theirs"}, tags, "the caller's tags are never modified") + + def test_install_tag_never_pushes_an_event_past_the_tag_cap(self): + client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k", install_id="mine") + full = {"t%d" % i: "v" for i in range(MAX_TAGS - 1)} # plus version = MAX_TAGS + sent = self._sent_tags(client, tags=full) + self.assertEqual(MAX_TAGS, len(sent)) + self.assertNotIn("install", sent) + self.capture.arrived.clear() + client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k", install_id="mine") + room = {"t%d" % i: "v" for i in range(MAX_TAGS - 2)} + sent = self._sent_tags(client, tags=room) + self.assertEqual(MAX_TAGS, len(sent)) + self.assertEqual("mine", sent["install"]) + if __name__ == "__main__": unittest.main() diff --git a/trace_client/__init__.py b/trace_client/__init__.py index 7eb2cca..1c7cd7a 100644 --- a/trace_client/__init__.py +++ b/trace_client/__init__.py @@ -1,4 +1,4 @@ -"""trace-client 0.3.0 -- https://github.com/Stephenson-Software/trace-client-python +"""trace-client 0.4.0 -- https://github.com/Stephenson-Software/trace-client-python One call to report that a program was used. Copy ``trace_client.py`` (this package's single module) into a project as is, or vendor the package; either @@ -6,9 +6,10 @@ MIT licensed. Keep this header when vendoring so the file can be found again. """ -from .trace_client import (ENV_DO_NOT_TRACK, ENV_TRACE_USAGE_REPORTING, MAX_TAG_LENGTH, - REASON_CONFIG, REASON_ENVIRONMENT, REASON_NO_KEY, TraceClient, +from .trace_client import (ENV_DO_NOT_TRACK, ENV_TRACE_USAGE_REPORTING, INSTALL_TAG, MAX_TAG_LENGTH, + MAX_TAGS, REASON_CONFIG, REASON_ENVIRONMENT, REASON_NO_KEY, TraceClient, __version__, environment_opts_out) __all__ = ["TraceClient", "__version__", "environment_opts_out", "ENV_TRACE_USAGE_REPORTING", - "ENV_DO_NOT_TRACK", "REASON_ENVIRONMENT", "REASON_CONFIG", "REASON_NO_KEY", "MAX_TAG_LENGTH"] + "ENV_DO_NOT_TRACK", "REASON_ENVIRONMENT", "REASON_CONFIG", "REASON_NO_KEY", "MAX_TAG_LENGTH", + "MAX_TAGS", "INSTALL_TAG"] diff --git a/trace_client/trace_client.py b/trace_client/trace_client.py index 2c3797f..4aed2b4 100644 --- a/trace_client/trace_client.py +++ b/trace_client/trace_client.py @@ -1,4 +1,4 @@ -"""trace-client 0.3.0 -- https://github.com/Stephenson-Software/trace-client-python +"""trace-client 0.4.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 @@ -12,12 +12,14 @@ import logging import os import queue +import re import threading import urllib.error import urllib.request -from typing import Dict, Mapping, Optional +import uuid +from typing import Dict, Mapping, Optional, Union -__version__ = "0.3.0" +__version__ = "0.4.0" _LOG = logging.getLogger("trace") @@ -40,6 +42,16 @@ #: limit on a tag value. MAX_TAG_LENGTH = 255 +#: The most tags one event carries: the trace server's limit. The ``install`` +#: tag is only added while an event has fewer than this. +MAX_TAGS = 32 + +#: The tag every event carries the installation's ID as. +INSTALL_TAG = "install" + +# What install_id_from_file accepts as an ID on a line of its file. +_INSTALL_ID_LINE = re.compile(r"^[A-Za-z0-9_.-]{1,%d}$" % MAX_TAG_LENGTH) + def environment_opts_out(environ: Optional[Mapping[str, str]] = None) -> bool: """Whether the environment asks for usage reporting to be off, via @@ -87,6 +99,16 @@ class TraceClient: release as well as a ``startup`` one. An event's own ``version`` tag wins over it. + Every event also carries a random per-installation ID as the tag + ``install``, so the trace server can count distinct installations rather + than raw events -- when the program supplies one: ``install_id=`` (an ID + it stores itself) or ``install_id_file=`` (a path the client loads the ID + from, or writes a new random one to; see :meth:`install_id_from_file`). + Without either, no ``install`` tag is sent and nothing is written + anywhere. Both are resolved only after the opt-outs, so a disabled client + never makes up an ID and never writes one. An event's own ``install`` tag + wins over it. + :: trace = TraceClient("https://trace.example.org", "roam", __version__, @@ -100,11 +122,21 @@ class TraceClient: TIMEOUT_SECONDS = 5.0 def __init__(self, base_url: str, application: str, version: str, *, key: Optional[str] = None, - enabled: bool = True) -> None: + enabled: bool = True, install_id: Optional[str] = None, + install_id_file: Optional[Union[str, "os.PathLike[str]"]] = None) -> None: """A client for the program named ``application``, at ``version``, reporting to the trace server at ``base_url``. The version is sent as the tag ``version`` on every event; a blank one, or one longer than - :data:`MAX_TAG_LENGTH` characters, is a :class:`ValueError`.""" + :data:`MAX_TAG_LENGTH` characters, is a :class:`ValueError`. + + ``install_id`` is the installation's ID, sent as the tag ``install`` + on every event. It should be random -- e.g. a :func:`uuid.uuid4` the + program stores in its own settings -- and never derived from a + person, account or address. Trimmed; ``None`` or blank means none; + longer than :data:`MAX_TAG_LENGTH` characters is a + :class:`ValueError`. ``install_id_file`` is a path to load it from + (or create it in) with :meth:`install_id_from_file`, only when the + client is enabled; an explicit ``install_id`` wins over it.""" if not base_url or not base_url.strip(): raise ValueError("base_url is required") if not application or not application.strip(): @@ -113,6 +145,9 @@ def __init__(self, base_url: str, application: str, version: str, *, key: Option raise ValueError("version is required") if len(version.strip()) > MAX_TAG_LENGTH: raise ValueError("version is longer than %d characters" % MAX_TAG_LENGTH) + explicit_install_id = (install_id or "").strip() or None + if explicit_install_id is not None and len(explicit_install_id) > MAX_TAG_LENGTH: + raise ValueError("install_id is longer than %d characters" % MAX_TAG_LENGTH) self._endpoint = base_url.strip().rstrip("/") + "/api/metrics" self._application = application.strip() self._version = version.strip() @@ -130,7 +165,14 @@ def __init__(self, base_url: str, application: str, version: str, *, key: Option self.disabled_reason = REASON_CONFIG elif not self._key: self.disabled_reason = REASON_NO_KEY + self._install_id: Optional[str] = None if self.disabled_reason is None: + # After the opt-outs, never before: a disabled client neither + # makes up an ID nor writes one to disk. + if explicit_install_id is not None: + self._install_id = explicit_install_id + elif install_id_file is not None: + self._install_id = TraceClient.install_id_from_file(install_id_file) self._queue = queue.Queue(maxsize=self.QUEUE_CAPACITY) self._thread = threading.Thread(target=self._drain, name="trace-client/" + self._application, daemon=True) @@ -141,6 +183,57 @@ def disabled(cls) -> "TraceClient": """A client that reports nothing. Useful as a default before settings are read.""" return cls("http://disabled.invalid", "disabled", "disabled", enabled=False) + @property + def install_id(self) -> Optional[str]: + """The per-installation ID every event carries as the tag ``install``, + or ``None`` when the client is disabled or was given none (no + ``install_id`` and no ``install_id_file``).""" + return self._install_id + + @staticmethod + def install_id_from_file(path: Union[str, "os.PathLike[str]"]) -> str: + """The installation's ID kept in the file at ``path``, which the + program chooses -- there is no default location. The first line that + is an ID (``[A-Za-z0-9_.-]``, at most :data:`MAX_TAG_LENGTH` + characters, surrounding whitespace ignored) is returned. If the file + does not exist or holds no such line, a new random :func:`uuid.uuid4` + is written to it (parent directories created) and returned. Delete + the file to get a new one. + + Never raises: if the file exists but cannot be read, or cannot be + written, a fresh random ID is returned for this process only, and an + unreadable file is left as it is. + + Called directly, this writes whatever the opt-outs say. Pass the path + as ``install_id_file=`` instead to keep the guarantee that a disabled + client writes nothing.""" + fresh = str(uuid.uuid4()) + try: + target = os.fspath(path) + if not target or not str(target).strip(): + return fresh + try: + with open(target, "r", encoding="utf-8") as existing: + for line in existing: + candidate = line.strip() + if _INSTALL_ID_LINE.match(candidate): + return candidate + except FileNotFoundError: + pass + except Exception as failure: # noqa: BLE001 - unreadable: never overwrite it + _LOG.debug("[trace] could not read install ID file %s, using an in-memory one: %s", + target, failure) + return fresh + parent = os.path.dirname(os.path.abspath(target)) + os.makedirs(parent, exist_ok=True) + with open(target, "w", encoding="utf-8") as written: + written.write(fresh + "\n") + return fresh + except Exception as failure: # noqa: BLE001 - an ID must never be the reason a program stops + _LOG.debug("[trace] could not write install ID file %s, using an in-memory one: %s", + path, failure) + return fresh + @property def enabled(self) -> bool: """Whether :meth:`report` will actually send anything. ``False`` after @@ -157,7 +250,8 @@ def report(self, name: str, value: Optional[float] = None, try: if not name or not name.strip(): return - body = _json(self._application, name, value, _with_version(tags, self._version)) + tags = _with_install(_with_version(tags, self._version), self._install_id) + body = _json(self._application, name, value, tags) self._queue.put_nowait(body) except queue.Full: _LOG.debug("[trace] queue full, dropped %s", name) @@ -243,6 +337,16 @@ def _with_version(tags: Optional[Mapping[str, str]], version: str) -> Dict[str, return merged +def _with_install(tags: Dict[str, str], install_id: Optional[str]) -> Dict[str, str]: + """The tags plus ``install``, unless they already carry one, there is no + ID, or adding it would pass :data:`MAX_TAGS`. A copy when it adds.""" + if install_id is None or INSTALL_TAG in tags or len(tags) >= MAX_TAGS: + return tags + merged = dict(tags) + merged[INSTALL_TAG] = install_id + return merged + + 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")):