trailscope 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
trailscope/__init__.py ADDED
@@ -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
+ ]
trailscope/_capture.py ADDED
@@ -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
trailscope/_context.py ADDED
@@ -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
+ }
trailscope/_ids.py ADDED
@@ -0,0 +1,56 @@
1
+ """Request, trace and span IDs, and W3C `traceparent`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import secrets
7
+ import uuid
8
+ from dataclasses import dataclass
9
+
10
+ _TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$")
11
+ _REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
12
+
13
+
14
+ def new_request_id() -> str:
15
+ """32 hex characters, like the server's generated IDs."""
16
+ return uuid.uuid4().hex
17
+
18
+
19
+ def new_trace_id() -> str:
20
+ return secrets.token_hex(16)
21
+
22
+
23
+ def new_span_id() -> str:
24
+ return secrets.token_hex(8)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TraceParent:
29
+ trace_id: str
30
+ parent_span_id: str
31
+ sampled: bool
32
+
33
+
34
+ def parse_traceparent(header: str | None) -> TraceParent | None:
35
+ """Parses a W3C `traceparent` header; None when it is missing or invalid."""
36
+ if not header:
37
+ return None
38
+ m = _TRACEPARENT.match(header.strip().lower())
39
+ if not m:
40
+ return None
41
+ version, trace_id, span_id, flags = m.groups()
42
+ if version == "ff" or set(trace_id) == {"0"} or set(span_id) == {"0"}:
43
+ return None
44
+ return TraceParent(trace_id, span_id, (int(flags, 16) & 1) == 1)
45
+
46
+
47
+ def format_traceparent(trace_id: str, span_id: str, sampled: bool = True) -> str:
48
+ return f"00-{trace_id}-{span_id}-{'01' if sampled else '00'}"
49
+
50
+
51
+ def accept_request_id(value: str | None) -> str | None:
52
+ """A client-provided request ID, only if it looks sane."""
53
+ if not value:
54
+ return None
55
+ v = value.strip()
56
+ return v if _REQUEST_ID.match(v) else None
trailscope/_redact.py ADDED
@@ -0,0 +1,168 @@
1
+ """Redaction of headers, bodies, query strings and IPs. Same rules as the Node SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Iterable, Mapping
8
+ from re import Pattern
9
+ from typing import Any
10
+ from urllib.parse import unquote_plus
11
+
12
+ REDACTED = "[REDACTED]"
13
+
14
+ DEFAULT_REDACT_HEADERS = ("authorization", "cookie", "set-cookie", "x-api-key")
15
+
16
+ DEFAULT_REDACT_KEYS = (
17
+ "password",
18
+ "passwd",
19
+ "secret",
20
+ "token",
21
+ "access_token",
22
+ "refresh_token",
23
+ "otp",
24
+ "pin",
25
+ "cvv",
26
+ "card",
27
+ "card_number",
28
+ "iban",
29
+ "ssn",
30
+ )
31
+
32
+ _IPV4 = re.compile(r"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\d{1,3}$")
33
+
34
+
35
+ def _norm_key(k: str) -> str:
36
+ """``accessToken``, ``access-token`` and ``ACCESS_TOKEN`` all become ``accesstoken``."""
37
+ return k.replace("_", "").replace("-", "").lower()
38
+
39
+
40
+ class Redactor:
41
+ def __init__(
42
+ self,
43
+ *,
44
+ headers: Iterable[str] = (),
45
+ keys: Iterable[str] = (),
46
+ patterns: Iterable[str | Pattern[str]] = (),
47
+ mask_ip: bool = True,
48
+ disable_defaults: bool = False,
49
+ ) -> None:
50
+ base_headers = () if disable_defaults else DEFAULT_REDACT_HEADERS
51
+ base_keys: list[str] = [] if disable_defaults else list(DEFAULT_REDACT_KEYS)
52
+ self._headers = {h.lower() for h in (*base_headers, *headers)}
53
+ self._paths: list[list[str]] = []
54
+ simple = list(base_keys)
55
+ for k in keys:
56
+ if "." in k:
57
+ self._paths.append([_norm_key(p) for p in k.split(".")])
58
+ elif k:
59
+ simple.append(k)
60
+ self._keys = {_norm_key(k) for k in simple}
61
+ self._patterns = [re.compile(p) if isinstance(p, str) else p for p in patterns]
62
+ self.mask_ip = mask_ip
63
+ self._raw_keys: Pattern[str] | None = None
64
+ if simple:
65
+ alts = "|".join("[_-]?".join(re.escape(part) for part in k.split("_")) for k in simple)
66
+ self._raw_keys = re.compile(
67
+ r'("(?:' + alts + r')"\s*:\s*)("(?:[^"\\]|\\.)*"?|[^,}\]\s]+)', re.IGNORECASE
68
+ )
69
+
70
+ def redact_headers(self, headers: Mapping[str, str]) -> dict[str, str]:
71
+ out: dict[str, str] = {}
72
+ for name, value in headers.items():
73
+ lower = name.lower()
74
+ out[lower] = REDACTED if lower in self._headers else self.apply_patterns(value)
75
+ return out
76
+
77
+ def redact_body(self, body: str, content_type: str = "") -> str:
78
+ if not body:
79
+ return body
80
+ t = body.lstrip()
81
+ if t.startswith(("{", "[")):
82
+ try:
83
+ parsed = json.loads(body)
84
+ except ValueError:
85
+ return self._redact_raw_text(body)
86
+ return json.dumps(
87
+ self.redact_value(parsed, []), ensure_ascii=False, separators=(",", ":")
88
+ )
89
+ if "x-www-form-urlencoded" in content_type:
90
+ return self.redact_urlencoded(body)
91
+ return self._redact_raw_text(body)
92
+
93
+ def redact_urlencoded(self, s: str) -> str:
94
+ parts = []
95
+ for pair in s.split("&"):
96
+ k, sep, v = pair.partition("=")
97
+ if not sep:
98
+ parts.append(pair)
99
+ continue
100
+ try:
101
+ decoded = unquote_plus(k)
102
+ except Exception:
103
+ decoded = k
104
+ if _norm_key(decoded) in self._keys:
105
+ parts.append(f"{k}={REDACTED}")
106
+ else:
107
+ parts.append(f"{k}={self.apply_patterns(v)}")
108
+ return "&".join(parts)
109
+
110
+ def redact_query(self, q: str) -> str:
111
+ return self.redact_urlencoded(q.lstrip("?")) if q else q
112
+
113
+ def redact_value(self, v: Any, path: list[str]) -> Any:
114
+ """Redacts a structured value: JSON bodies and job payloads."""
115
+ if isinstance(v, list):
116
+ return [self.redact_value(item, [*path, "*"]) for item in v]
117
+ if isinstance(v, dict):
118
+ out: dict[str, Any] = {}
119
+ for k, child in v.items():
120
+ nk = _norm_key(str(k))
121
+ p = [*path, nk]
122
+ if (nk in self._keys or self._path_matches(p)) and child is not None:
123
+ out[k] = REDACTED
124
+ else:
125
+ out[k] = self.redact_value(child, p)
126
+ return out
127
+ if isinstance(v, str):
128
+ return self.apply_patterns(v)
129
+ return v
130
+
131
+ def apply_patterns(self, s: str) -> str:
132
+ for p in self._patterns:
133
+ s = p.sub(REDACTED, s)
134
+ return s
135
+
136
+ def ip(self, ip: str | None) -> str | None:
137
+ if not ip or not self.mask_ip:
138
+ return ip
139
+ return mask_ip(ip)
140
+
141
+ def _redact_raw_text(self, s: str) -> str:
142
+ if self._raw_keys is not None:
143
+ s = self._raw_keys.sub(lambda m: f'{m.group(1)}"{REDACTED}"', s)
144
+ return self.apply_patterns(s)
145
+
146
+ def _path_matches(self, path: list[str]) -> bool:
147
+ return any(
148
+ len(p) == len(path)
149
+ and all(
150
+ want == "*" or got == "*" or want == got for want, got in zip(p, path, strict=True)
151
+ )
152
+ for p in self._paths
153
+ )
154
+
155
+
156
+ def mask_ip(raw: str) -> str:
157
+ """Zeroes the last IPv4 octet, or keeps the first 48 bits of an IPv6 address."""
158
+ ip = raw.strip()
159
+ if ip.startswith("::ffff:") and "." in ip:
160
+ ip = ip[7:]
161
+ m = _IPV4.match(ip)
162
+ if m:
163
+ return f"{m.group(1)}.{m.group(2)}.{m.group(3)}.0"
164
+ if ":" in ip:
165
+ head = ip.split("::")[0]
166
+ parts = [p for p in head.split(":") if p]
167
+ return ":".join(parts[:3]) + "::"
168
+ return ""
trailscope/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"