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.
@@ -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
+ }