crumbtrail-python 0.1.0__py3-none-any.whl

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.
crumbtrail/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .core import Client, Sender
2
+ from .middleware import ASGIMiddleware, WSGIMiddleware
3
+
4
+ __all__ = ["Client", "Sender", "ASGIMiddleware", "WSGIMiddleware"]
crumbtrail/core.py ADDED
@@ -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
crumbtrail/database.py ADDED
@@ -0,0 +1,73 @@
1
+ """Query metadata without SQL text, parameters, result rows or exception messages."""
2
+ import logging
3
+ import re
4
+ import time
5
+ from .core import current_capture, now
6
+
7
+ log = logging.getLogger("crumbtrail")
8
+
9
+
10
+ def _record(capture, sql, engine, start, rows=None, error=None):
11
+ if capture is None:
12
+ return
13
+ try:
14
+ # Only a leading SQL operation is retained. Arbitrary dialect literals cannot leak.
15
+ first = re.match(r"\s*(select|insert|update|delete)\b", sql[:64], re.I)
16
+ operation = first.group(1).lower() if first else "other"
17
+ capture.sequence += 1
18
+ payload = {"engine": engine, "op": operation, "table": None, "shape": "[statement omitted]", "requestId": capture.request, "t": now(), "durationMs": (time.monotonic() - start) * 1000, "seq": capture.sequence}
19
+ if error is None:
20
+ payload.update(rowCount=rows if isinstance(rows, int) and rows >= 0 else None, rowEvidence="not_captured")
21
+ else:
22
+ payload.update(code=None, category="unknown", errorName=type(error).__name__)
23
+ capture.add("db.error" if error else "db.statement", payload)
24
+ except Exception:
25
+ log.debug("Crumbtrail: could not record a database statement", exc_info=True)
26
+
27
+
28
+ def instrument_sqlalchemy(engine):
29
+ """Register on a SQLAlchemy 2.x Engine (or AsyncEngine.sync_engine). Returns uninstall."""
30
+ from sqlalchemy import event
31
+ if getattr(engine, "_crumbtrail_uninstall", None):
32
+ return engine._crumbtrail_uninstall
33
+ engine_name = {"postgresql": "postgres", "sqlite": "sqlite", "mysql": "mysql"}.get(engine.dialect.name, "unknown")
34
+
35
+ def before(conn, cursor, statement, parameters, context, executemany):
36
+ context._crumbtrail = (current_capture.get(), time.monotonic())
37
+
38
+ def after(conn, cursor, statement, parameters, context, executemany):
39
+ capture, start = getattr(context, "_crumbtrail", (None, 0))
40
+ _record(capture, statement, engine_name, start, cursor.rowcount)
41
+
42
+ def error(context):
43
+ execution = context.execution_context
44
+ capture, start = getattr(execution, "_crumbtrail", (None, 0))
45
+ _record(capture, context.statement or "", engine_name, start, error=context.original_exception)
46
+
47
+ listeners = [("before_cursor_execute", before), ("after_cursor_execute", after), ("handle_error", error)]
48
+ for name, fn in listeners:
49
+ event.listen(engine, name, fn)
50
+
51
+ def uninstall():
52
+ for name, fn in listeners:
53
+ if event.contains(engine, name, fn):
54
+ event.remove(engine, name, fn)
55
+ if getattr(engine, "_crumbtrail_uninstall", None) is uninstall:
56
+ del engine._crumbtrail_uninstall
57
+ engine._crumbtrail_uninstall = uninstall
58
+ return uninstall
59
+
60
+
61
+ def django_execute(execute, sql, params, many, context):
62
+ capture = current_capture.get()
63
+ started = time.monotonic()
64
+ error = None
65
+ try:
66
+ return execute(sql, params, many, context)
67
+ except BaseException as failure:
68
+ error = failure
69
+ raise
70
+ finally:
71
+ vendor = context["connection"].vendor
72
+ engine = {"postgresql": "postgres", "sqlite": "sqlite", "mysql": "mysql"}.get(vendor, "unknown")
73
+ _record(capture, sql, engine, started, getattr(context["cursor"], "rowcount", None), error)
crumbtrail/django.py ADDED
@@ -0,0 +1,31 @@
1
+ """Django synchronous WSGI integration with request scoped database wrappers."""
2
+ from contextlib import ExitStack
3
+ from .database import django_execute
4
+ from .middleware import WSGIMiddleware
5
+
6
+
7
+ def wrap_wsgi(application, client):
8
+ """Wrap get_wsgi_application() in wsgi.py; queries captured through response iteration."""
9
+ from django.db import connections
10
+
11
+ def app(environ, start_response):
12
+ def response():
13
+ with ExitStack() as stack:
14
+ for connection in connections.all():
15
+ stack.enter_context(connection.execute_wrapper(django_execute))
16
+ iterable = application(environ, start_response)
17
+ try:
18
+ yield from iterable
19
+ finally:
20
+ close = getattr(iterable, "close", None)
21
+ if close:
22
+ close()
23
+ return response()
24
+
25
+ def route(environ):
26
+ from django.urls import resolve, Resolver404
27
+ try:
28
+ return "/" + resolve(environ.get("PATH_INFO", "/")).route
29
+ except Resolver404:
30
+ return "/"
31
+ return WSGIMiddleware(app, client, route)
crumbtrail/flask.py ADDED
@@ -0,0 +1,16 @@
1
+ from .middleware import WSGIMiddleware
2
+
3
+
4
+ def install(app, client):
5
+ """Register once, before Flask handles its first request."""
6
+ if "crumbtrail" in app.extensions:
7
+ return app.extensions["crumbtrail"]
8
+ from flask import request
9
+
10
+ @app.before_request
11
+ def route_template():
12
+ request.environ["crumbtrail.route"] = request.url_rule.rule if request.url_rule else "/"
13
+
14
+ app.wsgi_app = WSGIMiddleware(app.wsgi_app, client)
15
+ app.extensions["crumbtrail"] = client
16
+ return client
@@ -0,0 +1,221 @@
1
+ """Transparent stream tees: capture never pre-reads or consumes application input."""
2
+ import logging
3
+
4
+ from .core import current_capture
5
+
6
+ log = logging.getLogger("crumbtrail")
7
+
8
+
9
+ class _Input:
10
+ def __init__(self, stream, capture, length):
11
+ self._stream, self._capture, self._length = stream, capture, length
12
+ self._count = 0
13
+
14
+ def _keep(self, data, eof=False):
15
+ self._capture.keep_request(data)
16
+ self._count += len(data)
17
+ if eof or (self._length is not None and self._count >= self._length):
18
+ self._capture.request_complete = True
19
+ return data
20
+
21
+ def read(self, size=-1):
22
+ value = self._stream.read(size)
23
+ return self._keep(value, size < 0 or (size != 0 and not value))
24
+
25
+ def readline(self, size=-1):
26
+ value = self._stream.readline(size)
27
+ return self._keep(value, size != 0 and not value)
28
+
29
+ def readlines(self, hint=-1):
30
+ lines = []
31
+ count = 0
32
+ while True:
33
+ line = self.readline()
34
+ if not line:
35
+ break
36
+ lines.append(line)
37
+ count += len(line)
38
+ if hint > 0 and count >= hint:
39
+ break
40
+ return lines
41
+
42
+ def readinto(self, buffer):
43
+ value = self.read(len(buffer))
44
+ buffer[:len(value)] = value
45
+ return len(value)
46
+
47
+ def __iter__(self):
48
+ return self
49
+
50
+ def __next__(self):
51
+ line = self.readline()
52
+ if not line:
53
+ raise StopIteration
54
+ return line
55
+
56
+ def __getattr__(self, name):
57
+ return getattr(self._stream, name)
58
+
59
+
60
+ class WSGIMiddleware:
61
+ def __init__(self, app, client, route=None):
62
+ self.app, self.client = app, client
63
+ self.route = route or (lambda environ: environ.get("crumbtrail.route", "/"))
64
+
65
+ def __call__(self, environ, start_response):
66
+ capture = self.client.begin(environ.get("PATH_INFO", "/"), environ.get("REQUEST_METHOD", "GET"), environ.get("HTTP_X_CRUMBTRAIL_SESSION_ID", ""), environ.get("HTTP_X_CRUMBTRAIL_REQUEST_ID", ""))
67
+ if capture is None:
68
+ return self.app(environ, start_response)
69
+ declared = environ.get("CONTENT_LENGTH")
70
+ capture.request_length = capture.content_length([("content-length", declared)] if declared not in (None, "") else [])
71
+ length = capture.request_length
72
+ def safe_route():
73
+ try:
74
+ value = self.route(environ)
75
+ return value if isinstance(value, str) and len(value) <= 2048 else "/"
76
+ except Exception:
77
+ log.debug("Crumbtrail: route resolution raised, reporting /", exc_info=True)
78
+ return "/"
79
+
80
+ original_input = environ["wsgi.input"]
81
+ environ["wsgi.input"] = _Input(original_input, capture, length)
82
+ capture.request_complete = length == 0
83
+
84
+ def wrapped_start(status, headers, exc_info=None):
85
+ write = start_response(status, headers, exc_info)
86
+ try:
87
+ capture.status = int(str(status).split(" ", 1)[0])
88
+ capture.response_headers(headers)
89
+ except Exception:
90
+ log.debug("Crumbtrail: could not read the response status line or headers", exc_info=True)
91
+
92
+ def wrapped_write(chunk):
93
+ result = write(chunk)
94
+ capture.response_started = True
95
+ try:
96
+ capture.keep_response(chunk)
97
+ except Exception:
98
+ log.debug("Crumbtrail: could not retain a written response chunk", exc_info=True)
99
+ return result
100
+ return wrapped_write
101
+
102
+ token = current_capture.set(capture)
103
+ try:
104
+ iterable = self.app(environ, wrapped_start)
105
+ except BaseException as error:
106
+ capture.finish(self.client.sink, environ.get("CONTENT_TYPE", ""), safe_route(), error)
107
+ environ["wsgi.input"] = original_input
108
+ raise
109
+ finally:
110
+ current_capture.reset(token)
111
+
112
+ middleware = self
113
+
114
+ class Response:
115
+ def __init__(self):
116
+ self.iterator = None
117
+ self.closed = False
118
+ self.error = None
119
+
120
+ def __iter__(self):
121
+ return self
122
+
123
+ def __next__(self):
124
+ token = current_capture.set(capture)
125
+ try:
126
+ if self.iterator is None:
127
+ self.iterator = iter(iterable)
128
+ chunk = next(self.iterator)
129
+ if chunk:
130
+ capture.response_started = True
131
+ try:
132
+ capture.keep_response(chunk)
133
+ except Exception:
134
+ log.debug("Crumbtrail: could not retain a yielded response chunk", exc_info=True)
135
+ return chunk
136
+ except StopIteration:
137
+ capture.response_complete = True
138
+ self.close()
139
+ raise
140
+ except BaseException as error:
141
+ self.error = error
142
+ self.close()
143
+ raise
144
+ finally:
145
+ current_capture.reset(token)
146
+
147
+ def close(self):
148
+ if self.closed:
149
+ return
150
+ self.closed = True
151
+ token = current_capture.set(capture)
152
+ try:
153
+ close = getattr(iterable, "close", None)
154
+ if close:
155
+ close()
156
+ except BaseException as failure:
157
+ if self.error is None:
158
+ self.error = failure
159
+ raise
160
+ # Preserve the primary application exception when cleanup also fails.
161
+ finally:
162
+ try:
163
+ capture.finish(middleware.client.sink, environ.get("CONTENT_TYPE", ""), safe_route(), self.error)
164
+ finally:
165
+ environ["wsgi.input"] = original_input
166
+ current_capture.reset(token)
167
+
168
+ return Response()
169
+
170
+
171
+ class ASGIMiddleware:
172
+ def __init__(self, app, client):
173
+ self.app, self.client = app, client
174
+
175
+ async def __call__(self, scope, receive, send):
176
+ if scope["type"] != "http":
177
+ return await self.app(scope, receive, send)
178
+ headers = {}
179
+ for key, value in scope.get("headers", []):
180
+ key = key.lower()
181
+ # Duplicate identity headers must not be treated as authoritative.
182
+ headers[key] = value.decode("latin1") if key not in headers else ""
183
+ capture = self.client.begin(scope.get("path", "/"), scope.get("method", "GET"), headers.get(b"x-crumbtrail-session-id", ""), headers.get(b"x-crumbtrail-request-id", ""))
184
+ if capture is None:
185
+ return await self.app(scope, receive, send)
186
+
187
+ capture.request_length = capture.content_length([(k.decode("latin1"), v.decode("latin1")) for k, v in scope.get("headers", [])])
188
+ capture.request_complete = capture.request_length == 0
189
+
190
+ async def wrapped_receive():
191
+ message = await receive()
192
+ if message["type"] == "http.request":
193
+ capture.keep_request(message.get("body", b""))
194
+ if not message.get("more_body", False):
195
+ capture.request_complete = True
196
+ return message
197
+
198
+ async def wrapped_send(message):
199
+ await send(message)
200
+ if message["type"] == "http.response.start":
201
+ capture.status = message["status"]
202
+ capture.response_started = True
203
+ capture.response_headers([(k.decode("latin1"), v.decode("latin1")) for k, v in message.get("headers", [])])
204
+ elif message["type"] == "http.response.body":
205
+ capture.keep_response(message.get("body", b""))
206
+ if not message.get("more_body", False):
207
+ capture.response_complete = True
208
+
209
+ token = current_capture.set(capture)
210
+ error = None
211
+ try:
212
+ return await self.app(scope, wrapped_receive, wrapped_send)
213
+ except BaseException as failure:
214
+ error = failure
215
+ raise
216
+ finally:
217
+ try:
218
+ route = getattr(scope.get("route"), "path", "/")
219
+ capture.finish(self.client.sink, headers.get(b"content-type", ""), route, error)
220
+ finally:
221
+ current_capture.reset(token)
crumbtrail/privacy.py ADDED
@@ -0,0 +1,113 @@
1
+ """Bounded conservative backend structured profile; never captures free text."""
2
+ import functools
3
+ import json
4
+ import logging
5
+ import math
6
+ import re
7
+
8
+ log = logging.getLogger("crumbtrail")
9
+
10
+ MAX_BYTES = 16384
11
+ POLICY = "crumbtrail.backend-redaction.v1"
12
+ REDACTED = "[REDACTED]"
13
+ MAX_KEYS = 64
14
+ MAX_ARRAY = 40
15
+ MAX_DEPTH = 8
16
+ # Substring match against the key with separators stripped, so only terms that cannot
17
+ # appear inside an unrelated word belong here. Short location terms are matched as whole
18
+ # words by _WORD instead, otherwise "lat" would redact "latency" and "platform".
19
+ _DENIED = re.compile(r"password|passwd|passphrase|passcode|secret|token|auth|card|cvv|cvc|ssn|email|phone|address|iban|account|birth|credential|creds|cookie|session|privatekey|apikey|accesskey|securitycode|verificationcode|connection|routingnumber|taxid|nationalid|sortcode|name|postal|payload|beforejson|afterjson|latitude|longitude|geolocation|coordinate", re.I)
20
+ _WORD = re.compile(r"^(pwd|pin|pan|otp|pass|sid|dob|zip|jwt|mfa|csrf|xsrf|lat|lon|lng|geo|coord|coords)[0-9]*$", re.I)
21
+ _KEY = re.compile(r"[a-zA-Z_][a-zA-Z0-9_.-]{0,63}")
22
+ _CAMEL = re.compile(r"([a-z0-9])([A-Z])")
23
+ _STRIP = re.compile(r"[^a-zA-Z0-9]")
24
+ _SPLIT = re.compile(r"[^a-zA-Z0-9]+")
25
+ _ENUM = re.compile(r"(?:[a-z][a-z_]{0,22}|[A-Z]{3}|[0-9]{1,12})")
26
+ _DIGITS = re.compile(r"[0-9]{13,19}")
27
+
28
+
29
+ class _Unsupported(ValueError):
30
+ """Well formed JSON whose shape the conservative profile will not export."""
31
+
32
+
33
+ @functools.lru_cache(maxsize=4096)
34
+ def _sensitive(key):
35
+ words = _CAMEL.sub(r"\1 \2", key)
36
+ return bool(_DENIED.search(_STRIP.sub("", key))) or any(_WORD.fullmatch(w) for w in _SPLIT.split(words))
37
+
38
+
39
+ def _card(number):
40
+ digits = str(int(number)) if isinstance(number, float) and number.is_integer() else str(number)
41
+ if not _DIGITS.fullmatch(digits):
42
+ return False
43
+ total = 0
44
+ for i, ch in enumerate(reversed(digits)):
45
+ n = int(ch) * (2 if i % 2 else 1)
46
+ total += n - 9 if n > 9 else n
47
+ return total % 10 == 0
48
+
49
+
50
+ def _object(pairs):
51
+ result = {}
52
+ for key, value in pairs:
53
+ if key in result or len(result) >= MAX_KEYS or not _KEY.fullmatch(key):
54
+ raise _Unsupported("ambiguous or unsupported object")
55
+ result[key] = value
56
+ return result
57
+
58
+
59
+ def capture_body(raw, truncated=False):
60
+ if truncated or len(raw) > MAX_BYTES:
61
+ return None, "truncated"
62
+ if not raw:
63
+ return None, "missing"
64
+ removed = False
65
+
66
+ def walk(value, key="", depth=0, check_key=True):
67
+ nonlocal removed
68
+ if depth > MAX_DEPTH:
69
+ raise _Unsupported("depth")
70
+ if check_key and _sensitive(key):
71
+ removed = True
72
+ return REDACTED
73
+ if isinstance(value, dict):
74
+ return {k: walk(v, k, depth + 1) for k, v in value.items()}
75
+ if isinstance(value, list):
76
+ if len(value) > MAX_ARRAY:
77
+ raise _Unsupported("array size")
78
+ # The key is constant across elements and was cleared above, so skip it.
79
+ return [walk(v, key, depth + 1, False) for v in value]
80
+ if value is None or isinstance(value, bool):
81
+ return value
82
+ if isinstance(value, (float, int)):
83
+ if math.isfinite(value) and abs(value) <= 9007199254740991 and not _card(value):
84
+ return value
85
+ elif isinstance(value, str) and _ENUM.fullmatch(value):
86
+ return value
87
+ removed = True
88
+ return REDACTED
89
+
90
+ try:
91
+ value = json.loads(raw.decode("utf-8"), object_pairs_hook=_object, parse_constant=lambda _: (_ for _ in ()).throw(ValueError("number")))
92
+ body = json.dumps(walk(value), separators=(",", ":"), ensure_ascii=True, allow_nan=False)
93
+ if len(body.encode()) > MAX_BYTES:
94
+ return None, "truncated"
95
+ return body, "redacted" if removed else "captured"
96
+ except _Unsupported as reason:
97
+ # The bundle consumer accepts only captured, redacted, missing, invalid and
98
+ # truncated, so an unsupported shape has to report "invalid". The log carries
99
+ # the distinction a new state value would have carried.
100
+ log.debug("Crumbtrail: body not exported, unsupported shape (%s)", reason)
101
+ return None, "invalid"
102
+ except (ValueError, RecursionError, OverflowError) as reason:
103
+ log.debug("Crumbtrail: body not exported, invalid JSON (%s)", reason)
104
+ return None, "invalid"
105
+
106
+
107
+ def redaction(field, state):
108
+ return {"policy": POLICY, "fields": [{"path": field, "reason": "backend_structured_profile", "action": "redacted"}] if state == "redacted" else []}
109
+
110
+
111
+ def is_json(content_type):
112
+ media = content_type.split(";", 1)[0].strip().lower()
113
+ return media == "application/json" or (media.startswith("application/") and media.endswith("+json"))
@@ -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,12 @@
1
+ crumbtrail/__init__.py,sha256=KrsoZgoScbG4WYJMQ3f85j9EJ1HKTHEbsa-h9WeAKug,156
2
+ crumbtrail/core.py,sha256=fJKGUdEpWrmogq3S1hh8u3sMbFzcbSkXQE9Xh6irpdI,14634
3
+ crumbtrail/database.py,sha256=QxqhTfa5cwk2qh_w5blovlnUUFrP6ZQXuH6T6EFM3c0,3278
4
+ crumbtrail/django.py,sha256=zhRmaHpa2qS4cIAuzjgBWJDxghdM5OyBJeaerbU4L68,1157
5
+ crumbtrail/flask.py,sha256=_ZcQ6E-S8TkZK23nOV8YKVZ2fX6l4JBa-dKchnJg4Yk,510
6
+ crumbtrail/middleware.py,sha256=TlJyH92LH87Q8CbayXrlWU_fw30pisZFJ2T4if_WxPM,8520
7
+ crumbtrail/privacy.py,sha256=39p0gbeup40cgUV_GKdyAwui2-PzBCL2tAxeVl2TT8E,4775
8
+ crumbtrail_python-0.1.0.dist-info/licenses/LICENSE,sha256=TtFbQp-yLe609hewRKljsptCHZmuFS_qZpGHBWF-_50,1067
9
+ crumbtrail_python-0.1.0.dist-info/METADATA,sha256=nYjmngIMkOkCG3-hyS46WQKC3r2WGUZ7lBHJbxOm4Jo,8075
10
+ crumbtrail_python-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ crumbtrail_python-0.1.0.dist-info/top_level.txt,sha256=83C0OZLxG0IgSg-lsi8NXT0-1rTTsPHALWznbca0suA,11
12
+ crumbtrail_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ crumbtrail