Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ key or value is `None` is left out, and every other key and value is sent as a
string. An event with a blank `name` is not sent at all. The server assigns the
timestamp. A `201` is success; anything else is logged at `DEBUG` and dropped.

The `User-Agent` is `trace-client-python/<client version> (<application>)`. Any
character of the application's name that a header cannot carry (outside printable
Latin-1) becomes `?` there; `application` in the body is always the exact name.

## Keys

A key identifies the program to the server and lets the operator revoke it;
Expand Down
18 changes: 17 additions & 1 deletion tests/test_trace_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest import mock

from trace_client import MAX_TAG_LENGTH, MAX_TAGS, TraceClient, environment_opts_out
from trace_client import MAX_TAG_LENGTH, MAX_TAGS, TraceClient, __version__, environment_opts_out

_ENV_VARS = ("TRACE_USAGE_REPORTING", "DO_NOT_TRACK")

Expand Down Expand Up @@ -123,6 +123,22 @@ def test_non_ascii_names_and_tags_arrive_intact_as_utf8_json(self):
"tags": {"ville": "Zürich", "emoji": "\U0001F3B2", "version": "1.2.3"}},
json.loads(request["body"]), "text the program reports is what the server reads")

def test_an_application_name_a_header_cannot_carry_still_delivers_its_events(self):
# The name goes in the User-Agent as well as the body, and a header
# refuses most of Unicode and any line break.
for application, shown in (("ゲーム", "???"), ("dice\U0001F3B2", "dice?"), ("two\nlines", "two?lines"),
("Zürich", "Zürich")):
self.capture.requests.clear()
client = TraceClient(self.base_url, application, "1.2.3", key="k")
client.report("startup")
client.close()
self.assertEqual(1, len(self.capture.requests), "%r lost its event: %s"
% (application, [r.getMessage() for r in self.log]))
request = self.capture.requests[0]
self.assertEqual(application, json.loads(request["body"])["application"],
"the event keeps the exact name; only the header is approximate")
self.assertEqual("trace-client-python/%s (%s)" % (__version__, shown), request["user_agent"])

def test_report_carries_value_and_tags_when_given(self):
client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k")
client.report("world-load", 2.5, {"seed": "42", "size": 'the "big" one'})
Expand Down
13 changes: 12 additions & 1 deletion trace_client/trace_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
# 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)

# What the User-Agent header cannot carry: everything but printable Latin-1.
_NOT_IN_A_HEADER = re.compile(r"[^\x20-\x7e\xa0-\xff]")


def environment_opts_out(environ: Optional[Mapping[str, str]] = None) -> bool:
"""Whether the environment asks for usage reporting to be off, via
Expand Down Expand Up @@ -308,7 +311,7 @@ def _send(self, body: bytes) -> None:
headers={
"Content-Type": "application/json; charset=utf-8",
"Authorization": "Bearer " + self._key,
"User-Agent": "trace-client-python/%s (%s)" % (__version__, self._application),
"User-Agent": _user_agent(self._application),
})
with urllib.request.urlopen(request, timeout=self.TIMEOUT_SECONDS) as response:
status = response.status
Expand All @@ -326,6 +329,14 @@ def _send(self, body: bytes) -> None:
_LOG.debug("[trace] trace server answered %s for %s", status, body)


def _user_agent(application: str) -> str:
"""The ``User-Agent`` for ``application``. A header carries printable
Latin-1 and nothing else, and one it cannot carry stops the request, so
any other character becomes ``?`` here. The event itself keeps the exact
name: what a program is called must not cost it every report."""
return "trace-client-python/%s (%s)" % (__version__, _NOT_IN_A_HEADER.sub("?", application))


def _with_version(tags: Optional[Mapping[str, str]], version: str) -> Dict[str, str]:
"""The event's own tags plus ``version``, unless the event already carries
one. A copy; the caller's mapping is never modified."""
Expand Down
Loading