restless-sdk 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.
- restless_sdk-0.1.0/LICENSE +18 -0
- restless_sdk-0.1.0/PKG-INFO +125 -0
- restless_sdk-0.1.0/README.md +112 -0
- restless_sdk-0.1.0/pyproject.toml +29 -0
- restless_sdk-0.1.0/setup.cfg +4 -0
- restless_sdk-0.1.0/src/restless/__init__.py +161 -0
- restless_sdk-0.1.0/src/restless/_caches.py +128 -0
- restless_sdk-0.1.0/src/restless/_capture.py +274 -0
- restless_sdk-0.1.0/src/restless/_conformance/__init__.py +0 -0
- restless_sdk-0.1.0/src/restless/_conformance/__main__.py +125 -0
- restless_sdk-0.1.0/src/restless/_fingerprint.py +342 -0
- restless_sdk-0.1.0/src/restless/_har.py +133 -0
- restless_sdk-0.1.0/src/restless/_injection.py +114 -0
- restless_sdk-0.1.0/src/restless/_mask.py +58 -0
- restless_sdk-0.1.0/src/restless/_redact.py +338 -0
- restless_sdk-0.1.0/src/restless/_request.py +136 -0
- restless_sdk-0.1.0/src/restless/_request_id.py +64 -0
- restless_sdk-0.1.0/src/restless/_settings.py +91 -0
- restless_sdk-0.1.0/src/restless/_text.py +63 -0
- restless_sdk-0.1.0/src/restless/_uploader.py +247 -0
- restless_sdk-0.1.0/src/restless/_version.py +15 -0
- restless_sdk-0.1.0/src/restless/adapters/__init__.py +0 -0
- restless_sdk-0.1.0/src/restless/adapters/asgi.py +232 -0
- restless_sdk-0.1.0/src/restless/adapters/wsgi.py +268 -0
- restless_sdk-0.1.0/src/restless/install.md +427 -0
- restless_sdk-0.1.0/src/restless/py.typed +0 -0
- restless_sdk-0.1.0/src/restless_sdk.egg-info/PKG-INFO +125 -0
- restless_sdk-0.1.0/src/restless_sdk.egg-info/SOURCES.txt +32 -0
- restless_sdk-0.1.0/src/restless_sdk.egg-info/dependency_links.txt +1 -0
- restless_sdk-0.1.0/src/restless_sdk.egg-info/top_level.txt +1 -0
- restless_sdk-0.1.0/tests/test_redact_unicode_names.py +56 -0
- restless_sdk-0.1.0/tests/test_request_info.py +187 -0
- restless_sdk-0.1.0/tests/test_stack_frames.py +135 -0
- restless_sdk-0.1.0/tests/test_vectors.py +102 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Copyright © 2026 Restless
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
4
|
+
this software and associated documentation files (the “Software”), to deal in
|
|
5
|
+
the Software without restriction, including without limitation the rights to
|
|
6
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
7
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
8
|
+
subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
15
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
16
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, OUT OF OR IN CONNECTION WITH THE
|
|
18
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: restless-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Capture API traffic and send it to Restless. WSGI and ASGI: Flask, Django, FastAPI, Starlette.
|
|
5
|
+
Author: Restless
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://restless.ai
|
|
8
|
+
Keywords: restless,api,logging,observability,wsgi,asgi,flask,fastapi
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# restless-sdk
|
|
15
|
+
|
|
16
|
+
Capture your API traffic and send it to [Restless](https://restless.ai).
|
|
17
|
+
|
|
18
|
+
Works with any **WSGI** app (Flask, Django, Pyramid, Bottle) or **ASGI** app
|
|
19
|
+
(FastAPI, Starlette, Quart). Python 3.8+. No dependencies.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pip install restless-sdk
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Use
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import os
|
|
31
|
+
import restless
|
|
32
|
+
|
|
33
|
+
client = restless.Restless(os.environ["RESTLESS_KEY"])
|
|
34
|
+
|
|
35
|
+
@client.setup
|
|
36
|
+
def _(request):
|
|
37
|
+
return {
|
|
38
|
+
"api_key": client.mask(request.headers.get("authorization")),
|
|
39
|
+
"owner": {
|
|
40
|
+
# Permanent and immutable. A workspace id or database primary
|
|
41
|
+
# key, never an API key, email, or anything else that rotates:
|
|
42
|
+
# the dashboard pins a project's whole log history to this.
|
|
43
|
+
"id": workspace_id_for(request),
|
|
44
|
+
# Runs once per owner id, then caches. Put the expensive lookup
|
|
45
|
+
# here, not in the fields above.
|
|
46
|
+
"enrich": lambda owner_id: {
|
|
47
|
+
"label": db.workspaces.get(owner_id).name,
|
|
48
|
+
"email": db.workspaces.get(owner_id).admin_emails,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then wrap your app:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
# Flask / Django / any WSGI
|
|
58
|
+
app.wsgi_app = client.wsgi(app.wsgi_app)
|
|
59
|
+
|
|
60
|
+
# FastAPI / Starlette / any ASGI
|
|
61
|
+
app = client.asgi(app)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## The request view
|
|
65
|
+
|
|
66
|
+
The callback receives a read-only `RequestInfo`, identical under WSGI and
|
|
67
|
+
ASGI, so the same callback works whichever you are on.
|
|
68
|
+
|
|
69
|
+
| accessor | what |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `request.header(name)` | One header, case-insensitive. `request["authorization"]` is an alias. |
|
|
72
|
+
| `request.headers` | All of them, as a case-insensitive mapping. |
|
|
73
|
+
| `request.method` | `"GET"`, `"POST"`, ... |
|
|
74
|
+
| `request.path` | Path, including any mount prefix. |
|
|
75
|
+
| `request.query_string` | Raw query string, without the `?`. |
|
|
76
|
+
| `request.url` | Full URL, as the capture records it. |
|
|
77
|
+
| `request.environ` / `request.scope` | The raw WSGI environ or ASGI scope; the other is `None`. |
|
|
78
|
+
|
|
79
|
+
Nothing is hidden by the wrapper: anything it does not model is still on
|
|
80
|
+
`request.environ` / `request.scope`.
|
|
81
|
+
|
|
82
|
+
`request.route` is normally `None` here. The callback runs before your
|
|
83
|
+
application, so no router has matched yet. The captured log still gets the
|
|
84
|
+
real route, read after the response.
|
|
85
|
+
|
|
86
|
+
## What you get
|
|
87
|
+
|
|
88
|
+
- **Lazy owner enrichment.** The `enrich` callback runs on the first request
|
|
89
|
+
from each owner id and then caches, so 100 requests from one workspace do
|
|
90
|
+
not mean 100 database lookups.
|
|
91
|
+
- **Safe by default.** `Authorization`, `Cookie`, `password`, `token`, `ssn`
|
|
92
|
+
and friends are redacted before anything leaves your process. Bodies with
|
|
93
|
+
nothing to redact are passed through untouched, so your payloads are not
|
|
94
|
+
reserialized on the way out.
|
|
95
|
+
- **Error triage.** 4xx/5xx responses get `x-log-url` and `x-debug` headers
|
|
96
|
+
and a `debug` block in the JSON body. If someone attaches a "next steps"
|
|
97
|
+
message to an error in the dashboard, the SDK injects it as
|
|
98
|
+
`debug.recovery`, read synchronously from an in-process cache, never
|
|
99
|
+
blocking the response on a network call.
|
|
100
|
+
- **Blocking.** Return `{"block": True}` from the setup callback to reject a
|
|
101
|
+
request before your handler runs.
|
|
102
|
+
|
|
103
|
+
## Never breaks your API
|
|
104
|
+
|
|
105
|
+
Observability must not take down a production request path. Upload failures,
|
|
106
|
+
callback exceptions, unserializable bodies and malformed input are all caught
|
|
107
|
+
and swallowed (surfaced only under `DEBUG=restless`). Uploads happen on a
|
|
108
|
+
background thread.
|
|
109
|
+
|
|
110
|
+
## Environment variables
|
|
111
|
+
|
|
112
|
+
| variable | purpose |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `RESTLESS_KEY` | Your project API key, if you do not pass one explicitly. |
|
|
115
|
+
| `RESTLESS_BASE_URL` | Override the ingest URL (self-hosted or staging). |
|
|
116
|
+
| `DEBUG=restless` | Print upload diagnostics to stderr. |
|
|
117
|
+
|
|
118
|
+
## Conformance
|
|
119
|
+
|
|
120
|
+
This SDK implements version 1.0.0 of the Restless SDK Contract at level L2,
|
|
121
|
+
and is verified against the shared cross-language conformance vectors. See
|
|
122
|
+
[CONFORMANCE.md](./CONFORMANCE.md).
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
MIT
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# restless-sdk
|
|
2
|
+
|
|
3
|
+
Capture your API traffic and send it to [Restless](https://restless.ai).
|
|
4
|
+
|
|
5
|
+
Works with any **WSGI** app (Flask, Django, Pyramid, Bottle) or **ASGI** app
|
|
6
|
+
(FastAPI, Starlette, Quart). Python 3.8+. No dependencies.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pip install restless-sdk
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Use
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import os
|
|
18
|
+
import restless
|
|
19
|
+
|
|
20
|
+
client = restless.Restless(os.environ["RESTLESS_KEY"])
|
|
21
|
+
|
|
22
|
+
@client.setup
|
|
23
|
+
def _(request):
|
|
24
|
+
return {
|
|
25
|
+
"api_key": client.mask(request.headers.get("authorization")),
|
|
26
|
+
"owner": {
|
|
27
|
+
# Permanent and immutable. A workspace id or database primary
|
|
28
|
+
# key, never an API key, email, or anything else that rotates:
|
|
29
|
+
# the dashboard pins a project's whole log history to this.
|
|
30
|
+
"id": workspace_id_for(request),
|
|
31
|
+
# Runs once per owner id, then caches. Put the expensive lookup
|
|
32
|
+
# here, not in the fields above.
|
|
33
|
+
"enrich": lambda owner_id: {
|
|
34
|
+
"label": db.workspaces.get(owner_id).name,
|
|
35
|
+
"email": db.workspaces.get(owner_id).admin_emails,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then wrap your app:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
# Flask / Django / any WSGI
|
|
45
|
+
app.wsgi_app = client.wsgi(app.wsgi_app)
|
|
46
|
+
|
|
47
|
+
# FastAPI / Starlette / any ASGI
|
|
48
|
+
app = client.asgi(app)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## The request view
|
|
52
|
+
|
|
53
|
+
The callback receives a read-only `RequestInfo`, identical under WSGI and
|
|
54
|
+
ASGI, so the same callback works whichever you are on.
|
|
55
|
+
|
|
56
|
+
| accessor | what |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `request.header(name)` | One header, case-insensitive. `request["authorization"]` is an alias. |
|
|
59
|
+
| `request.headers` | All of them, as a case-insensitive mapping. |
|
|
60
|
+
| `request.method` | `"GET"`, `"POST"`, ... |
|
|
61
|
+
| `request.path` | Path, including any mount prefix. |
|
|
62
|
+
| `request.query_string` | Raw query string, without the `?`. |
|
|
63
|
+
| `request.url` | Full URL, as the capture records it. |
|
|
64
|
+
| `request.environ` / `request.scope` | The raw WSGI environ or ASGI scope; the other is `None`. |
|
|
65
|
+
|
|
66
|
+
Nothing is hidden by the wrapper: anything it does not model is still on
|
|
67
|
+
`request.environ` / `request.scope`.
|
|
68
|
+
|
|
69
|
+
`request.route` is normally `None` here. The callback runs before your
|
|
70
|
+
application, so no router has matched yet. The captured log still gets the
|
|
71
|
+
real route, read after the response.
|
|
72
|
+
|
|
73
|
+
## What you get
|
|
74
|
+
|
|
75
|
+
- **Lazy owner enrichment.** The `enrich` callback runs on the first request
|
|
76
|
+
from each owner id and then caches, so 100 requests from one workspace do
|
|
77
|
+
not mean 100 database lookups.
|
|
78
|
+
- **Safe by default.** `Authorization`, `Cookie`, `password`, `token`, `ssn`
|
|
79
|
+
and friends are redacted before anything leaves your process. Bodies with
|
|
80
|
+
nothing to redact are passed through untouched, so your payloads are not
|
|
81
|
+
reserialized on the way out.
|
|
82
|
+
- **Error triage.** 4xx/5xx responses get `x-log-url` and `x-debug` headers
|
|
83
|
+
and a `debug` block in the JSON body. If someone attaches a "next steps"
|
|
84
|
+
message to an error in the dashboard, the SDK injects it as
|
|
85
|
+
`debug.recovery`, read synchronously from an in-process cache, never
|
|
86
|
+
blocking the response on a network call.
|
|
87
|
+
- **Blocking.** Return `{"block": True}` from the setup callback to reject a
|
|
88
|
+
request before your handler runs.
|
|
89
|
+
|
|
90
|
+
## Never breaks your API
|
|
91
|
+
|
|
92
|
+
Observability must not take down a production request path. Upload failures,
|
|
93
|
+
callback exceptions, unserializable bodies and malformed input are all caught
|
|
94
|
+
and swallowed (surfaced only under `DEBUG=restless`). Uploads happen on a
|
|
95
|
+
background thread.
|
|
96
|
+
|
|
97
|
+
## Environment variables
|
|
98
|
+
|
|
99
|
+
| variable | purpose |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `RESTLESS_KEY` | Your project API key, if you do not pass one explicitly. |
|
|
102
|
+
| `RESTLESS_BASE_URL` | Override the ingest URL (self-hosted or staging). |
|
|
103
|
+
| `DEBUG=restless` | Print upload diagnostics to stderr. |
|
|
104
|
+
|
|
105
|
+
## Conformance
|
|
106
|
+
|
|
107
|
+
This SDK implements version 1.0.0 of the Restless SDK Contract at level L2,
|
|
108
|
+
and is verified against the shared cross-language conformance vectors. See
|
|
109
|
+
[CONFORMANCE.md](./CONFORMANCE.md).
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "restless-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Capture API traffic and send it to Restless. WSGI and ASGI: Flask, Django, FastAPI, Starlette."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Restless" }]
|
|
13
|
+
keywords = ["restless", "api", "logging", "observability", "wsgi", "asgi", "flask", "fastapi"]
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://restless.ai"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.package-data]
|
|
23
|
+
# install.md ships INSIDE the package, not just in the sdist. The `api` CLI
|
|
24
|
+
# points installing agents at it with
|
|
25
|
+
# python -c "import restless, pathlib; print(pathlib.Path(restless.__file__).parent)"
|
|
26
|
+
# and pip installs the wheel, so a root-level file (or a MANIFEST.in entry,
|
|
27
|
+
# which only reaches the sdist) would leave that pointer resolving to nothing.
|
|
28
|
+
# The Node SDK ships its equivalent the same way, via package.json `files`.
|
|
29
|
+
restless = ["py.typed", "install.md"]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Restless Python SDK.
|
|
2
|
+
|
|
3
|
+
Capture your API traffic and send it to Restless.
|
|
4
|
+
|
|
5
|
+
import restless
|
|
6
|
+
|
|
7
|
+
client = restless.Restless(os.environ["RESTLESS_KEY"])
|
|
8
|
+
|
|
9
|
+
@client.setup
|
|
10
|
+
def _(request):
|
|
11
|
+
return {
|
|
12
|
+
"api_key": client.mask(request.headers.get("authorization")),
|
|
13
|
+
"owner": {
|
|
14
|
+
"id": current_workspace_id(),
|
|
15
|
+
"enrich": lambda owner_id: {"label": lookup_name(owner_id)},
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
app.wsgi_app = client.wsgi(app.wsgi_app)
|
|
20
|
+
|
|
21
|
+
Conformance: implements spec version 1.0.0 of the Restless SDK Contract at
|
|
22
|
+
level L2. Run the shared harness against ``python -m restless._conformance``
|
|
23
|
+
to verify. See CONFORMANCE.md.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
from ._capture import CaptureEngine
|
|
31
|
+
from ._mask import mask
|
|
32
|
+
from ._request import Headers, RequestInfo
|
|
33
|
+
from ._request_id import (
|
|
34
|
+
format_request_id,
|
|
35
|
+
is_valid_request_id,
|
|
36
|
+
new_request_id,
|
|
37
|
+
strip_request_id_prefix,
|
|
38
|
+
)
|
|
39
|
+
from ._settings import load_settings, resolve_api
|
|
40
|
+
from ._uploader import is_test_run, resolve_base_url
|
|
41
|
+
from ._version import CONFORMANCE_LEVEL, SDK_NAME, SDK_VERSION, SPEC_VERSION
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"Restless",
|
|
45
|
+
"restless",
|
|
46
|
+
"mask",
|
|
47
|
+
"RequestInfo",
|
|
48
|
+
"Headers",
|
|
49
|
+
"new_request_id",
|
|
50
|
+
"format_request_id",
|
|
51
|
+
"strip_request_id_prefix",
|
|
52
|
+
"is_valid_request_id",
|
|
53
|
+
"SDK_NAME",
|
|
54
|
+
"SDK_VERSION",
|
|
55
|
+
"SPEC_VERSION",
|
|
56
|
+
"CONFORMANCE_LEVEL",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
__version__ = SDK_VERSION
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Restless:
|
|
63
|
+
"""A Restless client.
|
|
64
|
+
|
|
65
|
+
Naming here is idiomatic Python rather than a transliteration of the Node
|
|
66
|
+
SDK. CONTRACT.md section 14 makes the public surface explicitly
|
|
67
|
+
per-language: what is normative is the SEMANTIC surface (construct with
|
|
68
|
+
an explicit key, register a per-request callback, mask a key, flush), not
|
|
69
|
+
the method names.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
api_key: Optional[str] = None,
|
|
75
|
+
api: Optional[str] = None,
|
|
76
|
+
redact: Optional[Dict[str, List[str]]] = None,
|
|
77
|
+
base_url: Optional[str] = None,
|
|
78
|
+
transport: Optional[Callable] = None,
|
|
79
|
+
):
|
|
80
|
+
# CONFIG-001. Explicit key wins, then RESTLESS_KEY, then README_API_KEY.
|
|
81
|
+
resolved = (
|
|
82
|
+
api_key
|
|
83
|
+
or os.environ.get("RESTLESS_KEY")
|
|
84
|
+
or os.environ.get("README_API_KEY")
|
|
85
|
+
or ""
|
|
86
|
+
)
|
|
87
|
+
# CONFIG-002. Capture still runs; only upload is disabled.
|
|
88
|
+
if not resolved and not is_test_run():
|
|
89
|
+
print(
|
|
90
|
+
"[restless] no API key found - set RESTLESS_KEY in your environment "
|
|
91
|
+
"or pass it to Restless(). Captured requests will not be uploaded.",
|
|
92
|
+
file=sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
request_id_prefix = None
|
|
96
|
+
settings_redact = None
|
|
97
|
+
entry = resolve_api(load_settings(), api) # CONFIG-013/014 may raise
|
|
98
|
+
if entry:
|
|
99
|
+
request_id_prefix = entry.get("requestIdPrefix")
|
|
100
|
+
settings_redact = entry.get("redact")
|
|
101
|
+
|
|
102
|
+
# REDACT-015. Both sources are additive on top of the defaults.
|
|
103
|
+
merged: Dict[str, List[str]] = {}
|
|
104
|
+
for field in ("headers", "bodyKeys", "queryParams"):
|
|
105
|
+
merged[field] = list((settings_redact or {}).get(field) or []) + list(
|
|
106
|
+
(redact or {}).get(field) or []
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self.engine = CaptureEngine(
|
|
110
|
+
api_key=resolved,
|
|
111
|
+
base_url=resolve_base_url(base_url),
|
|
112
|
+
request_id_prefix=request_id_prefix,
|
|
113
|
+
redact=merged,
|
|
114
|
+
transport=transport,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------ public API
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def mask(api_key: Optional[str]) -> Optional[str]:
|
|
121
|
+
"""Mask an end-user API key (MASK-001).
|
|
122
|
+
|
|
123
|
+
Pass the raw header value straight through. Do NOT substitute a
|
|
124
|
+
placeholder like ``"anonymous"``: its last 4 characters would become
|
|
125
|
+
the tail and cluster unrelated callers together.
|
|
126
|
+
"""
|
|
127
|
+
return mask(api_key)
|
|
128
|
+
|
|
129
|
+
def setup(self, callback: Callable) -> Callable:
|
|
130
|
+
"""Register the per-request callback. Usable as a decorator.
|
|
131
|
+
|
|
132
|
+
The callback receives the framework-native request object and returns
|
|
133
|
+
a dict: ``api_key`` (masked), ``owner`` (``id`` plus ``enrich``), and
|
|
134
|
+
optionally ``block``.
|
|
135
|
+
"""
|
|
136
|
+
self.engine.set_callback(callback)
|
|
137
|
+
return callback
|
|
138
|
+
|
|
139
|
+
def flush(self) -> None:
|
|
140
|
+
"""Force-upload the queued batch. Call before process exit if you
|
|
141
|
+
care about in-flight captures."""
|
|
142
|
+
self.engine.flush()
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------- adapters
|
|
145
|
+
|
|
146
|
+
def wsgi(self, app: Callable) -> Callable:
|
|
147
|
+
"""Wrap a WSGI app (Flask, Django, Pyramid, Bottle)."""
|
|
148
|
+
from .adapters.wsgi import wrap_wsgi
|
|
149
|
+
|
|
150
|
+
return wrap_wsgi(app, self.engine)
|
|
151
|
+
|
|
152
|
+
def asgi(self, app: Callable) -> Callable:
|
|
153
|
+
"""Wrap an ASGI app (FastAPI, Starlette, Quart)."""
|
|
154
|
+
from .adapters.asgi import wrap_asgi
|
|
155
|
+
|
|
156
|
+
return wrap_asgi(app, self.engine)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def restless(api_key: Optional[str] = None, **kwargs: Any) -> Restless:
|
|
160
|
+
"""Functional constructor, mirroring the Node SDK's ``restless(key)``."""
|
|
161
|
+
return Restless(api_key, **kwargs)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""In-process caches.
|
|
2
|
+
|
|
3
|
+
Implements CONTRACT.md section 11.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
# CACHE-004, CACHE-014
|
|
10
|
+
_ENRICH_TTL_MS = 60 * 60 * 1000
|
|
11
|
+
_RECOVERY_TTL_MS = 60 * 60 * 1000
|
|
12
|
+
_RECOVERY_NEGATIVE_TTL_MS = 5 * 60 * 1000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _now_ms() -> float:
|
|
16
|
+
return time.monotonic() * 1000.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EnrichCache:
|
|
20
|
+
"""CACHE-001..007.
|
|
21
|
+
|
|
22
|
+
Stores the enriched VALUE, not merely a freshness flag (CACHE-003). Every
|
|
23
|
+
upload has to carry owner metadata, including uploads that skipped the
|
|
24
|
+
callback, because the ingest cannot backfill it: without the value, every
|
|
25
|
+
request after the first would land in the dashboard as unauthenticated.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, ttl_ms: float = _ENRICH_TTL_MS):
|
|
29
|
+
self._cache: Dict[str, Any] = {}
|
|
30
|
+
self._ttl_ms = ttl_ms
|
|
31
|
+
|
|
32
|
+
def get(self, key: str) -> Optional[Dict[str, Any]]:
|
|
33
|
+
entry = self._cache.get(key)
|
|
34
|
+
if entry is None:
|
|
35
|
+
return None
|
|
36
|
+
value, ts = entry
|
|
37
|
+
if _now_ms() - ts > self._ttl_ms:
|
|
38
|
+
self._cache.pop(key, None)
|
|
39
|
+
return None
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
def set(self, key: str, value: Dict[str, Any]) -> None:
|
|
43
|
+
self._cache[key] = (value, _now_ms())
|
|
44
|
+
|
|
45
|
+
def invalidate(self, key: str) -> None:
|
|
46
|
+
"""CACHE-006. Next request from this key re-runs enrich."""
|
|
47
|
+
self._cache.pop(key, None)
|
|
48
|
+
|
|
49
|
+
def clear(self) -> None:
|
|
50
|
+
self._cache.clear()
|
|
51
|
+
|
|
52
|
+
def size(self) -> int:
|
|
53
|
+
return len(self._cache)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RecoveryCache:
|
|
57
|
+
"""CACHE-010..015. Fingerprint key to recovery message.
|
|
58
|
+
|
|
59
|
+
Every read is synchronous and in-process. The lookup sits on the hot path
|
|
60
|
+
of every 4xx/5xx, so it never performs I/O and never blocks the response;
|
|
61
|
+
a cold miss simply injects nothing.
|
|
62
|
+
|
|
63
|
+
"No message for this fingerprint" is itself cached (as ``None``) so a cold
|
|
64
|
+
miss does not stay a cold miss on every subsequent request. The negative
|
|
65
|
+
TTL is shorter so a freshly-attached dashboard message starts working
|
|
66
|
+
within minutes.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
ttl_ms: float = _RECOVERY_TTL_MS,
|
|
72
|
+
negative_ttl_ms: float = _RECOVERY_NEGATIVE_TTL_MS,
|
|
73
|
+
):
|
|
74
|
+
self._cache: Dict[str, Any] = {}
|
|
75
|
+
self._ttl_ms = ttl_ms
|
|
76
|
+
self._negative_ttl_ms = negative_ttl_ms
|
|
77
|
+
|
|
78
|
+
_MISS = object()
|
|
79
|
+
|
|
80
|
+
def get(self, key: str) -> Any:
|
|
81
|
+
"""Returns the message, ``None`` for a confirmed absence, or ``_MISS``.
|
|
82
|
+
|
|
83
|
+
The three-way result matters: ``None`` means the server told us there
|
|
84
|
+
is no message (do not ask again yet), while ``_MISS`` means we have
|
|
85
|
+
never asked.
|
|
86
|
+
"""
|
|
87
|
+
entry = self._cache.get(key)
|
|
88
|
+
if entry is None:
|
|
89
|
+
return self._MISS
|
|
90
|
+
message, ts = entry
|
|
91
|
+
ttl = self._negative_ttl_ms if message is None else self._ttl_ms
|
|
92
|
+
if _now_ms() - ts > ttl:
|
|
93
|
+
self._cache.pop(key, None)
|
|
94
|
+
return self._MISS
|
|
95
|
+
return message
|
|
96
|
+
|
|
97
|
+
def set(self, key: str, message: Optional[str]) -> None:
|
|
98
|
+
self._cache[key] = (message, _now_ms())
|
|
99
|
+
|
|
100
|
+
def lookup(self, key: str) -> Optional[str]:
|
|
101
|
+
"""CACHE-010. The hot-path read: a message to inject, or None."""
|
|
102
|
+
value = self.get(key)
|
|
103
|
+
return value if isinstance(value, str) else None
|
|
104
|
+
|
|
105
|
+
def invalidate(self, key: str) -> None:
|
|
106
|
+
self._cache.pop(key, None)
|
|
107
|
+
|
|
108
|
+
def clear(self) -> None:
|
|
109
|
+
self._cache.clear()
|
|
110
|
+
|
|
111
|
+
def size(self) -> int:
|
|
112
|
+
return len(self._cache)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Blocklist:
|
|
116
|
+
"""Masked keys to reject. O(1) lookup, per-process state."""
|
|
117
|
+
|
|
118
|
+
def __init__(self) -> None:
|
|
119
|
+
self._blocked = set()
|
|
120
|
+
|
|
121
|
+
def replace(self, masked_keys) -> None:
|
|
122
|
+
self._blocked = set(masked_keys)
|
|
123
|
+
|
|
124
|
+
def has(self, masked_key: Optional[str]) -> bool:
|
|
125
|
+
return bool(masked_key) and masked_key in self._blocked
|
|
126
|
+
|
|
127
|
+
def size(self) -> int:
|
|
128
|
+
return len(self._blocked)
|