analytics-platform 0.3.1__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.1 → analytics_platform-0.3.2}/PKG-INFO +6 -1
  2. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/README.md +5 -0
  3. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/__init__.py +22 -9
  4. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/core.py +1 -1
  5. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/django.py +3 -1
  6. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/errors.py +24 -1
  7. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform.egg-info/PKG-INFO +6 -1
  8. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/pyproject.toml +1 -1
  9. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/tests/test_errors.py +47 -1
  10. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/LICENSE +0 -0
  11. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/analytics.py +0 -0
  12. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/audit.py +0 -0
  13. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/crawlers.py +0 -0
  14. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/firewall.py +0 -0
  15. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/flags.py +0 -0
  16. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/forms.py +0 -0
  17. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/inventory.py +0 -0
  18. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/maintenance.py +0 -0
  19. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/performance.py +0 -0
  20. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/posture.py +0 -0
  21. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/proxy.py +0 -0
  22. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/request_rules.py +0 -0
  23. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform/resources.py +0 -0
  24. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform.egg-info/SOURCES.txt +0 -0
  25. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform.egg-info/dependency_links.txt +0 -0
  26. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/analytics_platform.egg-info/top_level.txt +0 -0
  27. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/setup.cfg +0 -0
  28. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/tests/test_crawlers.py +0 -0
  29. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/tests/test_firewall.py +0 -0
  30. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/tests/test_proxy.py +0 -0
  31. {analytics_platform-0.3.1 → analytics_platform-0.3.2}/tests/test_request_rules.py +0 -0
  32. {analytics_platform-0.3.1 → 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.1
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
@@ -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
@@ -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
 
@@ -154,6 +154,12 @@ class Platform:
154
154
  if errors:
155
155
  errors.capture_exception(error, request=request, status=status, kind=kind)
156
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
+
157
163
  def job(self, name):
158
164
  """A background job (a queue consumer, a cron task), as a decorator or
159
165
  a with block: its errors are sent with the job's name as their route,
@@ -365,6 +371,7 @@ class Platform:
365
371
  response_headers = list(response_headers) + platform.security_headers(n for n, _ in response_headers) + remember
366
372
  return start_response(status, response_headers, exc_info) if exc_info else start_response(status, response_headers)
367
373
 
374
+ trail = current_trail.set([])
368
375
  try:
369
376
  return app(environ, timed_start_response)
370
377
  except Exception as error:
@@ -374,6 +381,7 @@ class Platform:
374
381
  })
375
382
  raise
376
383
  finally:
384
+ current_trail.reset(trail)
377
385
  if timing:
378
386
  platform.time_request(environ.get("REQUEST_METHOD", ""), "", seen["status"], started, environ.get("PATH_INFO", ""))
379
387
 
@@ -453,18 +461,21 @@ class _Job:
453
461
  return {"method": "JOB", "path": self.route, "route": self.route, "session_id": ""}
454
462
 
455
463
  def __enter__(self):
456
- 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([])))
457
465
  return self
458
466
 
459
467
  def __exit__(self, kind, value, tb):
460
- started, token = self._runs.pop()
468
+ started, token, trail = self._runs.pop()
461
469
  current_request.reset(token)
462
- if value is not None and not isinstance(value, Exception):
463
- return False # cancelled or interrupted (CancelledError, KeyboardInterrupt): not a run that failed
464
- if value is not None:
465
- self.platform.capture_exception(value, status=500, kind="error", request=self._fields())
466
- self.platform.time_request("JOB", self.route, 500 if value is not None else 200, started, self.route)
467
- 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)
468
479
 
469
480
  async def __aenter__(self):
470
481
  return self.__enter__()
@@ -556,6 +567,7 @@ class PlatformMiddleware:
556
567
  "method": scope.get("method", ""), "path": scope.get("path", ""),
557
568
  "route": getattr(scope.get("route"), "path", ""), "session_id": headers.get(SESSION_HEADER, ""),
558
569
  })
570
+ trail = current_trail.set([])
559
571
  try:
560
572
  await self.app(scope, receive, timed_send)
561
573
  except Exception as error:
@@ -566,6 +578,7 @@ class PlatformMiddleware:
566
578
  raise
567
579
  finally:
568
580
  current_request.reset(token)
581
+ current_trail.reset(trail)
569
582
  # The router sets scope["route"] (FastAPI, Starlette) on the way in.
570
583
  self.platform.time_request(scope.get("method", ""), getattr(scope.get("route"), "path", "") or "",
571
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.1"
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]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.3.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "analytics-platform"
7
- version = "0.3.1"
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()