trailscope 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.
- trailscope-0.1.0/.gitignore +34 -0
- trailscope-0.1.0/PKG-INFO +81 -0
- trailscope-0.1.0/README.md +42 -0
- trailscope-0.1.0/bench/overhead.py +166 -0
- trailscope-0.1.0/pyproject.toml +71 -0
- trailscope-0.1.0/src/trailscope/__init__.py +55 -0
- trailscope-0.1.0/src/trailscope/_capture.py +108 -0
- trailscope-0.1.0/src/trailscope/_context.py +125 -0
- trailscope-0.1.0/src/trailscope/_ids.py +56 -0
- trailscope-0.1.0/src/trailscope/_redact.py +168 -0
- trailscope-0.1.0/src/trailscope/_version.py +1 -0
- trailscope-0.1.0/src/trailscope/asgi.py +194 -0
- trailscope-0.1.0/src/trailscope/celery.py +148 -0
- trailscope-0.1.0/src/trailscope/client.py +838 -0
- trailscope-0.1.0/src/trailscope/django.py +204 -0
- trailscope-0.1.0/src/trailscope/handler.py +49 -0
- trailscope-0.1.0/src/trailscope/py.typed +0 -0
- trailscope-0.1.0/tests/test_version.py +5 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/target
|
|
2
|
+
node_modules/
|
|
3
|
+
dist/
|
|
4
|
+
*.tsbuildinfo
|
|
5
|
+
.env
|
|
6
|
+
.env.local
|
|
7
|
+
trailscope.toml
|
|
8
|
+
!deploy/**/trailscope.toml
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
.venv/
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
*.egg-info/
|
|
16
|
+
build/
|
|
17
|
+
coverage/
|
|
18
|
+
test-results/
|
|
19
|
+
playwright-report/
|
|
20
|
+
crates/trailscope-query/fuzz/target/
|
|
21
|
+
crates/trailscope-query/fuzz/corpus/parse/*
|
|
22
|
+
!crates/trailscope-query/fuzz/corpus/parse/seed*
|
|
23
|
+
|
|
24
|
+
crates/trailscope-query/fuzz/artifacts/
|
|
25
|
+
.DS_Store
|
|
26
|
+
|
|
27
|
+
# Website: generated by website/scripts/sync-docs.mjs and astro
|
|
28
|
+
website/.astro/
|
|
29
|
+
website/src/content/docs/
|
|
30
|
+
website/public/og.png
|
|
31
|
+
website/public/favicon.svg
|
|
32
|
+
|
|
33
|
+
# Personal Claude Code notes (machine paths, working instructions)
|
|
34
|
+
CLAUDE.local.md
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: trailscope
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Trailscope SDK for Python: non-blocking API request logging for Django, FastAPI/Starlette and Celery
|
|
5
|
+
Project-URL: Homepage, https://trailscope.pro
|
|
6
|
+
Project-URL: Source, https://github.com/karaa1122/trailscope
|
|
7
|
+
Project-URL: Documentation, https://trailscope.pro/docs/sdk-python/
|
|
8
|
+
Author: Trailscope contributors
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: System :: Logging
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Provides-Extra: celery
|
|
19
|
+
Requires-Dist: celery>=5.3; extra == 'celery'
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: celery>=5.6; extra == 'dev'
|
|
22
|
+
Requires-Dist: django<6,>=5.2; (python_version < '3.12') and extra == 'dev'
|
|
23
|
+
Requires-Dist: django>=6.1; (python_version >= '3.12') and extra == 'dev'
|
|
24
|
+
Requires-Dist: fastapi>=0.162; extra == 'dev'
|
|
25
|
+
Requires-Dist: httpx>=0.28; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy>=2.4; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=1.4; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=9.1; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.17; extra == 'dev'
|
|
30
|
+
Requires-Dist: starlette<1.9,>=1.0; (python_version < '3.11') and extra == 'dev'
|
|
31
|
+
Requires-Dist: starlette>=1.9; (python_version >= '3.11') and extra == 'dev'
|
|
32
|
+
Provides-Extra: django
|
|
33
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
34
|
+
Provides-Extra: fastapi
|
|
35
|
+
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
|
|
36
|
+
Provides-Extra: starlette
|
|
37
|
+
Requires-Dist: starlette>=0.37; extra == 'starlette'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# trailscope
|
|
41
|
+
|
|
42
|
+
The [Trailscope](https://github.com/karaa1122/trailscope) SDK for Python 3.10+: logs every request with its route, timing, user, journey keys and (on errors) bodies and stack, plus Celery jobs and log lines, without blocking your request path. No dependencies beyond the standard library.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install trailscope
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## FastAPI / Starlette
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import trailscope
|
|
52
|
+
from trailscope.asgi import TrailscopeMiddleware
|
|
53
|
+
|
|
54
|
+
trailscope.init(service="orders-api") # reads TRAILSCOPE_ENDPOINT and TRAILSCOPE_INGEST_KEY
|
|
55
|
+
app = FastAPI()
|
|
56
|
+
app.add_middleware(TrailscopeMiddleware) # add it last, so it runs first
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Django
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
MIDDLEWARE = ["trailscope.django.TrailscopeMiddleware", ...] # first
|
|
63
|
+
TRAILSCOPE = {"service": "orders-api"}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Celery and logging
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from trailscope.celery import instrument
|
|
70
|
+
|
|
71
|
+
instrument() # in the web process and the worker, after trailscope.init()
|
|
72
|
+
logging.getLogger().addHandler(trailscope.TrailscopeHandler())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
In request code: `trailscope.set_user(user_id)`, `trailscope.set_correlation("order_id", order_id)`.
|
|
76
|
+
|
|
77
|
+
Full documentation: [docs/sdk-python.md](https://github.com/karaa1122/trailscope/blob/main/docs/sdk-python.md).
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
Apache-2.0
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# trailscope
|
|
2
|
+
|
|
3
|
+
The [Trailscope](https://github.com/karaa1122/trailscope) SDK for Python 3.10+: logs every request with its route, timing, user, journey keys and (on errors) bodies and stack, plus Celery jobs and log lines, without blocking your request path. No dependencies beyond the standard library.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install trailscope
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## FastAPI / Starlette
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import trailscope
|
|
13
|
+
from trailscope.asgi import TrailscopeMiddleware
|
|
14
|
+
|
|
15
|
+
trailscope.init(service="orders-api") # reads TRAILSCOPE_ENDPOINT and TRAILSCOPE_INGEST_KEY
|
|
16
|
+
app = FastAPI()
|
|
17
|
+
app.add_middleware(TrailscopeMiddleware) # add it last, so it runs first
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Django
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
MIDDLEWARE = ["trailscope.django.TrailscopeMiddleware", ...] # first
|
|
24
|
+
TRAILSCOPE = {"service": "orders-api"}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Celery and logging
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from trailscope.celery import instrument
|
|
31
|
+
|
|
32
|
+
instrument() # in the web process and the worker, after trailscope.init()
|
|
33
|
+
logging.getLogger().addHandler(trailscope.TrailscopeHandler())
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
In request code: `trailscope.set_user(user_id)`, `trailscope.set_correlation("order_id", order_id)`.
|
|
37
|
+
|
|
38
|
+
Full documentation: [docs/sdk-python.md](https://github.com/karaa1122/trailscope/blob/main/docs/sdk-python.md).
|
|
39
|
+
|
|
40
|
+
## License
|
|
41
|
+
|
|
42
|
+
Apache-2.0
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Measures what the ASGI and Django middleware add to the request path.
|
|
2
|
+
|
|
3
|
+
Context setup, request ID header, body buffering and recording the request
|
|
4
|
+
(redaction, serialization and gzip happen later in the flush thread). Synthetic
|
|
5
|
+
requests keep network noise out. Run: python bench/overhead.py
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import statistics
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import trailscope
|
|
19
|
+
from trailscope.asgi import TrailscopeMiddleware as AsgiMiddleware
|
|
20
|
+
|
|
21
|
+
N = 50_000
|
|
22
|
+
client = trailscope.Client(
|
|
23
|
+
ingest_key="trs_ingest_bench",
|
|
24
|
+
service="bench",
|
|
25
|
+
endpoint="http://127.0.0.1:9", # never reached: flushing is paused below
|
|
26
|
+
flush_interval=3600,
|
|
27
|
+
flush_at=10**9,
|
|
28
|
+
max_queue_size=N * 3,
|
|
29
|
+
remote_config=False,
|
|
30
|
+
)
|
|
31
|
+
PAYLOAD = json.dumps([{"id": "t1", "title": "Write the brief", "done": False}]).encode()
|
|
32
|
+
HEADERS = [
|
|
33
|
+
(b"host", b"api.example.com"),
|
|
34
|
+
(b"content-type", b"application/json"),
|
|
35
|
+
(b"authorization", b"Bearer eyJhbGciOiJIUzI1NiJ9.bench"),
|
|
36
|
+
(b"user-agent", b"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15"),
|
|
37
|
+
(b"content-length", b"64"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Route:
|
|
42
|
+
path_format = "/projects/{project_id}/tasks"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def app(
|
|
46
|
+
scope: dict[str, Any],
|
|
47
|
+
receive: Callable[[], Awaitable[Any]],
|
|
48
|
+
send: Callable[[Any], Awaitable[None]],
|
|
49
|
+
) -> None:
|
|
50
|
+
await receive()
|
|
51
|
+
scope["route"] = Route()
|
|
52
|
+
status = 500 if scope["path"].endswith("/fail") else 200
|
|
53
|
+
await send(
|
|
54
|
+
{
|
|
55
|
+
"type": "http.response.start",
|
|
56
|
+
"status": status,
|
|
57
|
+
"headers": [(b"content-type", b"application/json")],
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
await send({"type": "http.response.body", "body": PAYLOAD})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def pct(samples: list[float], p: float) -> float:
|
|
64
|
+
return samples[min(len(samples) - 1, int(len(samples) * p))] * 1000
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def bench_asgi(wrapped: bool) -> list[float]:
|
|
68
|
+
target = AsgiMiddleware(app, client=client) if wrapped else app
|
|
69
|
+
body = {
|
|
70
|
+
"type": "http.request",
|
|
71
|
+
"body": b'{"title":"Draft roadmap","tags":["a","b"]}',
|
|
72
|
+
"more_body": False,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async def receive() -> dict[str, Any]:
|
|
76
|
+
return body
|
|
77
|
+
|
|
78
|
+
async def send(_: Any) -> None:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
samples = []
|
|
82
|
+
for i in range(N):
|
|
83
|
+
scope = {
|
|
84
|
+
"type": "http",
|
|
85
|
+
"method": "POST",
|
|
86
|
+
"path": f"/projects/prj_{i % 100}/tasks" + ("/fail" if i % 50 == 0 else ""),
|
|
87
|
+
"query_string": b"expand=owner",
|
|
88
|
+
"headers": HEADERS,
|
|
89
|
+
"client": ("198.51.100.7", 50000),
|
|
90
|
+
"root_path": "",
|
|
91
|
+
}
|
|
92
|
+
t = time.perf_counter()
|
|
93
|
+
await target(scope, receive, send)
|
|
94
|
+
samples.append(time.perf_counter() - t)
|
|
95
|
+
return sorted(samples)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def bench_django(wrapped: bool) -> list[float]:
|
|
99
|
+
from django.conf import settings
|
|
100
|
+
|
|
101
|
+
if not settings.configured:
|
|
102
|
+
settings.configure(ALLOWED_HOSTS=["*"], USE_TZ=True)
|
|
103
|
+
import django
|
|
104
|
+
|
|
105
|
+
django.setup()
|
|
106
|
+
from django.http import HttpResponse
|
|
107
|
+
from django.test import RequestFactory
|
|
108
|
+
|
|
109
|
+
import trailscope.django as lbd
|
|
110
|
+
|
|
111
|
+
def view(request: Any) -> HttpResponse:
|
|
112
|
+
status = 500 if request.path.endswith("/fail") else 200
|
|
113
|
+
return HttpResponse(PAYLOAD, status=status, content_type="application/json")
|
|
114
|
+
|
|
115
|
+
trailscope.init( # the Django middleware uses the process-wide client
|
|
116
|
+
ingest_key="trs_ingest_bench",
|
|
117
|
+
service="bench",
|
|
118
|
+
endpoint="http://127.0.0.1:9",
|
|
119
|
+
flush_interval=3600,
|
|
120
|
+
flush_at=10**9,
|
|
121
|
+
max_queue_size=N * 3,
|
|
122
|
+
remote_config=False,
|
|
123
|
+
)
|
|
124
|
+
target: Callable[[Any], Any] = lbd.TrailscopeMiddleware(view) if wrapped else view
|
|
125
|
+
factory = RequestFactory()
|
|
126
|
+
samples = []
|
|
127
|
+
for i in range(N):
|
|
128
|
+
path = f"/projects/prj_{i % 100}/tasks" + ("/fail" if i % 50 == 0 else "")
|
|
129
|
+
request = factory.post(
|
|
130
|
+
path + "?expand=owner", data=b'{"title":"Draft"}', content_type="application/json"
|
|
131
|
+
)
|
|
132
|
+
t = time.perf_counter()
|
|
133
|
+
target(request)
|
|
134
|
+
samples.append(time.perf_counter() - t)
|
|
135
|
+
return sorted(samples)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def report(name: str, base: list[float], sdk: list[float]) -> dict[str, Any]:
|
|
139
|
+
return {
|
|
140
|
+
"name": name,
|
|
141
|
+
"iterations": N,
|
|
142
|
+
"baseline_p50_ms": round(pct(base, 0.5), 4),
|
|
143
|
+
"baseline_p99_ms": round(pct(base, 0.99), 4),
|
|
144
|
+
"with_sdk_p50_ms": round(pct(sdk, 0.5), 4),
|
|
145
|
+
"with_sdk_p99_ms": round(pct(sdk, 0.99), 4),
|
|
146
|
+
"added_p50_ms": round(pct(sdk, 0.5) - pct(base, 0.5), 4),
|
|
147
|
+
"added_p99_ms": round(pct(sdk, 0.99) - pct(base, 0.99), 4),
|
|
148
|
+
"added_mean_ms": round((statistics.fmean(sdk) - statistics.fmean(base)) * 1000, 4),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main() -> int:
|
|
153
|
+
results = []
|
|
154
|
+
asyncio.run(bench_asgi(True)) # warm up
|
|
155
|
+
results.append(report("asgi", asyncio.run(bench_asgi(False)), asyncio.run(bench_asgi(True))))
|
|
156
|
+
try:
|
|
157
|
+
bench_django(True) # warm up
|
|
158
|
+
results.append(report("django", bench_django(False), bench_django(True)))
|
|
159
|
+
except ImportError:
|
|
160
|
+
print("django not installed; skipping", file=sys.stderr)
|
|
161
|
+
print(json.dumps({"python": sys.version.split()[0], "results": results}, indent=2))
|
|
162
|
+
return 1 if any(r["added_p99_ms"] >= 0.2 for r in results) else 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
sys.exit(main())
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.32"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trailscope"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Trailscope SDK for Python: non-blocking API request logging for Django, FastAPI/Starlette and Celery"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Trailscope contributors" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Framework :: Django",
|
|
16
|
+
"Framework :: FastAPI",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: System :: Logging",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
django = ["django>=4.2"]
|
|
26
|
+
starlette = ["starlette>=0.37"]
|
|
27
|
+
fastapi = ["fastapi>=0.110"]
|
|
28
|
+
celery = ["celery>=5.3"]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=9.1",
|
|
31
|
+
"pytest-asyncio>=1.4",
|
|
32
|
+
"ruff>=0.17",
|
|
33
|
+
"mypy>=2.4",
|
|
34
|
+
# Newest releases need newer Pythons; 3.10 and 3.11 test against the previous lines.
|
|
35
|
+
"django>=6.1; python_version >= '3.12'",
|
|
36
|
+
"django>=5.2,<6; python_version < '3.12'",
|
|
37
|
+
"starlette>=1.9; python_version >= '3.11'",
|
|
38
|
+
"starlette>=1.0,<1.9; python_version < '3.11'",
|
|
39
|
+
"fastapi>=0.162",
|
|
40
|
+
"httpx>=0.28",
|
|
41
|
+
"celery>=5.6",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://trailscope.pro"
|
|
46
|
+
Source = "https://github.com/karaa1122/trailscope"
|
|
47
|
+
Documentation = "https://trailscope.pro/docs/sdk-python/"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/trailscope"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 100
|
|
54
|
+
target-version = "py310"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
|
|
58
|
+
|
|
59
|
+
[tool.mypy]
|
|
60
|
+
strict = true
|
|
61
|
+
python_version = "3.10"
|
|
62
|
+
mypy_path = "src"
|
|
63
|
+
packages = ["trailscope"]
|
|
64
|
+
|
|
65
|
+
[[tool.mypy.overrides]]
|
|
66
|
+
module = ["django.*", "celery.*"]
|
|
67
|
+
ignore_missing_imports = true
|
|
68
|
+
|
|
69
|
+
[tool.pytest.ini_options]
|
|
70
|
+
testpaths = ["tests"]
|
|
71
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Trailscope SDK for Python: non-blocking request logging for Django, ASGI apps and Celery."""
|
|
2
|
+
|
|
3
|
+
from ._context import (
|
|
4
|
+
TrailscopeContext,
|
|
5
|
+
current_context,
|
|
6
|
+
deserialize_context,
|
|
7
|
+
record_error,
|
|
8
|
+
serialize_context,
|
|
9
|
+
set_attribute,
|
|
10
|
+
set_correlation,
|
|
11
|
+
set_user,
|
|
12
|
+
use_context,
|
|
13
|
+
)
|
|
14
|
+
from ._ids import format_traceparent, new_request_id, new_span_id, new_trace_id, parse_traceparent
|
|
15
|
+
from ._redact import REDACTED, Redactor, mask_ip
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .client import (
|
|
18
|
+
DEFAULT_IGNORE_ROUTES,
|
|
19
|
+
Client,
|
|
20
|
+
ClientStats,
|
|
21
|
+
HttpExchange,
|
|
22
|
+
JobScope,
|
|
23
|
+
get_client,
|
|
24
|
+
init,
|
|
25
|
+
)
|
|
26
|
+
from .handler import TrailscopeHandler
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"DEFAULT_IGNORE_ROUTES",
|
|
30
|
+
"REDACTED",
|
|
31
|
+
"Client",
|
|
32
|
+
"ClientStats",
|
|
33
|
+
"HttpExchange",
|
|
34
|
+
"JobScope",
|
|
35
|
+
"Redactor",
|
|
36
|
+
"TrailscopeContext",
|
|
37
|
+
"TrailscopeHandler",
|
|
38
|
+
"__version__",
|
|
39
|
+
"current_context",
|
|
40
|
+
"deserialize_context",
|
|
41
|
+
"format_traceparent",
|
|
42
|
+
"get_client",
|
|
43
|
+
"init",
|
|
44
|
+
"mask_ip",
|
|
45
|
+
"new_request_id",
|
|
46
|
+
"new_span_id",
|
|
47
|
+
"new_trace_id",
|
|
48
|
+
"parse_traceparent",
|
|
49
|
+
"record_error",
|
|
50
|
+
"serialize_context",
|
|
51
|
+
"set_attribute",
|
|
52
|
+
"set_correlation",
|
|
53
|
+
"set_user",
|
|
54
|
+
"use_context",
|
|
55
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Body capture policy, serialization and truncation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
BodyCapture = Literal["errors", "always", "never"]
|
|
10
|
+
|
|
11
|
+
_BINARY_TYPES = (
|
|
12
|
+
"multipart/",
|
|
13
|
+
"application/octet-stream",
|
|
14
|
+
"image/",
|
|
15
|
+
"audio/",
|
|
16
|
+
"video/",
|
|
17
|
+
"font/",
|
|
18
|
+
"application/pdf",
|
|
19
|
+
"application/zip",
|
|
20
|
+
"application/gzip",
|
|
21
|
+
"application/x-protobuf",
|
|
22
|
+
"application/grpc",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_binary_content_type(content_type: str | None) -> bool:
|
|
27
|
+
"""Whether a body with this content type should never be captured."""
|
|
28
|
+
if not content_type:
|
|
29
|
+
return False
|
|
30
|
+
return content_type.lower().startswith(_BINARY_TYPES)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def should_capture_bodies(
|
|
34
|
+
policy: BodyCapture, status: int, duration_ms: float, slow_ms: float
|
|
35
|
+
) -> bool:
|
|
36
|
+
if policy == "never":
|
|
37
|
+
return False
|
|
38
|
+
if policy == "always":
|
|
39
|
+
return True
|
|
40
|
+
return status >= 400 or duration_ms >= slow_ms
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def truncate(text: str, limit: int) -> tuple[str, bool]:
|
|
44
|
+
data = text.encode("utf-8")
|
|
45
|
+
if len(data) <= limit:
|
|
46
|
+
return text, False
|
|
47
|
+
# Cut at a byte boundary and drop a trailing partial character.
|
|
48
|
+
return data[:limit].decode("utf-8", errors="ignore"), True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def serialize_body(value: Any, content_type: str | None, limit: int) -> tuple[str, bool] | None:
|
|
52
|
+
"""Serializes a body-like value and truncates it to ``limit`` bytes.
|
|
53
|
+
|
|
54
|
+
Returns None for binary or empty bodies. Raw bodies of binary and multipart
|
|
55
|
+
requests are skipped; already parsed form fields (a dict) are kept.
|
|
56
|
+
"""
|
|
57
|
+
if value is None:
|
|
58
|
+
return None
|
|
59
|
+
if isinstance(value, (bytes, bytearray, memoryview, str)) and is_binary_content_type(
|
|
60
|
+
content_type
|
|
61
|
+
):
|
|
62
|
+
return None
|
|
63
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
64
|
+
raw = bytes(value[: limit + 4])
|
|
65
|
+
if b"\x00" in raw[:512]:
|
|
66
|
+
return None
|
|
67
|
+
text = raw.decode("utf-8", errors="replace")
|
|
68
|
+
if len(value) > limit:
|
|
69
|
+
return truncate(text, limit)[0], True
|
|
70
|
+
elif isinstance(value, str):
|
|
71
|
+
text = value
|
|
72
|
+
else:
|
|
73
|
+
try:
|
|
74
|
+
text = json.dumps(value, ensure_ascii=False, default=str, separators=(",", ":"))
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
return None
|
|
77
|
+
if not text:
|
|
78
|
+
return None
|
|
79
|
+
return truncate(text, limit)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def split_url(url: str) -> tuple[str, str]:
|
|
83
|
+
"""``/a/b?x=1`` -> (``/a/b``, ``x=1``)."""
|
|
84
|
+
path, _, query = url.partition("?")
|
|
85
|
+
return path or "/", query
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def flatten_headers(
|
|
89
|
+
headers: Mapping[str, Any] | Iterable[tuple[str, Any]] | None,
|
|
90
|
+
) -> dict[str, str]:
|
|
91
|
+
"""Lower-cased names; repeated headers joined with ``, ``."""
|
|
92
|
+
out: dict[str, str] = {}
|
|
93
|
+
if headers is None:
|
|
94
|
+
return out
|
|
95
|
+
items = headers.items() if isinstance(headers, Mapping) else headers
|
|
96
|
+
for k, v in items:
|
|
97
|
+
if v is None:
|
|
98
|
+
continue
|
|
99
|
+
name = (k.decode("latin-1") if isinstance(k, bytes) else str(k)).lower()
|
|
100
|
+
val = (
|
|
101
|
+
v.decode("latin-1")
|
|
102
|
+
if isinstance(v, bytes)
|
|
103
|
+
else ", ".join(v)
|
|
104
|
+
if isinstance(v, list)
|
|
105
|
+
else str(v)
|
|
106
|
+
)
|
|
107
|
+
out[name] = f"{out[name]}, {val}" if name in out else val
|
|
108
|
+
return out
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Request-scoped context carried through threads and async tasks with contextvars."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from contextvars import ContextVar, Token
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
Scalar = str | int | float | bool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TrailscopeContext:
|
|
16
|
+
request_id: str
|
|
17
|
+
trace_id: str
|
|
18
|
+
span_id: str
|
|
19
|
+
parent_span_id: str | None = None
|
|
20
|
+
user_id: str | None = None
|
|
21
|
+
#: Journey keys, e.g. ``{"account_id": "acc_1"}``.
|
|
22
|
+
correlation: dict[str, str] = field(default_factory=dict)
|
|
23
|
+
attributes: dict[str, Scalar] = field(default_factory=dict)
|
|
24
|
+
#: Route pattern, once the framework has matched one.
|
|
25
|
+
route: str | None = None
|
|
26
|
+
#: The exception that failed the request, recorded by an error hook.
|
|
27
|
+
error: BaseException | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_current: ContextVar[TrailscopeContext | None] = ContextVar("trailscope_context", default=None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def current_context() -> TrailscopeContext | None:
|
|
34
|
+
return _current.get()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_context(ctx: TrailscopeContext | None) -> Token[TrailscopeContext | None]:
|
|
38
|
+
return _current.set(ctx)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reset_context(token: Token[TrailscopeContext | None]) -> None:
|
|
42
|
+
try:
|
|
43
|
+
_current.reset(token)
|
|
44
|
+
except ValueError:
|
|
45
|
+
# Reset from another context (e.g. a generator finalized elsewhere).
|
|
46
|
+
_current.set(None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@contextmanager
|
|
50
|
+
def use_context(ctx: TrailscopeContext) -> Iterator[TrailscopeContext]:
|
|
51
|
+
"""Runs a block inside ``ctx``."""
|
|
52
|
+
token = _current.set(ctx)
|
|
53
|
+
try:
|
|
54
|
+
yield ctx
|
|
55
|
+
finally:
|
|
56
|
+
reset_context(token)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_user(user_id: str | int | None) -> None:
|
|
60
|
+
"""Attaches a user ID to the current request."""
|
|
61
|
+
ctx = _current.get()
|
|
62
|
+
if ctx is not None and user_id is not None and user_id != "":
|
|
63
|
+
ctx.user_id = str(user_id)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def set_correlation(key: str, value: str | int) -> None:
|
|
67
|
+
"""Adds a correlation (journey) key to the current request and everything it triggers."""
|
|
68
|
+
ctx = _current.get()
|
|
69
|
+
if ctx is not None and key:
|
|
70
|
+
ctx.correlation[key] = str(value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def set_attribute(key: str, value: Scalar) -> None:
|
|
74
|
+
"""Adds a free-form attribute to the current request."""
|
|
75
|
+
ctx = _current.get()
|
|
76
|
+
if ctx is not None and key:
|
|
77
|
+
ctx.attributes[key] = value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def record_error(error: BaseException, ctx: TrailscopeContext | None = None) -> None:
|
|
81
|
+
"""Attaches the exception that failed the request (kept when the response is a 5xx)."""
|
|
82
|
+
ctx = ctx or _current.get()
|
|
83
|
+
if ctx is not None and ctx.error is None:
|
|
84
|
+
ctx.error = error
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def serialize_context(ctx: TrailscopeContext | None = None) -> dict[str, Any] | None:
|
|
88
|
+
"""The part of a context that travels with a background job."""
|
|
89
|
+
ctx = ctx or _current.get()
|
|
90
|
+
if ctx is None:
|
|
91
|
+
return None
|
|
92
|
+
out: dict[str, Any] = {
|
|
93
|
+
"requestId": ctx.request_id,
|
|
94
|
+
"traceId": ctx.trace_id,
|
|
95
|
+
"spanId": ctx.span_id,
|
|
96
|
+
}
|
|
97
|
+
if ctx.user_id:
|
|
98
|
+
out["userId"] = ctx.user_id
|
|
99
|
+
if ctx.correlation:
|
|
100
|
+
out["correlation"] = dict(ctx.correlation)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def deserialize_context(value: object) -> dict[str, Any] | None:
|
|
105
|
+
"""Validates a serialized context (from job data or message headers)."""
|
|
106
|
+
if not isinstance(value, dict):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def s(key: str) -> str | None:
|
|
110
|
+
v = value.get(key)
|
|
111
|
+
return v if isinstance(v, str) and len(v) <= 128 else None
|
|
112
|
+
|
|
113
|
+
correlation: dict[str, str] = {}
|
|
114
|
+
raw = value.get("correlation")
|
|
115
|
+
if isinstance(raw, dict):
|
|
116
|
+
for k, v in raw.items():
|
|
117
|
+
if isinstance(k, str) and isinstance(v, (str, int)):
|
|
118
|
+
correlation[k] = str(v)
|
|
119
|
+
return {
|
|
120
|
+
"request_id": s("requestId"),
|
|
121
|
+
"trace_id": s("traceId"),
|
|
122
|
+
"span_id": s("spanId"),
|
|
123
|
+
"user_id": s("userId"),
|
|
124
|
+
"correlation": correlation,
|
|
125
|
+
}
|