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/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