analytics-platform 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/PKG-INFO +16 -1
  2. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/README.md +15 -0
  3. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/__init__.py +26 -9
  4. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/core.py +1 -1
  5. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/django.py +3 -1
  6. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/errors.py +24 -1
  7. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/proxy.py +10 -0
  8. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform.egg-info/PKG-INFO +16 -1
  9. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/pyproject.toml +1 -1
  10. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/tests/test_errors.py +47 -1
  11. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/tests/test_proxy.py +6 -1
  12. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/LICENSE +0 -0
  13. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/analytics.py +0 -0
  14. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/audit.py +0 -0
  15. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/crawlers.py +0 -0
  16. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/firewall.py +0 -0
  17. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/flags.py +0 -0
  18. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/forms.py +0 -0
  19. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/inventory.py +0 -0
  20. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/maintenance.py +0 -0
  21. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/performance.py +0 -0
  22. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/posture.py +0 -0
  23. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/request_rules.py +0 -0
  24. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform/resources.py +0 -0
  25. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform.egg-info/SOURCES.txt +0 -0
  26. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform.egg-info/dependency_links.txt +0 -0
  27. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/analytics_platform.egg-info/top_level.txt +0 -0
  28. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/setup.cfg +0 -0
  29. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/tests/test_crawlers.py +0 -0
  30. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/tests/test_firewall.py +0 -0
  31. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/tests/test_request_rules.py +0 -0
  32. {analytics_platform-0.3.0 → analytics_platform-0.3.2}/tests/test_resources.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
5
5
  Author: steviec87
6
6
  License-Expression: MIT
@@ -168,6 +168,11 @@ a few seconds later.
168
168
  What still can't be sent waits in a file in the temp folder and is sent when
169
169
  it can be, after a restart too; each error keeps the time it happened.
170
170
 
171
+ An error in a request or job also shows the steps before it. Add them with
172
+ `platform.add_breadcrumb("Order saved")`; a step added outside a request or job
173
+ is dropped. Outgoing calls aren't recorded on their own, since Python has no
174
+ public way to see them.
175
+
171
176
  Background jobs, as a decorator or a `with` block (sync or async):
172
177
 
173
178
  ```python
@@ -179,6 +184,16 @@ Their errors carry the job's name as the route, a job that raises is sent
179
184
  (status 500) and raised on, and each run is timed (Server speed → Jobs).
180
185
  Several services on one site: `Platform(..., server_name="worker")`.
181
186
 
187
+ **Several apps on one site** (microservices, a worker, a Node service next to
188
+ this one): install the package in each, with the same `site_id`, `secret` and
189
+ `endpoint`, and a `server_name` of its own. Errors, Server speed, Crawlers and
190
+ Security → Updates then tell the apps apart. Without one, each app is named
191
+ after its machine: two apps on one machine are reported as one, and only the
192
+ last package list sent is kept. A service only your other apps call takes
193
+ `firewall=False, proxy=False, maintenance=False` (its requests all come from
194
+ your own apps, so the rate limit would see one visitor); on a machine shared
195
+ by several apps, keep `resources` on in only one of them.
196
+
182
197
  Options: `server_name`, `root` (traceback paths relative to it),
183
198
  `capture_uncaught`, `capture_logging`, `spool_dir` (where unsent errors wait;
184
199
  `False` for none), `before_send(event, error)` (change an error, or return
@@ -142,6 +142,11 @@ a few seconds later.
142
142
  What still can't be sent waits in a file in the temp folder and is sent when
143
143
  it can be, after a restart too; each error keeps the time it happened.
144
144
 
145
+ An error in a request or job also shows the steps before it. Add them with
146
+ `platform.add_breadcrumb("Order saved")`; a step added outside a request or job
147
+ is dropped. Outgoing calls aren't recorded on their own, since Python has no
148
+ public way to see them.
149
+
145
150
  Background jobs, as a decorator or a `with` block (sync or async):
146
151
 
147
152
  ```python
@@ -153,6 +158,16 @@ Their errors carry the job's name as the route, a job that raises is sent
153
158
  (status 500) and raised on, and each run is timed (Server speed → Jobs).
154
159
  Several services on one site: `Platform(..., server_name="worker")`.
155
160
 
161
+ **Several apps on one site** (microservices, a worker, a Node service next to
162
+ this one): install the package in each, with the same `site_id`, `secret` and
163
+ `endpoint`, and a `server_name` of its own. Errors, Server speed, Crawlers and
164
+ Security → Updates then tell the apps apart. Without one, each app is named
165
+ after its machine: two apps on one machine are reported as one, and only the
166
+ last package list sent is kept. A service only your other apps call takes
167
+ `firewall=False, proxy=False, maintenance=False` (its requests all come from
168
+ your own apps, so the rate limit would see one visitor); on a machine shared
169
+ by several apps, keep `resources` on in only one of them.
170
+
156
171
  Options: `server_name`, `root` (traceback paths relative to it),
157
172
  `capture_uncaught`, `capture_logging`, `spool_dir` (where unsent errors wait;
158
173
  `False` for none), `before_send(event, error)` (change an error, or return
@@ -18,7 +18,7 @@ import functools
18
18
  import time
19
19
 
20
20
  from .core import Core, SESSION_HEADER, VERSION, header, request_target
21
- from .errors import current_request
21
+ from .errors import add_breadcrumb, current_request, current_trail
22
22
 
23
23
  __all__ = ["create_platform", "Platform", "PlatformMiddleware", "VERSION"]
24
24
 
@@ -100,6 +100,10 @@ class Platform:
100
100
  # so its errors, timings and resources can be told apart.
101
101
  if server_name and name in ("errors", "performance", "crawlers", "resources"):
102
102
  opts.setdefault("server_name", server_name)
103
+ # Inventory and posture call it host, and are stored one per site
104
+ # and host: without it two services on one machine overwrite each other's.
105
+ if server_name and name in ("inventory", "posture"):
106
+ opts.setdefault("host", server_name)
103
107
  try:
104
108
  self._modules[name] = _load(name, self.core, opts)
105
109
  except Exception as error:
@@ -150,6 +154,12 @@ class Platform:
150
154
  if errors:
151
155
  errors.capture_exception(error, request=request, status=status, kind=kind)
152
156
 
157
+ def add_breadcrumb(self, text):
158
+ """A step of your own in the trail shown before an error in this
159
+ request or job."""
160
+ if self._modules.get("errors"):
161
+ add_breadcrumb(text)
162
+
153
163
  def job(self, name):
154
164
  """A background job (a queue consumer, a cron task), as a decorator or
155
165
  a with block: its errors are sent with the job's name as their route,
@@ -361,6 +371,7 @@ class Platform:
361
371
  response_headers = list(response_headers) + platform.security_headers(n for n, _ in response_headers) + remember
362
372
  return start_response(status, response_headers, exc_info) if exc_info else start_response(status, response_headers)
363
373
 
374
+ trail = current_trail.set([])
364
375
  try:
365
376
  return app(environ, timed_start_response)
366
377
  except Exception as error:
@@ -370,6 +381,7 @@ class Platform:
370
381
  })
371
382
  raise
372
383
  finally:
384
+ current_trail.reset(trail)
373
385
  if timing:
374
386
  platform.time_request(environ.get("REQUEST_METHOD", ""), "", seen["status"], started, environ.get("PATH_INFO", ""))
375
387
 
@@ -449,18 +461,21 @@ class _Job:
449
461
  return {"method": "JOB", "path": self.route, "route": self.route, "session_id": ""}
450
462
 
451
463
  def __enter__(self):
452
- self._runs.append((time.perf_counter(), current_request.set(self._fields)))
464
+ self._runs.append((time.perf_counter(), current_request.set(self._fields), current_trail.set([])))
453
465
  return self
454
466
 
455
467
  def __exit__(self, kind, value, tb):
456
- started, token = self._runs.pop()
468
+ started, token, trail = self._runs.pop()
457
469
  current_request.reset(token)
458
- if value is not None and not isinstance(value, Exception):
459
- return False # cancelled or interrupted (CancelledError, KeyboardInterrupt): not a run that failed
460
- if value is not None:
461
- self.platform.capture_exception(value, status=500, kind="error", request=self._fields())
462
- self.platform.time_request("JOB", self.route, 500 if value is not None else 200, started, self.route)
463
- return False
470
+ try:
471
+ if value is not None and not isinstance(value, Exception):
472
+ return False # cancelled or interrupted (CancelledError, KeyboardInterrupt): not a run that failed
473
+ if value is not None:
474
+ self.platform.capture_exception(value, status=500, kind="error", request=self._fields())
475
+ self.platform.time_request("JOB", self.route, 500 if value is not None else 200, started, self.route)
476
+ return False
477
+ finally:
478
+ current_trail.reset(trail)
464
479
 
465
480
  async def __aenter__(self):
466
481
  return self.__enter__()
@@ -552,6 +567,7 @@ class PlatformMiddleware:
552
567
  "method": scope.get("method", ""), "path": scope.get("path", ""),
553
568
  "route": getattr(scope.get("route"), "path", ""), "session_id": headers.get(SESSION_HEADER, ""),
554
569
  })
570
+ trail = current_trail.set([])
555
571
  try:
556
572
  await self.app(scope, receive, timed_send)
557
573
  except Exception as error:
@@ -562,6 +578,7 @@ class PlatformMiddleware:
562
578
  raise
563
579
  finally:
564
580
  current_request.reset(token)
581
+ current_trail.reset(trail)
565
582
  # The router sets scope["route"] (FastAPI, Starlette) on the way in.
566
583
  self.platform.time_request(scope.get("method", ""), getattr(scope.get("route"), "path", "") or "",
567
584
  seen["status"], started, scope.get("path", ""))
@@ -16,7 +16,7 @@ import urllib.error
16
16
  import urllib.parse
17
17
  import urllib.request
18
18
 
19
- VERSION = "0.3.0"
19
+ VERSION = "0.3.2"
20
20
  TIMEOUT = 5
21
21
  MIN_SYNC = 30
22
22
  RETRY = 60
@@ -16,7 +16,7 @@ from django.conf import settings
16
16
  from django.http import HttpResponse
17
17
 
18
18
  from . import SESSION_HEADER, Platform
19
- from .errors import current_request
19
+ from .errors import current_request, current_trail
20
20
 
21
21
  _platform = None
22
22
 
@@ -96,10 +96,12 @@ class PlatformMiddleware:
96
96
  # Errors captured during the request (logged, or caught without the
97
97
  # request) still know which request they came from.
98
98
  token = current_request.set(lambda: _request_fields(request))
99
+ trail = current_trail.set([])
99
100
  try:
100
101
  response = self.get_response(request)
101
102
  finally:
102
103
  current_request.reset(token)
104
+ current_trail.reset(trail)
103
105
  for name, value in self.platform.security_headers(response.headers.keys()):
104
106
  response[name] = value
105
107
  if closed:
@@ -8,7 +8,8 @@ error is sent once however many of those see it. Errors are sent in small
8
8
  batches, a batch the collector couldn't take is tried once more, and sending
9
9
  never raises. What still can't be sent (the collector is away, or the process
10
10
  is crashing) waits in a file and is sent once it can be, after a restart too;
11
- each error carries when it happened.
11
+ each error carries when it happened, and the trail of its request or job: the
12
+ steps you add with add_breadcrumb().
12
13
  """
13
14
  import contextvars
14
15
  import hashlib
@@ -44,6 +45,25 @@ SPOOL_BYTES = 1024 * 1024
44
45
  # returning {"method", "path", "route", "session_id"}, so an error captured
45
46
  # without a request (logged, or caught deep in your code) still gets its route.
46
47
  current_request = contextvars.ContextVar("analytics_platform_request", default=None)
48
+ # The steps of the request or job being handled, oldest first, set to a new
49
+ # list wherever current_request starts a request. Outside one, steps are dropped.
50
+ current_trail = contextvars.ContextVar("analytics_platform_trail", default=None)
51
+ CRUMB_MAX = 15
52
+
53
+
54
+ def add_breadcrumb(text):
55
+ trail = current_trail.get()
56
+ if trail is None:
57
+ return
58
+ trail.append({"type": "note", "text": str(text)[:200], "at": time.time()})
59
+ del trail[:-CRUMB_MAX]
60
+
61
+
62
+ def _breadcrumbs():
63
+ """The trail as the tracker sends it: how long before now each step happened."""
64
+ now = time.time()
65
+ return [{"type": crumb["type"], "text": crumb["text"], "ms": max(0, round((now - crumb["at"]) * 1000))}
66
+ for crumb in current_trail.get() or []]
47
67
 
48
68
 
49
69
  def _request_fields():
@@ -172,6 +192,9 @@ class Errors:
172
192
  # When it happened: a crash sent after a restart keeps its own time.
173
193
  "at": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
174
194
  }
195
+ trail = _breadcrumbs()
196
+ if trail:
197
+ event["breadcrumbs"] = trail
175
198
  event.update({k: str(v or "") for k, v in (request or {}).items() if k in ("method", "path", "route", "session_id")})
176
199
  if event.get("path"):
177
200
  event["path"] = event["path"].split("?")[0]
@@ -47,6 +47,13 @@ def prefix_for(secret):
47
47
  return hashlib.sha256(("proxy|" + str(secret)).encode("utf-8")).hexdigest()[:8]
48
48
 
49
49
 
50
+ # Sent as X-Proxy-Key, so the collector believes the visitor addresses this
51
+ # proxy passes on: anyone can write X-Forwarded-For. Worked out from the secret
52
+ # as the collector does (expresscollect/utils/proxyKey.js).
53
+ def key_for(secret):
54
+ return hashlib.sha256(("proxykey|" + str(secret)).encode("utf-8")).hexdigest()[:32]
55
+
56
+
50
57
  class Proxy:
51
58
  """prefix: the path it answers on (default: from the secret)."""
52
59
 
@@ -54,6 +61,7 @@ class Proxy:
54
61
  self.core = core
55
62
  self.enabled = enabled
56
63
  self.prefix = re.sub(r"[^A-Za-z0-9_-]", "", str(prefix or prefix_for(core.secret)))
64
+ self.key = key_for(core.secret) if core.secret else ""
57
65
  # One copy of each tracker file, so a repeat visitor costs no upstream
58
66
  # request. A handful of small files: it never grows.
59
67
  self._files = {}
@@ -139,6 +147,8 @@ class Proxy:
139
147
  # The visitor's address: the collector needs it for the country, the
140
148
  # daily visitor ID and the per-visitor rate limit.
141
149
  out = {"X-Forwarded-For": client_ip or ""}
150
+ if self.key:
151
+ out["X-Proxy-Key"] = self.key
142
152
  for name in PASS_HEADERS:
143
153
  value = headers.get(name)
144
154
  if value:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
5
5
  Author: steviec87
6
6
  License-Expression: MIT
@@ -168,6 +168,11 @@ a few seconds later.
168
168
  What still can't be sent waits in a file in the temp folder and is sent when
169
169
  it can be, after a restart too; each error keeps the time it happened.
170
170
 
171
+ An error in a request or job also shows the steps before it. Add them with
172
+ `platform.add_breadcrumb("Order saved")`; a step added outside a request or job
173
+ is dropped. Outgoing calls aren't recorded on their own, since Python has no
174
+ public way to see them.
175
+
171
176
  Background jobs, as a decorator or a `with` block (sync or async):
172
177
 
173
178
  ```python
@@ -179,6 +184,16 @@ Their errors carry the job's name as the route, a job that raises is sent
179
184
  (status 500) and raised on, and each run is timed (Server speed → Jobs).
180
185
  Several services on one site: `Platform(..., server_name="worker")`.
181
186
 
187
+ **Several apps on one site** (microservices, a worker, a Node service next to
188
+ this one): install the package in each, with the same `site_id`, `secret` and
189
+ `endpoint`, and a `server_name` of its own. Errors, Server speed, Crawlers and
190
+ Security → Updates then tell the apps apart. Without one, each app is named
191
+ after its machine: two apps on one machine are reported as one, and only the
192
+ last package list sent is kept. A service only your other apps call takes
193
+ `firewall=False, proxy=False, maintenance=False` (its requests all come from
194
+ your own apps, so the rate limit would see one visitor); on a machine shared
195
+ by several apps, keep `resources` on in only one of them.
196
+
182
197
  Options: `server_name`, `root` (traceback paths relative to it),
183
198
  `capture_uncaught`, `capture_logging`, `spool_dir` (where unsent errors wait;
184
199
  `False` for none), `before_send(event, error)` (change an error, or return
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "analytics-platform"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -13,7 +13,7 @@ import unittest
13
13
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
14
14
 
15
15
  from analytics_platform import errors as mod # noqa: E402
16
- from analytics_platform.errors import Errors, current_request # noqa: E402
16
+ from analytics_platform.errors import Errors, add_breadcrumb, current_request, current_trail # noqa: E402
17
17
 
18
18
 
19
19
  class Answer:
@@ -249,5 +249,51 @@ class ErrorsTest(unittest.TestCase):
249
249
  kept = open(errors.spool_file).read().splitlines()
250
250
  self.assertEqual([json.loads(line)["message"] for line in kept], ["e10", "e11"], "once, and not what was sent")
251
251
 
252
+
253
+ class Breadcrumbs(unittest.TestCase):
254
+ def capture(self, errors, message):
255
+ errors.capture_exception(ValueError(message))
256
+
257
+ def test_an_error_carries_the_last_steps_of_its_request(self):
258
+ core = FakeCore()
259
+ errors = Errors(core, capture_uncaught=False)
260
+ add_breadcrumb("outside a request, kept nowhere")
261
+ self.capture(errors, "no trail")
262
+ token = current_trail.set([])
263
+ try:
264
+ for i in range(20):
265
+ add_breadcrumb("step %d" % i)
266
+ self.capture(errors, "after the steps")
267
+ finally:
268
+ current_trail.reset(token)
269
+ self.capture(errors, "no trail either")
270
+ errors.flush()
271
+ first, second, third = core.sent[0]
272
+ self.assertNotIn("breadcrumbs", first)
273
+ self.assertNotIn("breadcrumbs", third)
274
+ crumbs = second["breadcrumbs"]
275
+ self.assertEqual(len(crumbs), 15)
276
+ self.assertEqual(crumbs[0]["text"], "step 5")
277
+ self.assertEqual(crumbs[-1]["type"], "note")
278
+ self.assertNotIn("at", crumbs[0])
279
+ self.assertGreaterEqual(crumbs[0]["ms"], 0)
280
+
281
+ def test_a_job_has_its_own_trail_and_keeps_it_for_the_error_it_raises(self):
282
+ from analytics_platform import create_platform
283
+ platform = create_platform(site_id="a1b2c3d4e5f6a1b2c3d4e5f6", secret="s", endpoint="http://127.0.0.1:9",
284
+ firewall=False, analytics=False, flags=False, inventory=False, posture=False,
285
+ resources=False, proxy=False, maintenance=False, crawlers=False, forms=False,
286
+ audit=False, errors={"capture_uncaught": False, "spool_dir": False})
287
+ seen = []
288
+ platform._modules["errors"].before_send = lambda event, error: seen.append(event) or None
289
+ with self.assertRaises(ValueError):
290
+ with platform.job("send-receipts"):
291
+ platform.add_breadcrumb("Order saved")
292
+ raise ValueError("in a job")
293
+ self.assertEqual([crumb["text"] for crumb in seen[0]["breadcrumbs"]], ["Order saved"])
294
+ self.assertIsNone(current_trail.get(), "the trail ends with the job")
295
+ platform.close()
296
+
297
+
252
298
  if __name__ == "__main__":
253
299
  unittest.main()
@@ -12,7 +12,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
12
12
 
13
13
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
14
14
 
15
- from analytics_platform.proxy import Proxy, prefix_for # noqa: E402
15
+ from analytics_platform.proxy import Proxy, key_for, prefix_for # noqa: E402
16
16
 
17
17
  TRACKER = b"// a tracker" + b"x" * 600
18
18
 
@@ -77,6 +77,10 @@ class ProxyTest(unittest.TestCase):
77
77
  Collector.seen = []
78
78
  self.proxy = Proxy(FakeCore(self.endpoint))
79
79
 
80
+ def test_the_proxy_key_is_worked_out_as_the_collector_does(self):
81
+ # The same vector is pinned in the collector, the backend and the other packages.
82
+ self.assertEqual(key_for("0123456789abcdef01234567"), "8fc74dbbd4f088449862841cd29fcaee")
83
+
80
84
  def test_the_address_comes_from_the_secret(self):
81
85
  self.assertEqual(prefix_for("s3cret"), prefix_for("s3cret"))
82
86
  self.assertNotEqual(prefix_for("s3cret"), prefix_for("other"))
@@ -123,6 +127,7 @@ class ProxyTest(unittest.TestCase):
123
127
  self.assertEqual(got["path"], "/collectanalytics/e?v=1")
124
128
  self.assertEqual(got["body"], b'{"event":"pageview"}')
125
129
  self.assertEqual(got["headers"].get("X-Forwarded-For"), "198.51.100.4")
130
+ self.assertEqual(got["headers"].get("X-Proxy-Key"), key_for("s3cret"), "so the collector believes the address")
126
131
  self.assertEqual(got["headers"].get("User-Agent"), "Mozilla/5.0")
127
132
  self.assertIsNone(got["headers"].get("Cookie"), "the site's own cookies stay on the site")
128
133