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 +55 -0
- trailscope/_capture.py +108 -0
- trailscope/_context.py +125 -0
- trailscope/_ids.py +56 -0
- trailscope/_redact.py +168 -0
- trailscope/_version.py +1 -0
- trailscope/asgi.py +194 -0
- trailscope/celery.py +148 -0
- trailscope/client.py +838 -0
- trailscope/django.py +204 -0
- trailscope/handler.py +49 -0
- trailscope/py.typed +0 -0
- trailscope-0.1.0.dist-info/METADATA +81 -0
- trailscope-0.1.0.dist-info/RECORD +15 -0
- trailscope-0.1.0.dist-info/WHEEL +4 -0
trailscope/client.py
ADDED
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
"""The Trailscope client.
|
|
2
|
+
|
|
3
|
+
Every capture method is cheap and never blocks on the network: items go into a
|
|
4
|
+
bounded in-memory queue (the oldest are dropped when it is full) and a
|
|
5
|
+
background thread ships them as gzipped NDJSON. Redaction and serialization
|
|
6
|
+
happen in that thread, off the request path.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import functools
|
|
13
|
+
import gzip
|
|
14
|
+
import inspect
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import random
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import traceback
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
import weakref
|
|
25
|
+
from collections import deque
|
|
26
|
+
from collections.abc import Awaitable, Callable, Iterator, Mapping
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from re import Pattern
|
|
30
|
+
from types import TracebackType
|
|
31
|
+
from typing import Any, Literal, TypeVar, cast
|
|
32
|
+
|
|
33
|
+
from ._capture import BodyCapture, flatten_headers, serialize_body, should_capture_bodies, split_url
|
|
34
|
+
from ._context import Scalar, TrailscopeContext, current_context, reset_context, set_context
|
|
35
|
+
from ._ids import accept_request_id, new_request_id, new_span_id, new_trace_id, parse_traceparent
|
|
36
|
+
from ._redact import Redactor
|
|
37
|
+
from ._version import __version__
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger("trailscope")
|
|
40
|
+
|
|
41
|
+
Item = dict[str, Any]
|
|
42
|
+
Level = Literal["debug", "info", "warn", "error"]
|
|
43
|
+
SpanKind = Literal["http", "db", "cache", "queue", "fn", "external"]
|
|
44
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
45
|
+
|
|
46
|
+
DEFAULT_IGNORE_ROUTES: tuple[str, ...] = ("/health", "/healthz", "/ready", "/readyz", "/livez")
|
|
47
|
+
_CONFIG_REFRESH_SECONDS = 300.0
|
|
48
|
+
_USER_AGENT = f"trailscope-python/{__version__}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _env(name: str) -> str | None:
|
|
52
|
+
v = os.environ.get(name)
|
|
53
|
+
return v if v else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _now_ms() -> int:
|
|
57
|
+
return int(time.time() * 1000)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _clamp(rate: float) -> float:
|
|
61
|
+
return min(1.0, max(0.0, float(rate)))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def error_fields(error: BaseException) -> dict[str, str]:
|
|
65
|
+
"""``errorType``, ``errorMessage`` and ``errorStack`` for the wire format."""
|
|
66
|
+
return {
|
|
67
|
+
"errorType": type(error).__name__,
|
|
68
|
+
"errorMessage": str(error),
|
|
69
|
+
"errorStack": "".join(traceback.format_exception(type(error), error, error.__traceback__)),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class HttpExchange:
|
|
75
|
+
"""One finished HTTP exchange, as framework integrations report it."""
|
|
76
|
+
|
|
77
|
+
#: Epoch milliseconds when the request started.
|
|
78
|
+
timestamp: float
|
|
79
|
+
duration_ms: float
|
|
80
|
+
method: str
|
|
81
|
+
#: Path with the query string, e.g. ``/orders/42?expand=items``.
|
|
82
|
+
url: str
|
|
83
|
+
status: int
|
|
84
|
+
#: Route pattern, e.g. ``/orders/:id``. Falls back to the context's route.
|
|
85
|
+
route: str | None = None
|
|
86
|
+
req_headers: Mapping[str, str] | None = None
|
|
87
|
+
res_headers: Mapping[str, str] | None = None
|
|
88
|
+
req_body: Any = None
|
|
89
|
+
res_body: Any = None
|
|
90
|
+
ip: str | None = None
|
|
91
|
+
req_size: int | None = None
|
|
92
|
+
res_size: int | None = None
|
|
93
|
+
#: Falls back to the context's error.
|
|
94
|
+
error: BaseException | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class ClientStats:
|
|
99
|
+
queued: int
|
|
100
|
+
dropped: int
|
|
101
|
+
sent: int
|
|
102
|
+
rejected: int
|
|
103
|
+
failed_batches: int
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Client:
|
|
107
|
+
"""Ships requests, spans, job runs and log lines to Trailscope.
|
|
108
|
+
|
|
109
|
+
Every option falls back to an environment variable where one exists:
|
|
110
|
+
``TRAILSCOPE_ENDPOINT``, ``TRAILSCOPE_INGEST_KEY``, ``TRAILSCOPE_SERVICE``,
|
|
111
|
+
``TRAILSCOPE_ENVIRONMENT``, ``TRAILSCOPE_RELEASE``, ``TRAILSCOPE_ENABLED=0`` and
|
|
112
|
+
``TRAILSCOPE_DEBUG=1``.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
endpoint: str | None = None,
|
|
119
|
+
ingest_key: str | None = None,
|
|
120
|
+
service: str | None = None,
|
|
121
|
+
environment: str | None = None,
|
|
122
|
+
release: str | None = None,
|
|
123
|
+
enabled: bool | None = None,
|
|
124
|
+
max_queue_size: int = 10_000,
|
|
125
|
+
flush_interval: float = 1.0,
|
|
126
|
+
flush_at: int = 500,
|
|
127
|
+
max_batch_size: int = 1000,
|
|
128
|
+
request_timeout: float = 10.0,
|
|
129
|
+
shutdown_timeout: float = 5.0,
|
|
130
|
+
sample_rate: float | None = None,
|
|
131
|
+
route_sample_rates: Mapping[str, float] | None = None,
|
|
132
|
+
slow_ms: float | None = None,
|
|
133
|
+
capture_bodies: BodyCapture | None = None,
|
|
134
|
+
max_body_bytes: int | None = None,
|
|
135
|
+
ignore_routes: list[str | Pattern[str]] | None = None,
|
|
136
|
+
request_id_header: str = "x-request-id",
|
|
137
|
+
redact_headers: list[str] | None = None,
|
|
138
|
+
redact_keys: list[str] | None = None,
|
|
139
|
+
redact_patterns: list[str | Pattern[str]] | None = None,
|
|
140
|
+
mask_ip: bool | None = None,
|
|
141
|
+
disable_default_redaction: bool = False,
|
|
142
|
+
remote_config: bool = True,
|
|
143
|
+
before_send: Callable[[Item], Item | None] | None = None,
|
|
144
|
+
debug: bool | None = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
self.endpoint = (endpoint or _env("TRAILSCOPE_ENDPOINT") or "http://localhost:8080").rstrip(
|
|
147
|
+
"/"
|
|
148
|
+
)
|
|
149
|
+
self.ingest_key = ingest_key or _env("TRAILSCOPE_INGEST_KEY") or ""
|
|
150
|
+
self.service = service or _env("TRAILSCOPE_SERVICE") or "unknown-service"
|
|
151
|
+
self.environment = environment or _env("TRAILSCOPE_ENVIRONMENT") or "production"
|
|
152
|
+
self.release = release or _env("TRAILSCOPE_RELEASE") or ""
|
|
153
|
+
self.enabled = enabled if enabled is not None else _env("TRAILSCOPE_ENABLED") != "0"
|
|
154
|
+
self.max_queue_size = max(1, max_queue_size)
|
|
155
|
+
self.flush_interval = max(0.01, flush_interval)
|
|
156
|
+
self.flush_at = max(1, flush_at)
|
|
157
|
+
self.max_batch_size = min(10_000, max(1, max_batch_size))
|
|
158
|
+
self.request_timeout = request_timeout
|
|
159
|
+
self.shutdown_timeout = shutdown_timeout
|
|
160
|
+
self.sample_rate = _clamp(sample_rate) if sample_rate is not None else 1.0
|
|
161
|
+
self.route_sample_rates = {k: _clamp(v) for k, v in (route_sample_rates or {}).items()}
|
|
162
|
+
self.slow_ms = slow_ms if slow_ms is not None else 1000.0
|
|
163
|
+
self.capture_bodies: BodyCapture = capture_bodies or "errors"
|
|
164
|
+
self.max_body_bytes = max_body_bytes if max_body_bytes is not None else 8192
|
|
165
|
+
self.ignore_routes: list[str | Pattern[str]] = (
|
|
166
|
+
list(ignore_routes) if ignore_routes is not None else list(DEFAULT_IGNORE_ROUTES)
|
|
167
|
+
)
|
|
168
|
+
self.request_id_header = request_id_header.lower()
|
|
169
|
+
self.remote_config = remote_config
|
|
170
|
+
self.before_send = before_send
|
|
171
|
+
self._redact_args: dict[str, Any] = {
|
|
172
|
+
"headers": redact_headers or [],
|
|
173
|
+
"keys": redact_keys or [],
|
|
174
|
+
"patterns": redact_patterns or [],
|
|
175
|
+
"mask_ip": mask_ip if mask_ip is not None else True,
|
|
176
|
+
"disable_defaults": disable_default_redaction,
|
|
177
|
+
}
|
|
178
|
+
self.redactor = Redactor(**self._redact_args)
|
|
179
|
+
# Options the application set itself win over /v1/config.
|
|
180
|
+
self._explicit = {
|
|
181
|
+
name
|
|
182
|
+
for name, value in (
|
|
183
|
+
("sample_rate", sample_rate),
|
|
184
|
+
("route_sample_rates", route_sample_rates),
|
|
185
|
+
("slow_ms", slow_ms),
|
|
186
|
+
("capture_bodies", capture_bodies),
|
|
187
|
+
("max_body_bytes", max_body_bytes),
|
|
188
|
+
("ignore_routes", ignore_routes),
|
|
189
|
+
("mask_ip", mask_ip),
|
|
190
|
+
)
|
|
191
|
+
if value is not None
|
|
192
|
+
}
|
|
193
|
+
if debug is None:
|
|
194
|
+
debug = _env("TRAILSCOPE_DEBUG") == "1"
|
|
195
|
+
if debug:
|
|
196
|
+
log.setLevel(logging.DEBUG)
|
|
197
|
+
if not log.handlers:
|
|
198
|
+
log.addHandler(logging.StreamHandler())
|
|
199
|
+
|
|
200
|
+
self._queue: deque[Item] = deque()
|
|
201
|
+
self._dropped = 0
|
|
202
|
+
self._sent = 0
|
|
203
|
+
self._rejected = 0
|
|
204
|
+
self._failed_batches = 0
|
|
205
|
+
self._failures = 0
|
|
206
|
+
self._backoff_until = 0.0
|
|
207
|
+
self._warned_auth = False
|
|
208
|
+
self._closed = False
|
|
209
|
+
self._stopping = False
|
|
210
|
+
self._thread: threading.Thread | None = None
|
|
211
|
+
self._lock = threading.Lock()
|
|
212
|
+
self._send_lock = threading.Lock()
|
|
213
|
+
self._wake = threading.Event()
|
|
214
|
+
self._pid = os.getpid()
|
|
215
|
+
if self.enabled and not self.ingest_key:
|
|
216
|
+
log.debug("no ingest key configured (TRAILSCOPE_INGEST_KEY); events will not be sent")
|
|
217
|
+
if hasattr(os, "register_at_fork"):
|
|
218
|
+
ref = weakref.WeakMethod(self._after_fork)
|
|
219
|
+
|
|
220
|
+
def after_fork() -> None:
|
|
221
|
+
method = ref()
|
|
222
|
+
if method is not None:
|
|
223
|
+
method()
|
|
224
|
+
|
|
225
|
+
os.register_at_fork(after_in_child=after_fork)
|
|
226
|
+
# Start now so /v1/config is loaded before the first request; after a
|
|
227
|
+
# fork the thread is restarted by the first capture.
|
|
228
|
+
if self.enabled and self.ingest_key:
|
|
229
|
+
with self._lock:
|
|
230
|
+
self._start_worker()
|
|
231
|
+
|
|
232
|
+
# ---- capture ----------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def capture_request(self, item: Mapping[str, Any]) -> None:
|
|
235
|
+
"""Queues a request in the wire format (camelCase keys, see docs/ingest-protocol.md)."""
|
|
236
|
+
self._enqueue({**item, "type": "request", "service": item.get("service") or self.service})
|
|
237
|
+
|
|
238
|
+
def capture_event(
|
|
239
|
+
self,
|
|
240
|
+
name: str,
|
|
241
|
+
*,
|
|
242
|
+
kind: Literal["job", "log", "custom"] = "custom",
|
|
243
|
+
level: Level | None = None,
|
|
244
|
+
message: str | None = None,
|
|
245
|
+
status: Literal["ok", "warn", "error"] | None = None,
|
|
246
|
+
duration_ms: float | None = None,
|
|
247
|
+
attempt: int | None = None,
|
|
248
|
+
attributes: Mapping[str, Scalar] | None = None,
|
|
249
|
+
error: BaseException | None = None,
|
|
250
|
+
timestamp: float | None = None,
|
|
251
|
+
context: TrailscopeContext | None = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Records an event; IDs, user and correlation come from the current context."""
|
|
254
|
+
ctx = context or current_context()
|
|
255
|
+
item: Item = {
|
|
256
|
+
"type": "event",
|
|
257
|
+
"service": self.service,
|
|
258
|
+
"kind": kind,
|
|
259
|
+
"name": name,
|
|
260
|
+
"level": level,
|
|
261
|
+
"message": message,
|
|
262
|
+
"status": status,
|
|
263
|
+
"durationMs": duration_ms,
|
|
264
|
+
"attempt": attempt,
|
|
265
|
+
"attributes": dict(attributes) if attributes else None,
|
|
266
|
+
"timestamp": timestamp,
|
|
267
|
+
}
|
|
268
|
+
if ctx is not None:
|
|
269
|
+
item["requestId"] = ctx.request_id
|
|
270
|
+
item["traceId"] = ctx.trace_id
|
|
271
|
+
item["userId"] = ctx.user_id
|
|
272
|
+
item["correlation"] = dict(ctx.correlation) if ctx.correlation else None
|
|
273
|
+
if error is not None:
|
|
274
|
+
item.update(error_fields(error))
|
|
275
|
+
self._enqueue(item)
|
|
276
|
+
|
|
277
|
+
def log(
|
|
278
|
+
self,
|
|
279
|
+
level: Level,
|
|
280
|
+
message: str,
|
|
281
|
+
*,
|
|
282
|
+
name: str = "log",
|
|
283
|
+
attributes: Mapping[str, Scalar] | None = None,
|
|
284
|
+
error: BaseException | None = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""An application log line, linked to the current request if there is one."""
|
|
287
|
+
self.capture_event(
|
|
288
|
+
name, kind="log", level=level, message=message, attributes=attributes, error=error
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def capture_exception(
|
|
292
|
+
self,
|
|
293
|
+
error: BaseException,
|
|
294
|
+
*,
|
|
295
|
+
name: str = "exception",
|
|
296
|
+
attributes: Mapping[str, Scalar] | None = None,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Reports an exception as an error event (outside an HTTP request)."""
|
|
299
|
+
self.capture_event(
|
|
300
|
+
name,
|
|
301
|
+
kind="log",
|
|
302
|
+
level="error",
|
|
303
|
+
status="error",
|
|
304
|
+
message=str(error),
|
|
305
|
+
attributes=attributes,
|
|
306
|
+
error=error,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
@contextmanager
|
|
310
|
+
def span(
|
|
311
|
+
self, kind: SpanKind, name: str, attributes: Mapping[str, Scalar] | None = None
|
|
312
|
+
) -> Iterator[None]:
|
|
313
|
+
"""Times a block as a span of the current request."""
|
|
314
|
+
ctx = current_context()
|
|
315
|
+
start = time.time() * 1000
|
|
316
|
+
t0 = time.perf_counter()
|
|
317
|
+
status = "ok"
|
|
318
|
+
try:
|
|
319
|
+
yield
|
|
320
|
+
except BaseException:
|
|
321
|
+
status = "error"
|
|
322
|
+
raise
|
|
323
|
+
finally:
|
|
324
|
+
self._enqueue(
|
|
325
|
+
{
|
|
326
|
+
"type": "span",
|
|
327
|
+
"service": self.service,
|
|
328
|
+
"traceId": ctx.trace_id if ctx else new_trace_id(),
|
|
329
|
+
"spanId": new_span_id(),
|
|
330
|
+
"parentSpanId": ctx.span_id if ctx else None,
|
|
331
|
+
"requestId": ctx.request_id if ctx else None,
|
|
332
|
+
"kind": kind,
|
|
333
|
+
"name": name,
|
|
334
|
+
"start": round(start, 3),
|
|
335
|
+
"durationMs": round((time.perf_counter() - t0) * 1000, 2),
|
|
336
|
+
"status": status,
|
|
337
|
+
"attributes": dict(attributes) if attributes else None,
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def track_job(
|
|
342
|
+
self,
|
|
343
|
+
name: str,
|
|
344
|
+
*,
|
|
345
|
+
attempt: int | None = None,
|
|
346
|
+
context: TrailscopeContext | Mapping[str, Any] | None = None,
|
|
347
|
+
attributes: Mapping[str, Scalar] | None = None,
|
|
348
|
+
) -> JobScope:
|
|
349
|
+
"""Runs a background job inside a context and records it as a ``kind=job`` event.
|
|
350
|
+
|
|
351
|
+
Use it as a context manager or a decorator (sync or async)::
|
|
352
|
+
|
|
353
|
+
with client.track_job("report.build", attempt=1):
|
|
354
|
+
build()
|
|
355
|
+
|
|
356
|
+
``context`` restores a serialized request context (see
|
|
357
|
+
``serialize_context``); by default a job started inside a request
|
|
358
|
+
belongs to that request.
|
|
359
|
+
"""
|
|
360
|
+
return JobScope(self, name, attempt, context, attributes)
|
|
361
|
+
|
|
362
|
+
# ---- helpers for framework integrations ---------------------------------------
|
|
363
|
+
|
|
364
|
+
def context_from_headers(self, get: Callable[[str], str | None]) -> TrailscopeContext:
|
|
365
|
+
"""Builds the context of an incoming request from its headers."""
|
|
366
|
+
tp = parse_traceparent(get("traceparent"))
|
|
367
|
+
return TrailscopeContext(
|
|
368
|
+
request_id=accept_request_id(get(self.request_id_header)) or new_request_id(),
|
|
369
|
+
trace_id=tp.trace_id if tp else new_trace_id(),
|
|
370
|
+
span_id=new_span_id(),
|
|
371
|
+
parent_span_id=tp.parent_span_id if tp else None,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def is_ignored(self, route_or_path: str | None) -> bool:
|
|
375
|
+
if not route_or_path:
|
|
376
|
+
return False
|
|
377
|
+
return any(
|
|
378
|
+
r == route_or_path if isinstance(r, str) else r.search(route_or_path) is not None
|
|
379
|
+
for r in self.ignore_routes
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def sample(self, route: str, status: int, duration_ms: float, has_error: bool = False) -> float:
|
|
383
|
+
"""The sample rate to record when the request is kept, or 0 when it is dropped.
|
|
384
|
+
|
|
385
|
+
Errors (status >= 400) and slow requests are always kept.
|
|
386
|
+
"""
|
|
387
|
+
if status >= 400 or has_error or duration_ms >= self.slow_ms:
|
|
388
|
+
return 1.0
|
|
389
|
+
rate = self.route_sample_rates.get(route, self.sample_rate)
|
|
390
|
+
if rate >= 1:
|
|
391
|
+
return 1.0
|
|
392
|
+
return rate if random.random() < rate else 0.0
|
|
393
|
+
|
|
394
|
+
def record_http(self, ctx: TrailscopeContext, ex: HttpExchange) -> None:
|
|
395
|
+
"""Records a finished HTTP exchange.
|
|
396
|
+
|
|
397
|
+
Applies the ignore list, sampling and the body capture policy. Error
|
|
398
|
+
details are kept only for 5xx responses; a handled 4xx is an expected
|
|
399
|
+
outcome.
|
|
400
|
+
"""
|
|
401
|
+
if not self.enabled or self._closed:
|
|
402
|
+
return
|
|
403
|
+
path, query = split_url(ex.url)
|
|
404
|
+
route = ex.route or ctx.route
|
|
405
|
+
if self.is_ignored(route) or self.is_ignored(path):
|
|
406
|
+
return
|
|
407
|
+
status = ex.status
|
|
408
|
+
duration_ms = round(ex.duration_ms, 2)
|
|
409
|
+
failed = (ex.error or ctx.error) if status >= 500 else None
|
|
410
|
+
rate = self.sample(route or path, status, duration_ms, failed is not None)
|
|
411
|
+
if rate == 0:
|
|
412
|
+
return
|
|
413
|
+
req_headers = flatten_headers(ex.req_headers)
|
|
414
|
+
res_headers = flatten_headers(ex.res_headers)
|
|
415
|
+
item: Item = {
|
|
416
|
+
"type": "request",
|
|
417
|
+
"service": self.service,
|
|
418
|
+
"timestamp": int(ex.timestamp),
|
|
419
|
+
"requestId": ctx.request_id,
|
|
420
|
+
"traceId": ctx.trace_id,
|
|
421
|
+
"spanId": ctx.span_id,
|
|
422
|
+
"method": ex.method.upper(),
|
|
423
|
+
"route": route,
|
|
424
|
+
"path": path,
|
|
425
|
+
"query": query or None,
|
|
426
|
+
"status": status,
|
|
427
|
+
"durationMs": duration_ms,
|
|
428
|
+
"reqSize": ex.req_size,
|
|
429
|
+
"resSize": ex.res_size,
|
|
430
|
+
"userId": ctx.user_id,
|
|
431
|
+
"ip": ex.ip,
|
|
432
|
+
"userAgent": req_headers.get("user-agent"),
|
|
433
|
+
"reqHeaders": req_headers,
|
|
434
|
+
"resHeaders": res_headers,
|
|
435
|
+
"attributes": dict(ctx.attributes) if ctx.attributes else None,
|
|
436
|
+
"correlation": dict(ctx.correlation) if ctx.correlation else None,
|
|
437
|
+
"sampleRate": rate if rate < 1 else None,
|
|
438
|
+
}
|
|
439
|
+
if should_capture_bodies(self.capture_bodies, status, duration_ms, self.slow_ms):
|
|
440
|
+
limit = self.max_body_bytes
|
|
441
|
+
rq = serialize_body(ex.req_body, req_headers.get("content-type"), limit)
|
|
442
|
+
encoding = res_headers.get("content-encoding")
|
|
443
|
+
rs = (
|
|
444
|
+
None
|
|
445
|
+
if encoding and encoding != "identity"
|
|
446
|
+
else serialize_body(ex.res_body, res_headers.get("content-type"), limit)
|
|
447
|
+
)
|
|
448
|
+
item["reqBody"] = rq[0] if rq else None
|
|
449
|
+
item["resBody"] = rs[0] if rs else None
|
|
450
|
+
if (rq and rq[1]) or (rs and rs[1]):
|
|
451
|
+
item["bodyTruncated"] = True
|
|
452
|
+
# Said separately so a cut response alone doesn't block replaying the request.
|
|
453
|
+
item["reqBodyTruncated"] = bool(rq and rq[1])
|
|
454
|
+
if failed is not None:
|
|
455
|
+
item.update(error_fields(failed))
|
|
456
|
+
self._enqueue(item)
|
|
457
|
+
|
|
458
|
+
# ---- lifecycle --------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
def stats(self) -> ClientStats:
|
|
461
|
+
return ClientStats(
|
|
462
|
+
len(self._queue), self._dropped, self._sent, self._rejected, self._failed_batches
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
def flush(self) -> None:
|
|
466
|
+
"""Sends everything queued, in the calling thread."""
|
|
467
|
+
if self.enabled:
|
|
468
|
+
self._drain()
|
|
469
|
+
|
|
470
|
+
def shutdown(self, timeout: float | None = None) -> None:
|
|
471
|
+
"""Stops the background thread after a final flush, waiting at most ``timeout`` seconds."""
|
|
472
|
+
if self._closed:
|
|
473
|
+
return
|
|
474
|
+
self._closed = True
|
|
475
|
+
thread = self._thread
|
|
476
|
+
if thread is None or not thread.is_alive():
|
|
477
|
+
return
|
|
478
|
+
self._backoff_until = 0.0
|
|
479
|
+
self._stopping = True
|
|
480
|
+
self._wake.set()
|
|
481
|
+
thread.join(self.shutdown_timeout if timeout is None else timeout)
|
|
482
|
+
|
|
483
|
+
def refresh_config(self) -> None:
|
|
484
|
+
"""Fetches the project's SDK settings from ``/v1/config`` and applies them."""
|
|
485
|
+
req = urllib.request.Request(
|
|
486
|
+
f"{self.endpoint}/v1/config",
|
|
487
|
+
headers={"authorization": f"Bearer {self.ingest_key}", "user-agent": _USER_AGENT},
|
|
488
|
+
)
|
|
489
|
+
try:
|
|
490
|
+
with urllib.request.urlopen(req, timeout=self.request_timeout) as resp:
|
|
491
|
+
config = json.loads(resp.read())
|
|
492
|
+
except (urllib.error.URLError, OSError, ValueError) as e:
|
|
493
|
+
log.debug("could not load /v1/config: %s", e)
|
|
494
|
+
return
|
|
495
|
+
if isinstance(config, dict):
|
|
496
|
+
self.apply_remote_config(config)
|
|
497
|
+
|
|
498
|
+
def apply_remote_config(self, c: Mapping[str, Any]) -> None:
|
|
499
|
+
"""Applies project settings to every option the application didn't set itself."""
|
|
500
|
+
|
|
501
|
+
def num(v: object) -> bool:
|
|
502
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
503
|
+
|
|
504
|
+
set_ = self._explicit
|
|
505
|
+
if "sample_rate" not in set_ and num(c.get("sampleRate")):
|
|
506
|
+
self.sample_rate = _clamp(c["sampleRate"])
|
|
507
|
+
rates = c.get("routeSampleRates")
|
|
508
|
+
if "route_sample_rates" not in set_ and isinstance(rates, dict):
|
|
509
|
+
self.route_sample_rates = {str(k): _clamp(v) for k, v in rates.items() if num(v)}
|
|
510
|
+
if "slow_ms" not in set_ and num(c.get("slowMs")) and c["slowMs"] > 0:
|
|
511
|
+
self.slow_ms = float(c["slowMs"])
|
|
512
|
+
if "capture_bodies" not in set_ and c.get("bodyCapture") in ("errors", "always", "never"):
|
|
513
|
+
self.capture_bodies = c["bodyCapture"]
|
|
514
|
+
if "max_body_bytes" not in set_ and num(c.get("maxBodyBytes")) and c["maxBodyBytes"] >= 0:
|
|
515
|
+
self.max_body_bytes = int(c["maxBodyBytes"])
|
|
516
|
+
routes = c.get("ignoreRoutes")
|
|
517
|
+
if "ignore_routes" not in set_ and isinstance(routes, list):
|
|
518
|
+
self.ignore_routes = [r for r in routes if isinstance(r, str)]
|
|
519
|
+
mask = c.get("maskIp")
|
|
520
|
+
if "mask_ip" not in set_ and isinstance(mask, bool) and mask != self.redactor.mask_ip:
|
|
521
|
+
self._redact_args["mask_ip"] = mask
|
|
522
|
+
self.redactor = Redactor(**self._redact_args)
|
|
523
|
+
|
|
524
|
+
# ---- internals --------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
def _enqueue(self, item: Item) -> None:
|
|
527
|
+
if not self.enabled or self._closed:
|
|
528
|
+
return
|
|
529
|
+
if item["type"] != "span":
|
|
530
|
+
if not item.get("environment"):
|
|
531
|
+
item["environment"] = self.environment
|
|
532
|
+
if self.release and not item.get("release"):
|
|
533
|
+
item["release"] = self.release
|
|
534
|
+
if item.get("timestamp") is None:
|
|
535
|
+
item["timestamp"] = _now_ms()
|
|
536
|
+
with self._lock:
|
|
537
|
+
if len(self._queue) >= self.max_queue_size:
|
|
538
|
+
self._queue.popleft()
|
|
539
|
+
self._dropped += 1
|
|
540
|
+
self._queue.append(item)
|
|
541
|
+
size = len(self._queue)
|
|
542
|
+
if self._thread is None:
|
|
543
|
+
self._start_worker()
|
|
544
|
+
if size >= self.flush_at:
|
|
545
|
+
self._wake.set()
|
|
546
|
+
|
|
547
|
+
def _start_worker(self) -> None:
|
|
548
|
+
"""Starts the flush thread; the caller holds ``self._lock``."""
|
|
549
|
+
if not self.enabled or self._thread is not None:
|
|
550
|
+
return
|
|
551
|
+
self._thread = threading.Thread(target=self._run, name="trailscope-flush", daemon=True)
|
|
552
|
+
self._thread.start()
|
|
553
|
+
|
|
554
|
+
def _after_fork(self) -> None:
|
|
555
|
+
# The parent ships what it queued; the child starts empty with fresh locks.
|
|
556
|
+
self._lock = threading.Lock()
|
|
557
|
+
self._send_lock = threading.Lock()
|
|
558
|
+
self._wake = threading.Event()
|
|
559
|
+
self._queue = deque()
|
|
560
|
+
self._thread = None
|
|
561
|
+
self._pid = os.getpid()
|
|
562
|
+
|
|
563
|
+
def _run(self) -> None:
|
|
564
|
+
next_config = 0.0
|
|
565
|
+
while True:
|
|
566
|
+
if self.remote_config and self.ingest_key and time.monotonic() >= next_config:
|
|
567
|
+
self.refresh_config()
|
|
568
|
+
next_config = time.monotonic() + _CONFIG_REFRESH_SECONDS
|
|
569
|
+
self._wake.wait(self.flush_interval)
|
|
570
|
+
self._wake.clear()
|
|
571
|
+
try:
|
|
572
|
+
self._drain()
|
|
573
|
+
except Exception: # never let the flush thread die
|
|
574
|
+
log.exception("trailscope flush failed")
|
|
575
|
+
if self._stopping:
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
def _take(self, n: int) -> list[Item]:
|
|
579
|
+
with self._lock:
|
|
580
|
+
count = min(n, len(self._queue))
|
|
581
|
+
return [self._queue.popleft() for _ in range(count)]
|
|
582
|
+
|
|
583
|
+
def _put_back(self, batch: list[Item]) -> None:
|
|
584
|
+
"""Puts a failed batch back at the front, as far as room allows (keeping the newest)."""
|
|
585
|
+
with self._lock:
|
|
586
|
+
room = self.max_queue_size - len(self._queue)
|
|
587
|
+
keep = batch[max(0, len(batch) - room) :]
|
|
588
|
+
self._dropped += len(batch) - len(keep)
|
|
589
|
+
self._queue.extendleft(reversed(keep))
|
|
590
|
+
|
|
591
|
+
def _drain(self) -> None:
|
|
592
|
+
with self._send_lock:
|
|
593
|
+
while True:
|
|
594
|
+
if not self.ingest_key:
|
|
595
|
+
with self._lock:
|
|
596
|
+
self._queue.clear()
|
|
597
|
+
return
|
|
598
|
+
if time.monotonic() < self._backoff_until:
|
|
599
|
+
return
|
|
600
|
+
batch = self._take(self.max_batch_size)
|
|
601
|
+
if not batch:
|
|
602
|
+
return
|
|
603
|
+
prepared = self._prepare(batch)
|
|
604
|
+
if not prepared:
|
|
605
|
+
continue
|
|
606
|
+
result, retry_after = self._send(prepared)
|
|
607
|
+
if result == "ok":
|
|
608
|
+
self._failures = 0
|
|
609
|
+
continue
|
|
610
|
+
if result == "drop":
|
|
611
|
+
continue
|
|
612
|
+
self._failed_batches += 1
|
|
613
|
+
self._failures += 1
|
|
614
|
+
self._put_back(batch)
|
|
615
|
+
backoff = min(60.0, 2.0 ** (self._failures - 1))
|
|
616
|
+
jittered = backoff / 2 + random.random() * backoff / 2
|
|
617
|
+
self._backoff_until = time.monotonic() + max(retry_after or 0.0, jittered)
|
|
618
|
+
log.debug(
|
|
619
|
+
"flush failed; retrying in %.1f s", self._backoff_until - time.monotonic()
|
|
620
|
+
)
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
def _prepare(self, batch: list[Item]) -> list[Item]:
|
|
624
|
+
"""Redaction, ``before_send`` and removal of empty fields, off the request path."""
|
|
625
|
+
r = self.redactor
|
|
626
|
+
out: list[Item] = []
|
|
627
|
+
for original in batch:
|
|
628
|
+
item: Item = {k: v for k, v in original.items() if v is not None}
|
|
629
|
+
if item["type"] == "request":
|
|
630
|
+
ct = item.get("reqHeaders", {}).get("content-type", "")
|
|
631
|
+
res_ct = item.get("resHeaders", {}).get("content-type", "")
|
|
632
|
+
if "reqHeaders" in item:
|
|
633
|
+
item["reqHeaders"] = r.redact_headers(item["reqHeaders"])
|
|
634
|
+
if "resHeaders" in item:
|
|
635
|
+
item["resHeaders"] = r.redact_headers(item["resHeaders"])
|
|
636
|
+
if "reqBody" in item:
|
|
637
|
+
item["reqBody"] = r.redact_body(item["reqBody"], ct)
|
|
638
|
+
if "resBody" in item:
|
|
639
|
+
item["resBody"] = r.redact_body(item["resBody"], res_ct)
|
|
640
|
+
if "query" in item:
|
|
641
|
+
item["query"] = r.redact_query(item["query"])
|
|
642
|
+
if "ip" in item:
|
|
643
|
+
item["ip"] = r.ip(item["ip"])
|
|
644
|
+
if "errorMessage" in item:
|
|
645
|
+
item["errorMessage"] = r.apply_patterns(item["errorMessage"])
|
|
646
|
+
elif item["type"] == "event":
|
|
647
|
+
for key in ("message", "errorMessage"):
|
|
648
|
+
if key in item:
|
|
649
|
+
item[key] = r.apply_patterns(item[key])
|
|
650
|
+
result: Item | None = item
|
|
651
|
+
if self.before_send is not None:
|
|
652
|
+
try:
|
|
653
|
+
result = self.before_send(item)
|
|
654
|
+
except Exception as e:
|
|
655
|
+
log.debug("before_send raised: %s", e)
|
|
656
|
+
if result:
|
|
657
|
+
out.append(result)
|
|
658
|
+
return out
|
|
659
|
+
|
|
660
|
+
def _send(self, items: list[Item]) -> tuple[Literal["ok", "retry", "drop"], float | None]:
|
|
661
|
+
try:
|
|
662
|
+
lines = "\n".join(
|
|
663
|
+
json.dumps(i, ensure_ascii=False, default=str, separators=(",", ":")) for i in items
|
|
664
|
+
)
|
|
665
|
+
body = gzip.compress(lines.encode("utf-8"), compresslevel=6)
|
|
666
|
+
except (TypeError, ValueError) as e:
|
|
667
|
+
log.debug("could not serialize batch: %s", e)
|
|
668
|
+
return "drop", None
|
|
669
|
+
req = urllib.request.Request(
|
|
670
|
+
f"{self.endpoint}/v1/ingest",
|
|
671
|
+
data=body,
|
|
672
|
+
method="POST",
|
|
673
|
+
headers={
|
|
674
|
+
"authorization": f"Bearer {self.ingest_key}",
|
|
675
|
+
"content-type": "application/x-ndjson",
|
|
676
|
+
"content-encoding": "gzip",
|
|
677
|
+
"user-agent": _USER_AGENT,
|
|
678
|
+
},
|
|
679
|
+
)
|
|
680
|
+
retry_after: str | None = None
|
|
681
|
+
payload = b""
|
|
682
|
+
try:
|
|
683
|
+
with urllib.request.urlopen(req, timeout=self.request_timeout) as resp:
|
|
684
|
+
status = resp.status
|
|
685
|
+
payload = resp.read()
|
|
686
|
+
except urllib.error.HTTPError as e:
|
|
687
|
+
status = e.code
|
|
688
|
+
retry_after = e.headers.get("retry-after") if e.headers else None
|
|
689
|
+
e.close()
|
|
690
|
+
except (urllib.error.URLError, OSError, ValueError) as e:
|
|
691
|
+
log.debug("network error: %s", e)
|
|
692
|
+
return "retry", None
|
|
693
|
+
if status in (200, 207):
|
|
694
|
+
try:
|
|
695
|
+
parsed = json.loads(payload) if payload else {}
|
|
696
|
+
except ValueError:
|
|
697
|
+
parsed = {}
|
|
698
|
+
self._sent += int(parsed.get("accepted", len(items)))
|
|
699
|
+
rejected = int(parsed.get("rejected", 0))
|
|
700
|
+
if rejected:
|
|
701
|
+
self._rejected += rejected
|
|
702
|
+
errors = parsed.get("errors") or [{}]
|
|
703
|
+
log.debug("server rejected %d items: %s", rejected, errors[0].get("message", ""))
|
|
704
|
+
return "ok", None
|
|
705
|
+
if status == 429 or status >= 500:
|
|
706
|
+
try:
|
|
707
|
+
after = float(retry_after) if retry_after else None
|
|
708
|
+
except ValueError:
|
|
709
|
+
after = None
|
|
710
|
+
return "retry", after
|
|
711
|
+
if status in (401, 403) and not self._warned_auth:
|
|
712
|
+
self._warned_auth = True
|
|
713
|
+
log.warning(
|
|
714
|
+
"trailscope: ingest key rejected (HTTP %d); check TRAILSCOPE_INGEST_KEY", status
|
|
715
|
+
)
|
|
716
|
+
self._rejected += len(items)
|
|
717
|
+
log.debug("batch dropped: HTTP %d", status)
|
|
718
|
+
return "drop", None
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
class JobScope:
|
|
722
|
+
"""A job run: a context manager and a decorator for sync and async functions."""
|
|
723
|
+
|
|
724
|
+
def __init__(
|
|
725
|
+
self,
|
|
726
|
+
client: Client,
|
|
727
|
+
name: str,
|
|
728
|
+
attempt: int | None,
|
|
729
|
+
context: TrailscopeContext | Mapping[str, Any] | None,
|
|
730
|
+
attributes: Mapping[str, Scalar] | None,
|
|
731
|
+
) -> None:
|
|
732
|
+
self._client = client
|
|
733
|
+
self._name = name
|
|
734
|
+
self._attempt = attempt
|
|
735
|
+
self._context = context
|
|
736
|
+
self._attributes = attributes
|
|
737
|
+
self._token: Any = None
|
|
738
|
+
self._ctx: TrailscopeContext | None = None
|
|
739
|
+
self._t0 = 0.0
|
|
740
|
+
self._timestamp = 0
|
|
741
|
+
|
|
742
|
+
def _fresh(self) -> JobScope:
|
|
743
|
+
return JobScope(self._client, self._name, self._attempt, self._context, self._attributes)
|
|
744
|
+
|
|
745
|
+
def __enter__(self) -> TrailscopeContext:
|
|
746
|
+
parent: TrailscopeContext | Mapping[str, Any] | None = self._context or current_context()
|
|
747
|
+
if isinstance(parent, TrailscopeContext):
|
|
748
|
+
p: Mapping[str, Any] = {
|
|
749
|
+
"request_id": parent.request_id,
|
|
750
|
+
"trace_id": parent.trace_id,
|
|
751
|
+
"span_id": parent.span_id,
|
|
752
|
+
"user_id": parent.user_id,
|
|
753
|
+
"correlation": parent.correlation,
|
|
754
|
+
}
|
|
755
|
+
else:
|
|
756
|
+
p = parent or {}
|
|
757
|
+
self._ctx = TrailscopeContext(
|
|
758
|
+
request_id=p.get("request_id") or new_request_id(),
|
|
759
|
+
trace_id=p.get("trace_id") or new_trace_id(),
|
|
760
|
+
span_id=new_span_id(),
|
|
761
|
+
parent_span_id=p.get("span_id"),
|
|
762
|
+
user_id=p.get("user_id"),
|
|
763
|
+
correlation=dict(p.get("correlation") or {}),
|
|
764
|
+
)
|
|
765
|
+
self._token = set_context(self._ctx)
|
|
766
|
+
self._timestamp = _now_ms()
|
|
767
|
+
self._t0 = time.perf_counter()
|
|
768
|
+
return self._ctx
|
|
769
|
+
|
|
770
|
+
def __exit__(
|
|
771
|
+
self,
|
|
772
|
+
exc_type: type[BaseException] | None,
|
|
773
|
+
exc: BaseException | None,
|
|
774
|
+
tb: TracebackType | None,
|
|
775
|
+
) -> Literal[False]:
|
|
776
|
+
duration = round((time.perf_counter() - self._t0) * 1000, 2)
|
|
777
|
+
failed = exc is not None and not isinstance(
|
|
778
|
+
exc, (GeneratorExit, KeyboardInterrupt, SystemExit)
|
|
779
|
+
)
|
|
780
|
+
self._client.capture_event(
|
|
781
|
+
self._name,
|
|
782
|
+
kind="job",
|
|
783
|
+
level="error" if failed else "info",
|
|
784
|
+
status="error" if failed else "ok",
|
|
785
|
+
message=f"{self._name} failed: {exc}" if failed else f"{self._name} completed",
|
|
786
|
+
duration_ms=duration,
|
|
787
|
+
attempt=self._attempt,
|
|
788
|
+
attributes=self._attributes,
|
|
789
|
+
error=exc if failed else None,
|
|
790
|
+
timestamp=self._timestamp,
|
|
791
|
+
context=self._ctx,
|
|
792
|
+
)
|
|
793
|
+
if self._token is not None:
|
|
794
|
+
reset_context(self._token)
|
|
795
|
+
return False
|
|
796
|
+
|
|
797
|
+
def __call__(self, fn: F) -> F:
|
|
798
|
+
if inspect.iscoroutinefunction(fn):
|
|
799
|
+
afn = cast(Callable[..., Awaitable[Any]], fn)
|
|
800
|
+
|
|
801
|
+
@functools.wraps(fn)
|
|
802
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
803
|
+
with self._fresh():
|
|
804
|
+
return await afn(*args, **kwargs)
|
|
805
|
+
|
|
806
|
+
return cast(F, async_wrapper)
|
|
807
|
+
|
|
808
|
+
@functools.wraps(fn)
|
|
809
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
810
|
+
with self._fresh():
|
|
811
|
+
return fn(*args, **kwargs)
|
|
812
|
+
|
|
813
|
+
return cast(F, wrapper)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
_global: Client | None = None
|
|
817
|
+
_global_lock = threading.Lock()
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def init(**options: Any) -> Client:
|
|
821
|
+
"""Creates the process-wide client used by the framework integrations.
|
|
822
|
+
|
|
823
|
+
Takes the same keyword arguments as :class:`Client`. A previous client is
|
|
824
|
+
shut down. The client flushes when the interpreter exits.
|
|
825
|
+
"""
|
|
826
|
+
global _global
|
|
827
|
+
with _global_lock:
|
|
828
|
+
previous = _global
|
|
829
|
+
_global = Client(**options)
|
|
830
|
+
client = _global
|
|
831
|
+
if previous is not None:
|
|
832
|
+
previous.shutdown()
|
|
833
|
+
atexit.register(client.shutdown)
|
|
834
|
+
return client
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def get_client() -> Client | None:
|
|
838
|
+
return _global
|