crumbtrail-python 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Crumbtrail
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: crumbtrail-python
3
+ Version: 0.1.0
4
+ Summary: Maintained Crumbtrail request and database capture for Python services
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest<9,>=8; extra == "test"
11
+ Requires-Dist: Flask<4,>=3; extra == "test"
12
+ Requires-Dist: fastapi<1,>=0.115; extra == "test"
13
+ Requires-Dist: httpx<1,>=0.27; extra == "test"
14
+ Requires-Dist: Django<5,>=4.2; extra == "test"
15
+ Requires-Dist: SQLAlchemy<3,>=2; extra == "test"
16
+ Requires-Dist: build<2,>=1; extra == "test"
17
+ Dynamic: license-file
18
+
19
+ # Capture Python backend evidence
20
+
21
+ Requires Python 3.9 or later. Package version 0.1.0 is source available and pending publication. Build and install the wheel locally until a release is available:
22
+
23
+ ```sh
24
+ python -m pip install build
25
+ python -m build packages/python
26
+ python -m pip install packages/python/dist/crumbtrail_python-0.1.0-py3-none-any.whl
27
+ ```
28
+
29
+ Configure `CRUMBTRAIL_ENDPOINT` with the HTTPS Crumbtrail origin and `CRUMBTRAIL_INGEST_KEY` with the project's backend ingest key. A loopback origin such as `http://localhost:19890` may use plain HTTP so the package can point at a local stack. Every other origin must be HTTPS.
30
+
31
+ When the configuration is missing or unusable the client does not raise. It logs one warning on the `crumbtrail` logger, disables capture, and returns the application unchanged, so a client built at module scope cannot crash the host at import.
32
+
33
+ A client may be built before the server forks its workers. The package rebuilds its queue, its worker thread and its locks in each child, so `gunicorn --preload`, uwsgi with a master process, and a client created at module scope in `wsgi.py` all capture normally. Evidence produced in a process the sender was never rebuilt for is dropped, and the first such drop logs a warning.
34
+
35
+ Capture is disabled for every route unless `should_capture` explicitly selects it. Requests must include valid `x-crumbtrail-session-id` and `x-crumbtrail-request-id` headers from an existing browser session.
36
+
37
+ ```python
38
+ from crumbtrail import Client
39
+
40
+ client = Client(
41
+ service="orders-api",
42
+ should_capture=lambda path: path.startswith("/api/") and not path.startswith("/api/auth"),
43
+ )
44
+ ```
45
+
46
+ ## Flask
47
+
48
+ Register before serving requests:
49
+
50
+ ```python
51
+ from flask import Flask
52
+ from crumbtrail.flask import install
53
+
54
+ app = Flask(__name__)
55
+ install(app, client)
56
+ ```
57
+
58
+ ## FastAPI and other ASGI applications
59
+
60
+ ```python
61
+ from fastapi import FastAPI
62
+ from crumbtrail import ASGIMiddleware
63
+
64
+ app = FastAPI()
65
+ app.add_middleware(ASGIMiddleware, client=client)
66
+ ```
67
+
68
+ Non HTTP scopes pass through. FastAPI route templates are retained. Generic ASGI integrations without a route object report `/` to avoid exporting path parameters. SQLAlchemy instrumentation also works with FastAPI synchronous handlers because context is propagated to its thread pool.
69
+
70
+ ## Django WSGI
71
+
72
+ In `wsgi.py`, wrap the application returned by Django:
73
+
74
+ ```python
75
+ from django.core.wsgi import get_wsgi_application
76
+ from crumbtrail.django import wrap_wsgi
77
+
78
+ application = wrap_wsgi(get_wsgi_application(), client)
79
+ ```
80
+
81
+ The wrapper captures queries on configured Django database connections throughout response iteration. This Django database adapter supports synchronous WSGI. For Django ASGI, the generic `ASGIMiddleware` provides request evidence but does not instrument Django's separate synchronous database threads.
82
+
83
+ ## SQLAlchemy
84
+
85
+ Register once on an engine. For an asynchronous engine, pass its `sync_engine`:
86
+
87
+ ```python
88
+ from sqlalchemy import create_engine
89
+ from crumbtrail.database import instrument_sqlalchemy
90
+
91
+ engine = create_engine("sqlite://")
92
+ uninstall = instrument_sqlalchemy(engine)
93
+ ```
94
+
95
+ The maintained adapter records query operation, duration, row count when available, and error type. It never captures SQL text, parameters, row values, connection strings or exception messages. `shape` is explicitly `[statement omitted]`, and `rowEvidence` is `not_captured`. Row diffs and transaction state capture are not implemented. Call `uninstall()` when disposing instrumentation.
96
+
97
+ ## Verify and shut down
98
+
99
+ Exercise an eligible JSON route with browser correlation headers. The existing session should contain `backend.req.start`, `backend.req.end` and any instrumented `db.statement` or `db.error` events. This package appends to existing sessions. It does not create browser sessions.
100
+
101
+ Call `client.close(timeout=5)` from the server's worker shutdown hook. It stops accepting evidence and waits up to five seconds for queued delivery. It returns `False` if work remains. ASGI lifecycle scopes pass through, so register your shutdown hook explicitly. A hook registered with `atexit` flushes queued evidence for up to two seconds at interpreter exit, which covers an ordinary shutdown but not a signal that kills the process outright.
102
+
103
+ The background worker uses a queue of 64 batches, each containing at most 20 events. A request retains at most 198 intermediate events and emits a capture gap when that limit is exceeded. Delivery retries 429, server failures and network failures up to four attempts with five second request timeouts, and honours a `Retry-After` header on 429 up to 30 seconds. A 404 means the session does not belong to this ingest key, which no retry changes, so it is not retried. Redirects are disabled. Other rejection statuses are not retried. `client.sink.dropped` and `client.sink.failed` report overflow or failed delivery counts when using the default sender. Capture failures do not change application response bytes or exceptions, including when an application breaks the WSGI contract by passing a malformed status line or yielding `str` instead of `bytes`. A failure after response transmission begins records `backend.req.error` and retains the emitted HTTP status.
104
+
105
+ JSON request and response bodies retain at most 16 KiB each. The conservative profile exports numbers, booleans, null, short lowercase enums and three letter uppercase units. It also exports object key names verbatim. Read that plainly: a numeric value under a key the profile does not recognise as sensitive leaves your process, so `{"salary":185000}` is exported in full, and so is every key name in the body. Values under keys matching the sensitive list, including location keys such as `lat`, `lng` and `latitude`, are replaced with `[REDACTED]`, as is every other string.
106
+
107
+ Object keys must match `[a-zA-Z_][a-zA-Z0-9_.-]{0,63}`, so hyphens and dots are accepted. Duplicate keys, keys outside that pattern, more than 64 keys in one object, depth beyond 8 and arrays longer than 40 elements are not exported. Bodies have explicit `captured`, `redacted`, `missing`, `invalid` or `truncated` states. A body the profile will not export reports `invalid` whether the JSON was malformed or merely an unsupported shape, because the analysis pipeline accepts no other state. The `crumbtrail` logger records which of the two it was at debug level. Bodies the application does not fully read are marked truncated. Request capture never pre reads a stream. Response streaming remains incremental. A JSON request or response whose observed byte count differs from its declared `Content-Length` is marked truncated even if its stream ends normally. Malformed length declarations also prevent claiming complete body evidence. HEAD responses and statuses that do not carry a response body report missing body evidence. Query strings, headers, raw path parameters and exception messages are not exported.
108
+
109
+ ## Diagnostics
110
+
111
+ The package logs on the `crumbtrail` logger and never raises into the host application. Failures inside capture are logged at debug. Capture being disabled, a batch dropped after every delivery attempt, an ingest rejection, and evidence produced in a process the sender was not rebuilt for are logged at warning.
112
+
113
+ ```python
114
+ import logging
115
+
116
+ logging.getLogger("crumbtrail").setLevel(logging.DEBUG)
117
+ ```
118
+
119
+ ## Run package tests
120
+
121
+ ```sh
122
+ python -m pip install -e 'packages/python[test]'
123
+ python -m pytest packages/python/tests
124
+ ```
@@ -0,0 +1,106 @@
1
+ # Capture Python backend evidence
2
+
3
+ Requires Python 3.9 or later. Package version 0.1.0 is source available and pending publication. Build and install the wheel locally until a release is available:
4
+
5
+ ```sh
6
+ python -m pip install build
7
+ python -m build packages/python
8
+ python -m pip install packages/python/dist/crumbtrail_python-0.1.0-py3-none-any.whl
9
+ ```
10
+
11
+ Configure `CRUMBTRAIL_ENDPOINT` with the HTTPS Crumbtrail origin and `CRUMBTRAIL_INGEST_KEY` with the project's backend ingest key. A loopback origin such as `http://localhost:19890` may use plain HTTP so the package can point at a local stack. Every other origin must be HTTPS.
12
+
13
+ When the configuration is missing or unusable the client does not raise. It logs one warning on the `crumbtrail` logger, disables capture, and returns the application unchanged, so a client built at module scope cannot crash the host at import.
14
+
15
+ A client may be built before the server forks its workers. The package rebuilds its queue, its worker thread and its locks in each child, so `gunicorn --preload`, uwsgi with a master process, and a client created at module scope in `wsgi.py` all capture normally. Evidence produced in a process the sender was never rebuilt for is dropped, and the first such drop logs a warning.
16
+
17
+ Capture is disabled for every route unless `should_capture` explicitly selects it. Requests must include valid `x-crumbtrail-session-id` and `x-crumbtrail-request-id` headers from an existing browser session.
18
+
19
+ ```python
20
+ from crumbtrail import Client
21
+
22
+ client = Client(
23
+ service="orders-api",
24
+ should_capture=lambda path: path.startswith("/api/") and not path.startswith("/api/auth"),
25
+ )
26
+ ```
27
+
28
+ ## Flask
29
+
30
+ Register before serving requests:
31
+
32
+ ```python
33
+ from flask import Flask
34
+ from crumbtrail.flask import install
35
+
36
+ app = Flask(__name__)
37
+ install(app, client)
38
+ ```
39
+
40
+ ## FastAPI and other ASGI applications
41
+
42
+ ```python
43
+ from fastapi import FastAPI
44
+ from crumbtrail import ASGIMiddleware
45
+
46
+ app = FastAPI()
47
+ app.add_middleware(ASGIMiddleware, client=client)
48
+ ```
49
+
50
+ Non HTTP scopes pass through. FastAPI route templates are retained. Generic ASGI integrations without a route object report `/` to avoid exporting path parameters. SQLAlchemy instrumentation also works with FastAPI synchronous handlers because context is propagated to its thread pool.
51
+
52
+ ## Django WSGI
53
+
54
+ In `wsgi.py`, wrap the application returned by Django:
55
+
56
+ ```python
57
+ from django.core.wsgi import get_wsgi_application
58
+ from crumbtrail.django import wrap_wsgi
59
+
60
+ application = wrap_wsgi(get_wsgi_application(), client)
61
+ ```
62
+
63
+ The wrapper captures queries on configured Django database connections throughout response iteration. This Django database adapter supports synchronous WSGI. For Django ASGI, the generic `ASGIMiddleware` provides request evidence but does not instrument Django's separate synchronous database threads.
64
+
65
+ ## SQLAlchemy
66
+
67
+ Register once on an engine. For an asynchronous engine, pass its `sync_engine`:
68
+
69
+ ```python
70
+ from sqlalchemy import create_engine
71
+ from crumbtrail.database import instrument_sqlalchemy
72
+
73
+ engine = create_engine("sqlite://")
74
+ uninstall = instrument_sqlalchemy(engine)
75
+ ```
76
+
77
+ The maintained adapter records query operation, duration, row count when available, and error type. It never captures SQL text, parameters, row values, connection strings or exception messages. `shape` is explicitly `[statement omitted]`, and `rowEvidence` is `not_captured`. Row diffs and transaction state capture are not implemented. Call `uninstall()` when disposing instrumentation.
78
+
79
+ ## Verify and shut down
80
+
81
+ Exercise an eligible JSON route with browser correlation headers. The existing session should contain `backend.req.start`, `backend.req.end` and any instrumented `db.statement` or `db.error` events. This package appends to existing sessions. It does not create browser sessions.
82
+
83
+ Call `client.close(timeout=5)` from the server's worker shutdown hook. It stops accepting evidence and waits up to five seconds for queued delivery. It returns `False` if work remains. ASGI lifecycle scopes pass through, so register your shutdown hook explicitly. A hook registered with `atexit` flushes queued evidence for up to two seconds at interpreter exit, which covers an ordinary shutdown but not a signal that kills the process outright.
84
+
85
+ The background worker uses a queue of 64 batches, each containing at most 20 events. A request retains at most 198 intermediate events and emits a capture gap when that limit is exceeded. Delivery retries 429, server failures and network failures up to four attempts with five second request timeouts, and honours a `Retry-After` header on 429 up to 30 seconds. A 404 means the session does not belong to this ingest key, which no retry changes, so it is not retried. Redirects are disabled. Other rejection statuses are not retried. `client.sink.dropped` and `client.sink.failed` report overflow or failed delivery counts when using the default sender. Capture failures do not change application response bytes or exceptions, including when an application breaks the WSGI contract by passing a malformed status line or yielding `str` instead of `bytes`. A failure after response transmission begins records `backend.req.error` and retains the emitted HTTP status.
86
+
87
+ JSON request and response bodies retain at most 16 KiB each. The conservative profile exports numbers, booleans, null, short lowercase enums and three letter uppercase units. It also exports object key names verbatim. Read that plainly: a numeric value under a key the profile does not recognise as sensitive leaves your process, so `{"salary":185000}` is exported in full, and so is every key name in the body. Values under keys matching the sensitive list, including location keys such as `lat`, `lng` and `latitude`, are replaced with `[REDACTED]`, as is every other string.
88
+
89
+ Object keys must match `[a-zA-Z_][a-zA-Z0-9_.-]{0,63}`, so hyphens and dots are accepted. Duplicate keys, keys outside that pattern, more than 64 keys in one object, depth beyond 8 and arrays longer than 40 elements are not exported. Bodies have explicit `captured`, `redacted`, `missing`, `invalid` or `truncated` states. A body the profile will not export reports `invalid` whether the JSON was malformed or merely an unsupported shape, because the analysis pipeline accepts no other state. The `crumbtrail` logger records which of the two it was at debug level. Bodies the application does not fully read are marked truncated. Request capture never pre reads a stream. Response streaming remains incremental. A JSON request or response whose observed byte count differs from its declared `Content-Length` is marked truncated even if its stream ends normally. Malformed length declarations also prevent claiming complete body evidence. HEAD responses and statuses that do not carry a response body report missing body evidence. Query strings, headers, raw path parameters and exception messages are not exported.
90
+
91
+ ## Diagnostics
92
+
93
+ The package logs on the `crumbtrail` logger and never raises into the host application. Failures inside capture are logged at debug. Capture being disabled, a batch dropped after every delivery attempt, an ingest rejection, and evidence produced in a process the sender was not rebuilt for are logged at warning.
94
+
95
+ ```python
96
+ import logging
97
+
98
+ logging.getLogger("crumbtrail").setLevel(logging.DEBUG)
99
+ ```
100
+
101
+ ## Run package tests
102
+
103
+ ```sh
104
+ python -m pip install -e 'packages/python[test]'
105
+ python -m pytest packages/python/tests
106
+ ```
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "crumbtrail-python"
7
+ version = "0.1.0"
8
+ description = "Maintained Crumbtrail request and database capture for Python services"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+
14
+ [project.optional-dependencies]
15
+ test = ["pytest>=8,<9", "Flask>=3,<4", "fastapi>=0.115,<1", "httpx>=0.27,<1", "Django>=4.2,<5", "SQLAlchemy>=2,<3", "build>=1,<2"]
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .core import Client, Sender
2
+ from .middleware import ASGIMiddleware, WSGIMiddleware
3
+
4
+ __all__ = ["Client", "Sender", "ASGIMiddleware", "WSGIMiddleware"]
@@ -0,0 +1,373 @@
1
+ """Request scoped evidence and bounded asynchronous HTTP delivery."""
2
+ import atexit
3
+ import contextvars
4
+ import datetime
5
+ import email.utils
6
+ import ipaddress
7
+ import json
8
+ import logging
9
+ import os
10
+ import queue
11
+ import re
12
+ import threading
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ import weakref
18
+
19
+ from .privacy import capture_body, is_json, redaction, MAX_BYTES
20
+
21
+ log = logging.getLogger("crumbtrail")
22
+
23
+ current_capture = contextvars.ContextVar("crumbtrail_capture", default=None)
24
+ _ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
25
+ _SECONDS = re.compile(r"[0-9]{1,5}")
26
+ _MAX_RETRY_AFTER = 30.0
27
+
28
+
29
+ def now():
30
+ return int(time.time() * 1000)
31
+
32
+
33
+ def _loopback(hostname):
34
+ """Local stacks are served over plain HTTP, so exempt loopback from the HTTPS rule."""
35
+ if not hostname:
36
+ return False
37
+ host = hostname.lower().strip("[]")
38
+ if host == "localhost" or host.endswith(".localhost"):
39
+ return True
40
+ try:
41
+ return ipaddress.ip_address(host).is_loopback
42
+ except ValueError:
43
+ return False
44
+
45
+
46
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
47
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
48
+ return None
49
+
50
+
51
+ # Senders are tracked weakly so the fork and exit hooks below can reach every live
52
+ # sender without keeping a dead one alive. The lock is taken before every fork and
53
+ # released on both sides, otherwise a child could inherit it already held.
54
+ _registry_lock = threading.Lock()
55
+ _registry = []
56
+
57
+
58
+ def _register(sender):
59
+ with _registry_lock:
60
+ _registry[:] = [ref for ref in _registry if ref() is not None]
61
+ _registry.append(weakref.ref(sender))
62
+
63
+
64
+ def _live_senders():
65
+ with _registry_lock:
66
+ refs = list(_registry)
67
+ return [sender for sender in (ref() for ref in refs) if sender is not None]
68
+
69
+
70
+ def _before_fork():
71
+ _registry_lock.acquire()
72
+
73
+
74
+ def _after_fork_parent():
75
+ _registry_lock.release()
76
+
77
+
78
+ def _after_fork_child():
79
+ try:
80
+ senders = [ref() for ref in _registry]
81
+ finally:
82
+ _registry_lock.release()
83
+ for sender in senders:
84
+ if sender is not None:
85
+ sender._adopt_fork()
86
+
87
+
88
+ def _flush_at_exit():
89
+ for sender in _live_senders():
90
+ try:
91
+ sender.close(2)
92
+ except Exception:
93
+ log.debug("Crumbtrail: flush at interpreter exit failed", exc_info=True)
94
+
95
+
96
+ if hasattr(os, "register_at_fork"):
97
+ os.register_at_fork(before=_before_fork, after_in_parent=_after_fork_parent, after_in_child=_after_fork_child)
98
+ atexit.register(_flush_at_exit)
99
+
100
+
101
+ class Sender:
102
+ """One worker per process. Rebuilt automatically in a forked child; close on shutdown."""
103
+ def __init__(self, endpoint, key):
104
+ parsed = urllib.parse.urlsplit(endpoint)
105
+ if parsed.scheme not in ("https", "http") or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
106
+ raise ValueError("Crumbtrail endpoint must be HTTPS without credentials, query or fragment")
107
+ if parsed.scheme == "http" and not _loopback(parsed.hostname):
108
+ raise ValueError("Crumbtrail endpoint must be HTTPS unless it is a loopback address")
109
+ if not key or any(ord(c) < 32 or ord(c) > 126 for c in key):
110
+ raise ValueError("Crumbtrail ingest key must be nonempty printable ASCII")
111
+ self.url = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "/api/events", "", ""))
112
+ self.key = key
113
+ self.dropped = 0
114
+ self.failed = 0
115
+ self._counters = threading.Lock()
116
+ self._closed = False
117
+ self._warned_fork = False
118
+ self.queue = queue.Queue(maxsize=64)
119
+ self._lock = threading.Lock()
120
+ self._worker = None
121
+ self._pid = os.getpid()
122
+ self._http = urllib.request.build_opener(_NoRedirect())
123
+ _register(self)
124
+
125
+ def _adopt_fork(self):
126
+ """Runs single threaded in the child, so state is replaced by assignment.
127
+
128
+ Only this thread survives fork, so the queue, the worker and both locks are
129
+ rebuilt. `_closed` is inherited on purpose: a sender the parent shut down
130
+ stays shut down.
131
+ """
132
+ self.queue = queue.Queue(maxsize=64)
133
+ self._lock = threading.Lock()
134
+ self._counters = threading.Lock()
135
+ self._worker = None
136
+ self._pid = os.getpid()
137
+
138
+ def _count_drop(self):
139
+ with self._counters:
140
+ self.dropped += 1
141
+
142
+ def _count_failure(self):
143
+ with self._counters:
144
+ self.failed += 1
145
+
146
+ def enqueue(self, batch):
147
+ if os.getpid() != self._pid:
148
+ self._count_drop()
149
+ if not self._warned_fork:
150
+ self._warned_fork = True
151
+ log.warning("Crumbtrail: dropping evidence in process %d from a sender built in process %d; build the client after the worker forks", os.getpid(), self._pid)
152
+ return
153
+ with self._lock:
154
+ if self._closed:
155
+ self._count_drop()
156
+ return
157
+ if self._worker is None:
158
+ self._worker = threading.Thread(target=self._run, name="crumbtrail-delivery", daemon=True)
159
+ self._worker.start()
160
+ try:
161
+ self.queue.put_nowait(batch)
162
+ except queue.Full:
163
+ self._count_drop()
164
+ log.debug("Crumbtrail: delivery queue is full, dropping a batch")
165
+
166
+ @staticmethod
167
+ def _retryable(status):
168
+ # A 404 means the session is not one this key owns, which no retry changes.
169
+ return status == 429 or status >= 500
170
+
171
+ @staticmethod
172
+ def _retry_after(headers, status):
173
+ if status != 429 or headers is None:
174
+ return None
175
+ try:
176
+ value = headers.get("Retry-After")
177
+ except Exception:
178
+ return None
179
+ if value is None:
180
+ return None
181
+ value = str(value).strip()
182
+ if _SECONDS.fullmatch(value):
183
+ return min(float(value), _MAX_RETRY_AFTER)
184
+ try:
185
+ stamp = email.utils.parsedate_to_datetime(value)
186
+ except (TypeError, ValueError, IndexError):
187
+ return None
188
+ if stamp is None:
189
+ return None
190
+ if stamp.tzinfo is None:
191
+ stamp = stamp.replace(tzinfo=datetime.timezone.utc)
192
+ seconds = (stamp - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
193
+ return max(0.0, min(seconds, _MAX_RETRY_AFTER))
194
+
195
+ def _send(self, batch):
196
+ body = json.dumps(batch, allow_nan=False).encode()
197
+ for attempt in range(4):
198
+ request = urllib.request.Request(self.url, data=body, headers={"Authorization": "Bearer " + self.key, "Content-Type": "application/json"}, method="POST")
199
+ delay = None
200
+ try:
201
+ with self._http.open(request, timeout=5) as response:
202
+ if response.status == 200:
203
+ return True
204
+ if not self._retryable(response.status):
205
+ log.warning("Crumbtrail: ingest rejected a batch with status %s", response.status)
206
+ return False
207
+ delay = self._retry_after(getattr(response, "headers", None), response.status)
208
+ except urllib.error.HTTPError as error:
209
+ code, headers = error.code, error.headers
210
+ error.close()
211
+ if not self._retryable(code):
212
+ log.warning("Crumbtrail: ingest rejected a batch with status %s", code)
213
+ return False
214
+ delay = self._retry_after(headers, code)
215
+ except (OSError, urllib.error.URLError) as failure:
216
+ log.debug("Crumbtrail: delivery attempt %d failed: %r", attempt + 1, failure)
217
+ if attempt < 3:
218
+ time.sleep(0.25 * (attempt + 1) if delay is None else delay)
219
+ log.warning("Crumbtrail: dropping %d events after 4 delivery attempts", len(batch.get("events") or []) if isinstance(batch, dict) else 0)
220
+ return False
221
+
222
+ def _run(self):
223
+ while True:
224
+ try:
225
+ batch = self.queue.get(timeout=0.1)
226
+ except queue.Empty:
227
+ if self._closed:
228
+ return
229
+ continue
230
+ try:
231
+ if not self._send(batch):
232
+ self._count_failure()
233
+ except Exception:
234
+ self._count_failure()
235
+ log.debug("Crumbtrail: delivery worker raised", exc_info=True)
236
+ finally:
237
+ self.queue.task_done()
238
+
239
+ def close(self, timeout=5):
240
+ """Stop accepting evidence and wait at most timeout seconds for queued delivery."""
241
+ if os.getpid() != self._pid:
242
+ return False
243
+ with self._lock:
244
+ self._closed = True
245
+ if self._worker:
246
+ self._worker.join(max(0, timeout))
247
+ return self.queue.unfinished_tasks == 0
248
+
249
+
250
+ class _Disabled:
251
+ """Sink used when configuration is unusable. Capture stays off and nothing is sent."""
252
+ def __init__(self):
253
+ self.dropped = 0
254
+ self.failed = 0
255
+
256
+ def enqueue(self, batch):
257
+ self.dropped += 1
258
+
259
+ def close(self, timeout=5):
260
+ return True
261
+
262
+
263
+ def _build_sender(endpoint, key):
264
+ try:
265
+ return Sender(endpoint, key)
266
+ except ValueError as reason:
267
+ log.warning("Crumbtrail: capture is disabled, %s", reason)
268
+ return _Disabled()
269
+
270
+
271
+ class Capture:
272
+ def __init__(self, session, request, service, method):
273
+ self.session, self.request, self.service, self.method = session, request, service, method
274
+ self.started = now()
275
+ self.clock = time.monotonic()
276
+ self.events = []
277
+ self.dropped = 0
278
+ self.request_bytes = bytearray()
279
+ self.response_bytes = bytearray()
280
+ self.request_complete = False
281
+ self.request_count = 0
282
+ self.request_length = None
283
+ self.response_complete = False
284
+ self.status = 500
285
+ self.response_started = False
286
+ self.response_type = ""
287
+ self.response_count = 0
288
+ self.response_length = None
289
+ self.sequence = 0
290
+
291
+ def keep(self, target, chunk):
292
+ remaining = MAX_BYTES + 1 - len(target)
293
+ if remaining > 0:
294
+ target.extend(chunk[:remaining])
295
+
296
+ @staticmethod
297
+ def content_length(headers):
298
+ lengths = [v for k, v in headers if k.lower() == "content-length"]
299
+ if not lengths:
300
+ return None
301
+ value = lengths[0].strip()
302
+ return int(value) if len(lengths) == 1 and re.fullmatch(r"[0-9]{1,20}", value) else -1
303
+
304
+ def keep_request(self, chunk):
305
+ self.request_count += len(chunk)
306
+ self.keep(self.request_bytes, chunk)
307
+
308
+ def request_body(self, content_type):
309
+ if not is_json(content_type):
310
+ return None, "missing"
311
+ complete = self.request_complete and (self.request_length is None or self.request_count == self.request_length)
312
+ return capture_body(self.request_bytes, not complete)
313
+
314
+ def response_headers(self, headers):
315
+ self.response_type = next((v for k, v in headers if k.lower() == "content-type"), "")
316
+ self.response_length = self.content_length(headers)
317
+
318
+ def keep_response(self, chunk):
319
+ self.response_count += len(chunk)
320
+ self.keep(self.response_bytes, chunk)
321
+
322
+ def response_body(self):
323
+ no_body = self.method.upper() == "HEAD" or 100 <= self.status < 200 or self.status in (204, 205, 304) or (self.method.upper() == "CONNECT" and 200 <= self.status < 300)
324
+ if no_body or not is_json(self.response_type):
325
+ return None, "missing"
326
+ complete = self.response_complete and (self.response_length is None or self.response_count == self.response_length)
327
+ return capture_body(self.response_bytes, not complete)
328
+
329
+ def add(self, kind, data):
330
+ if len(self.events) < 198:
331
+ self.events.append({"t": now(), "k": kind, "d": data})
332
+ else:
333
+ self.dropped += 1
334
+
335
+ def finish(self, sink, request_type, route="/", error=None):
336
+ try:
337
+ correlation = {"status": "linked", "sessionIdSource": "header", "requestIdSource": "header"}
338
+ common = {"requestId": self.request, "sessionId": self.session, "method": self.method, "url": route, "pathname": route, "route": route, "service": self.service, "correlation": correlation}
339
+ body, state = self.request_body(request_type)
340
+ start = {"t": self.started, "k": "backend.req.start", "d": dict(common, body=body, requestBodyState=state, redaction=redaction("body", state))}
341
+ if error:
342
+ self.add("backend.req.error", dict(common, error={"name": type(error).__name__}))
343
+ body, state = self.response_body()
344
+ end = {"t": now(), "k": "backend.req.end", "d": dict(common, statusCode=self.status if error is None or self.response_started else 500, durationMs=(time.monotonic() - self.clock) * 1000, responseBody=body, responseBodyState=state, responseBodyTruncated=state == "truncated", redaction=redaction("responseBody", state))}
345
+ events = [start] + self.events + [end]
346
+ if self.dropped:
347
+ events.append({"t": now(), "k": "capture_gap", "d": {"kind": "capture_gap", "surface": "backend_request", "reason": "scan_budget_exceeded", "requestId": self.request, "detail": "Event limit reached", "droppedEvents": self.dropped}})
348
+ for i in range(0, len(events), 20):
349
+ sink.enqueue({"sessionId": self.session, "events": events[i:i + 20]})
350
+ except Exception:
351
+ log.debug("Crumbtrail: could not assemble request evidence", exc_info=True)
352
+
353
+
354
+ class Client:
355
+ def __init__(self, *, service="python", should_capture=None, endpoint=None, key=None, sink=None):
356
+ self.service = service
357
+ self.should_capture = should_capture or (lambda path: False)
358
+ self.sink = sink if sink is not None else _build_sender(endpoint or os.environ.get("CRUMBTRAIL_ENDPOINT", ""), key or os.environ.get("CRUMBTRAIL_INGEST_KEY", ""))
359
+ self.enabled = not isinstance(self.sink, _Disabled)
360
+
361
+ def begin(self, path, method, session, request):
362
+ if not self.enabled:
363
+ return None
364
+ try:
365
+ if _ID.fullmatch(session) and _ID.fullmatch(request) and self.should_capture(path):
366
+ return Capture(session, request, self.service, method)
367
+ except Exception:
368
+ log.debug("Crumbtrail: capture eligibility check raised", exc_info=True)
369
+ return None
370
+
371
+ def close(self, timeout=5):
372
+ close = getattr(self.sink, "close", None)
373
+ return close(timeout) if close else True