analytics-platform 0.3.1__tar.gz → 0.3.3__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 (33) hide show
  1. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/PKG-INFO +10 -2
  2. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/README.md +9 -1
  3. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/__init__.py +23 -10
  4. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/core.py +43 -2
  5. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/django.py +3 -1
  6. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/errors.py +24 -1
  7. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform.egg-info/PKG-INFO +10 -2
  8. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform.egg-info/SOURCES.txt +1 -0
  9. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/pyproject.toml +1 -1
  10. analytics_platform-0.3.3/tests/test_checkin.py +29 -0
  11. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/tests/test_errors.py +47 -1
  12. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/LICENSE +0 -0
  13. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/analytics.py +0 -0
  14. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/audit.py +0 -0
  15. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/crawlers.py +0 -0
  16. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/firewall.py +0 -0
  17. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/flags.py +0 -0
  18. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/forms.py +0 -0
  19. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/inventory.py +0 -0
  20. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/maintenance.py +0 -0
  21. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/performance.py +0 -0
  22. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/posture.py +0 -0
  23. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/proxy.py +0 -0
  24. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/request_rules.py +0 -0
  25. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform/resources.py +0 -0
  26. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform.egg-info/dependency_links.txt +0 -0
  27. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/analytics_platform.egg-info/top_level.txt +0 -0
  28. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/setup.cfg +0 -0
  29. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/tests/test_crawlers.py +0 -0
  30. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/tests/test_firewall.py +0 -0
  31. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/tests/test_proxy.py +0 -0
  32. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/tests/test_request_rules.py +0 -0
  33. {analytics_platform-0.3.1 → analytics_platform-0.3.3}/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.3
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
@@ -182,7 +187,10 @@ Several services on one site: `Platform(..., server_name="worker")`.
182
187
  **Several apps on one site** (microservices, a worker, a Node service next to
183
188
  this one): install the package in each, with the same `site_id`, `secret` and
184
189
  `endpoint`, and a `server_name` of its own. Errors, Server speed, Crawlers and
185
- Security → Updates then tell the apps apart. Without one, each app is named
190
+ Security → Updates then tell the apps apart, and Health → All systems lists
191
+ each with a light: the package checks in about once a minute, so an app that
192
+ stops reads "Stopped" within ten minutes (`sdk/protocols/checkin.md`).
193
+ Without one, each app is named
186
194
  after its machine: two apps on one machine are reported as one, and only the
187
195
  last package list sent is kept. A service only your other apps call takes
188
196
  `firewall=False, proxy=False, maintenance=False` (its requests all come from
@@ -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
@@ -156,7 +161,10 @@ Several services on one site: `Platform(..., server_name="worker")`.
156
161
  **Several apps on one site** (microservices, a worker, a Node service next to
157
162
  this one): install the package in each, with the same `site_id`, `secret` and
158
163
  `endpoint`, and a `server_name` of its own. Errors, Server speed, Crawlers and
159
- Security → Updates then tell the apps apart. Without one, each app is named
164
+ Security → Updates then tell the apps apart, and Health → All systems lists
165
+ each with a light: the package checks in about once a minute, so an app that
166
+ stops reads "Stopped" within ten minutes (`sdk/protocols/checkin.md`).
167
+ Without one, each app is named
160
168
  after its machine: two apps on one machine are reported as one, and only the
161
169
  last package list sent is kept. A service only your other apps call takes
162
170
  `firewall=False, proxy=False, maintenance=False` (its requests all come from
@@ -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
 
@@ -81,7 +81,7 @@ class Platform:
81
81
  performance=True, crawlers=True, forms=True, headers=True, audit=True, flags=True, maintenance=True,
82
82
  inventory=True,
83
83
  posture=True, resources=True, proxy=True, server_name=None):
84
- self.core = Core(site_id, secret, endpoint, debug)
84
+ self.core = Core(site_id, secret, endpoint, debug, server_name=server_name)
85
85
  if not self.core.valid:
86
86
  print("[analytics-platform] site_id, secret and endpoint are required; nothing is sent and every request passes")
87
87
  settings = {"firewall": firewall, "errors": errors, "analytics": analytics, "performance": performance,
@@ -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", ""))
@@ -10,13 +10,16 @@ start again in a forked worker (gunicorn, uWSGI), where threads don't survive.
10
10
  import atexit
11
11
  import json
12
12
  import os
13
+ import platform
13
14
  import re
15
+ import socket
14
16
  import threading
17
+ import time
15
18
  import urllib.error
16
19
  import urllib.parse
17
20
  import urllib.request
18
21
 
19
- VERSION = "0.3.1"
22
+ VERSION = "0.3.3"
20
23
  TIMEOUT = 5
21
24
  MIN_SYNC = 30
22
25
  RETRY = 60
@@ -100,9 +103,30 @@ class Periodic:
100
103
  thread.join(wait)
101
104
 
102
105
 
106
+ def _rss_mb():
107
+ """This process's resident memory in MB, where the platform says (Linux
108
+ /proc; elsewhere the peak, from resource); None when neither can."""
109
+ try:
110
+ with open("/proc/self/statm") as statm:
111
+ return round(int(statm.read().split()[1]) * os.sysconf("SC_PAGE_SIZE") / 1048576)
112
+ except Exception:
113
+ pass
114
+ try:
115
+ import resource
116
+ import sys
117
+ peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
118
+ return round(peak / 1048576 if sys.platform == "darwin" else peak / 1024)
119
+ except Exception:
120
+ return None
121
+
122
+
103
123
  class Core:
104
- def __init__(self, site_id, secret, endpoint, debug=False):
124
+ def __init__(self, site_id, secret, endpoint, debug=False, server_name=None):
105
125
  self.site_id = str(site_id or "")
126
+ # The name this service checks in under (X-Platform-App, on the switches
127
+ # request): server_name, as every module sends it, else the machine's.
128
+ self.server_name = str(server_name or socket.gethostname())[:100]
129
+ self._started = time.time()
106
130
  self.secret = str(secret or "")
107
131
  self.endpoint = str(endpoint or "").rstrip("/")
108
132
  self.valid = bool(re.fullmatch(r"[a-fA-F0-9]{24}", self.site_id) and self.secret and self.endpoint)
@@ -154,8 +178,25 @@ class Core:
154
178
  """False only when the dashboard turned the module off."""
155
179
  return self._switches.get(name) is not False
156
180
 
181
+ def _check_in(self):
182
+ """This service's check-in, form-encoded so any name is plain ASCII:
183
+ Health -> All systems shows it running and when it was last heard from.
184
+ None when it can't be built: the switches still sync, without it."""
185
+ try:
186
+ fields = {"server": self.server_name, "kind": "process", "pid": os.getpid(),
187
+ "up_s": int(time.time() - self._started), "runtime": "Python " + platform.python_version()}
188
+ rss = _rss_mb()
189
+ if rss is not None:
190
+ fields["rss_mb"] = rss
191
+ return urllib.parse.urlencode(fields) if self.server_name else None
192
+ except Exception:
193
+ return None
194
+
157
195
  def _sync_switches(self):
158
196
  headers = {"X-Platform-Modules": ",".join(self._loaded)}
197
+ app = self._check_in()
198
+ if app:
199
+ headers["X-Platform-App"] = app
159
200
  if self._etag:
160
201
  headers["If-None-Match"] = self._etag
161
202
  res = self.send("core", "/events/platform/config?site_id=" + self.site_id, method="GET", auth="bearer", headers=headers)
@@ -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.3
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
@@ -182,7 +187,10 @@ Several services on one site: `Platform(..., server_name="worker")`.
182
187
  **Several apps on one site** (microservices, a worker, a Node service next to
183
188
  this one): install the package in each, with the same `site_id`, `secret` and
184
189
  `endpoint`, and a `server_name` of its own. Errors, Server speed, Crawlers and
185
- Security → Updates then tell the apps apart. Without one, each app is named
190
+ Security → Updates then tell the apps apart, and Health → All systems lists
191
+ each with a light: the package checks in about once a minute, so an app that
192
+ stops reads "Stopped" within ten minutes (`sdk/protocols/checkin.md`).
193
+ Without one, each app is named
186
194
  after its machine: two apps on one machine are reported as one, and only the
187
195
  last package list sent is kept. A service only your other apps call takes
188
196
  `firewall=False, proxy=False, maintenance=False` (its requests all come from
@@ -22,6 +22,7 @@ analytics_platform.egg-info/PKG-INFO
22
22
  analytics_platform.egg-info/SOURCES.txt
23
23
  analytics_platform.egg-info/dependency_links.txt
24
24
  analytics_platform.egg-info/top_level.txt
25
+ tests/test_checkin.py
25
26
  tests/test_crawlers.py
26
27
  tests/test_errors.py
27
28
  tests/test_firewall.py
@@ -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.3"
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"
@@ -0,0 +1,29 @@
1
+ """The check-in on the switches request (X-Platform-App): what names this
2
+ service on Health -> All systems."""
3
+ import os
4
+ import unittest
5
+ import urllib.parse
6
+ from unittest import mock
7
+
8
+ from analytics_platform.core import Core
9
+
10
+
11
+ class CheckInTest(unittest.TestCase):
12
+ def test_switches_request_says_which_service_asks_and_that_it_runs(self):
13
+ core = Core("a" * 24, "s", "http://collector", server_name="api wörker")
14
+ with mock.patch.object(core, "send", return_value=None) as send:
15
+ core._sync_switches()
16
+ header = send.call_args.kwargs["headers"]["X-Platform-App"]
17
+ fields = dict(urllib.parse.parse_qsl(header))
18
+ self.assertEqual(fields["server"], "api wörker")
19
+ self.assertEqual(fields["kind"], "process")
20
+ self.assertEqual(fields["pid"], str(os.getpid()))
21
+ self.assertTrue(fields["runtime"].startswith("Python "))
22
+ self.assertTrue(header.isascii())
23
+
24
+ def test_default_name_is_the_machines(self):
25
+ self.assertTrue(Core("a" * 24, "s", "http://collector").server_name)
26
+
27
+
28
+ if __name__ == "__main__":
29
+ unittest.main()
@@ -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()