Sitelet https://github.com/Stephenson-Software/trace-client-python/pull/8/files
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
130 changes: 127 additions & 3 deletions tests/test_trace_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
9 changes: 5 additions & 4 deletions trace_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"""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
way there is nothing else to add.

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"]
Loading
Loading