diff --git a/README.md b/README.md index 1ed08f1..bba38b9 100644 --- a/README.md +++ b/README.md @@ -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/ ()`. 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; diff --git a/tests/test_trace_client.py b/tests/test_trace_client.py index 03bcd97..50afd95 100644 --- a/tests/test_trace_client.py +++ b/tests/test_trace_client.py @@ -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") @@ -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'}) diff --git a/trace_client/trace_client.py b/trace_client/trace_client.py index 9a7affc..9484b92 100644 --- a/trace_client/trace_client.py +++ b/trace_client/trace_client.py @@ -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 @@ -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 @@ -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."""