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.
- crumbtrail_python-0.1.0/LICENSE +21 -0
- crumbtrail_python-0.1.0/PKG-INFO +124 -0
- crumbtrail_python-0.1.0/README.md +106 -0
- crumbtrail_python-0.1.0/pyproject.toml +21 -0
- crumbtrail_python-0.1.0/setup.cfg +4 -0
- crumbtrail_python-0.1.0/src/crumbtrail/__init__.py +4 -0
- crumbtrail_python-0.1.0/src/crumbtrail/core.py +373 -0
- crumbtrail_python-0.1.0/src/crumbtrail/database.py +73 -0
- crumbtrail_python-0.1.0/src/crumbtrail/django.py +31 -0
- crumbtrail_python-0.1.0/src/crumbtrail/flask.py +16 -0
- crumbtrail_python-0.1.0/src/crumbtrail/middleware.py +221 -0
- crumbtrail_python-0.1.0/src/crumbtrail/privacy.py +113 -0
- crumbtrail_python-0.1.0/src/crumbtrail_python.egg-info/PKG-INFO +124 -0
- crumbtrail_python-0.1.0/src/crumbtrail_python.egg-info/SOURCES.txt +19 -0
- crumbtrail_python-0.1.0/src/crumbtrail_python.egg-info/dependency_links.txt +1 -0
- crumbtrail_python-0.1.0/src/crumbtrail_python.egg-info/requires.txt +9 -0
- crumbtrail_python-0.1.0/src/crumbtrail_python.egg-info/top_level.txt +1 -0
- crumbtrail_python-0.1.0/tests/test_capture.py +308 -0
- crumbtrail_python-0.1.0/tests/test_delivery.py +216 -0
- crumbtrail_python-0.1.0/tests/test_django.py +45 -0
- crumbtrail_python-0.1.0/tests/test_response_completion.py +107 -0
|
@@ -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,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
|