debugbundle-python 1.5.0__py3-none-any.whl → 2.0.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.
- debugbundle/capture_hooks.py +65 -0
- debugbundle/core.py +342 -259
- debugbundle/delivery_hooks.py +62 -0
- debugbundle/event_support.py +1 -1
- debugbundle/exception_snapshot.py +74 -0
- debugbundle/flush_aggregates.py +37 -0
- debugbundle/probe_capture.py +101 -0
- debugbundle/process_support.py +59 -0
- debugbundle/queue_admission.py +39 -0
- debugbundle/request_capture_policy.py +45 -0
- debugbundle/suppression.py +13 -6
- {debugbundle_python-1.5.0.dist-info → debugbundle_python-2.0.0.dist-info}/METADATA +6 -1
- {debugbundle_python-1.5.0.dist-info → debugbundle_python-2.0.0.dist-info}/RECORD +16 -8
- {debugbundle_python-1.5.0.dist-info → debugbundle_python-2.0.0.dist-info}/WHEEL +0 -0
- {debugbundle_python-1.5.0.dist-info → debugbundle_python-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {debugbundle_python-1.5.0.dist-info → debugbundle_python-2.0.0.dist-info}/top_level.txt +0 -0
debugbundle/core.py
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import json
|
|
4
5
|
import logging
|
|
6
|
+
import os
|
|
5
7
|
import sys
|
|
6
8
|
import threading
|
|
7
|
-
import
|
|
9
|
+
import time
|
|
8
10
|
import uuid
|
|
9
11
|
from collections import deque
|
|
10
12
|
from collections.abc import Callable, Mapping
|
|
11
13
|
from contextvars import ContextVar, Token
|
|
12
|
-
from dataclasses import dataclass
|
|
13
14
|
from random import random
|
|
14
15
|
from typing import Any, Protocol, cast
|
|
15
16
|
|
|
16
17
|
from .acknowledgement import decide_acknowledgement
|
|
17
18
|
from .before_send import BeforeSendHook, apply_before_send
|
|
19
|
+
from .capture_hooks import DebugBundleLogHandler, try_capture_lock
|
|
18
20
|
from .config import (
|
|
19
21
|
BALANCED_CAPTURE_POLICY,
|
|
20
22
|
DEFAULT_PROBES_POLL_INTERVAL_MS,
|
|
@@ -25,6 +27,7 @@ from .config import (
|
|
|
25
27
|
find_matching_remote_probe_directives,
|
|
26
28
|
parse_remote_config,
|
|
27
29
|
)
|
|
30
|
+
from .delivery_hooks import finalize_batch
|
|
28
31
|
from .event_support import (
|
|
29
32
|
DEFAULT_LOG_LEVEL,
|
|
30
33
|
LEVEL_RANKS,
|
|
@@ -32,7 +35,6 @@ from .event_support import (
|
|
|
32
35
|
backend_exception_response_payload,
|
|
33
36
|
correlation_payload,
|
|
34
37
|
event_context,
|
|
35
|
-
is_immediate_request_incident_status,
|
|
36
38
|
iso_now,
|
|
37
39
|
level_enabled,
|
|
38
40
|
normalize_level,
|
|
@@ -44,7 +46,12 @@ from .event_support import (
|
|
|
44
46
|
serialize_error,
|
|
45
47
|
time_now,
|
|
46
48
|
)
|
|
49
|
+
from .exception_snapshot import exception_details
|
|
50
|
+
from .flush_aggregates import prepare_flush_aggregates
|
|
47
51
|
from .logger_integrations import attach_optional_integrations
|
|
52
|
+
from .probe_capture import ProbeEntry, capture_probe
|
|
53
|
+
from .process_support import ensure_current_process
|
|
54
|
+
from .queue_admission import evict_low_priority, high_priority_event, may_prepare_event
|
|
48
55
|
from .redaction import (
|
|
49
56
|
DEFAULT_REDACT_FIELDS,
|
|
50
57
|
UnsafeTelemetry,
|
|
@@ -52,6 +59,7 @@ from .redaction import (
|
|
|
52
59
|
redact_value,
|
|
53
60
|
sanitize_telemetry,
|
|
54
61
|
)
|
|
62
|
+
from .request_capture_policy import should_capture_request_event
|
|
55
63
|
from .suppression import EventSuppressionTracker
|
|
56
64
|
from .transport import HttpTransport, Transport, coerce_transport_response
|
|
57
65
|
from .trigger_token import resolve_request_trigger_directives
|
|
@@ -60,13 +68,8 @@ DEFAULT_BATCH_SIZE = 25
|
|
|
60
68
|
DEFAULT_FLUSH_INTERVAL = 5.0
|
|
61
69
|
DEFAULT_ENDPOINT = "https://api.debugbundle.com/v1/events"
|
|
62
70
|
SCHEMA_VERSION = "2026-03-01"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
@dataclass
|
|
66
|
-
class ProbeEntry:
|
|
67
|
-
label: str
|
|
68
|
-
data: dict[str, object]
|
|
69
|
-
timestamp: str
|
|
71
|
+
MAX_PENDING_EVENTS = 1_000
|
|
72
|
+
MAX_PENDING_BYTES = 8 * 1024 * 1024
|
|
70
73
|
|
|
71
74
|
|
|
72
75
|
class ConfigFetchResponse(Protocol):
|
|
@@ -76,23 +79,6 @@ class ConfigFetchResponse(Protocol):
|
|
|
76
79
|
def json(self) -> object: ...
|
|
77
80
|
|
|
78
81
|
|
|
79
|
-
class DebugBundleLogHandler(logging.Handler):
|
|
80
|
-
def __init__(self, sdk: DebugBundleSdk) -> None:
|
|
81
|
-
super().__init__()
|
|
82
|
-
self._sdk = sdk
|
|
83
|
-
|
|
84
|
-
def emit(self, record: logging.LogRecord) -> None:
|
|
85
|
-
self._sdk.capture_log(
|
|
86
|
-
record.getMessage(),
|
|
87
|
-
level=record.levelname.lower(),
|
|
88
|
-
context={
|
|
89
|
-
"logger_name": record.name,
|
|
90
|
-
"pathname": record.pathname,
|
|
91
|
-
"lineno": record.lineno,
|
|
92
|
-
},
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
|
|
96
82
|
class DebugBundleSdk:
|
|
97
83
|
def __init__(
|
|
98
84
|
self,
|
|
@@ -100,10 +86,12 @@ class DebugBundleSdk:
|
|
|
100
86
|
time_provider: Callable[[], float] | None = None,
|
|
101
87
|
) -> None:
|
|
102
88
|
self._transport_override = transport
|
|
89
|
+
self._pid = os.getpid()
|
|
103
90
|
self._time_provider = time_provider or time_now
|
|
104
91
|
self._lock = threading.RLock()
|
|
105
92
|
self._timer: threading.Timer | None = None
|
|
106
93
|
self._remote_config_timer: threading.Timer | None = None
|
|
94
|
+
self._initial_config_ready = threading.Event()
|
|
107
95
|
self._transport: Transport | None = None
|
|
108
96
|
self._http_transport: HttpTransport | None = None
|
|
109
97
|
self._enabled = False
|
|
@@ -117,6 +105,15 @@ class DebugBundleSdk:
|
|
|
117
105
|
self._sample_rate = 1.0
|
|
118
106
|
self._redact_fields = set(DEFAULT_REDACT_FIELDS)
|
|
119
107
|
self._buffer: list[dict[str, object]] = []
|
|
108
|
+
self._buffer_sizes: dict[int, int] = {}
|
|
109
|
+
self._buffer_bytes = 0
|
|
110
|
+
self._pending_low_priority = 0
|
|
111
|
+
self._sending = False
|
|
112
|
+
self._inflight_count = 0
|
|
113
|
+
self._timer_due_at = 0.0
|
|
114
|
+
self._generation = 0
|
|
115
|
+
self._pressure_drops = 0
|
|
116
|
+
self._last_pressure_report_at = 0.0
|
|
120
117
|
self._context: dict[str, object] = {}
|
|
121
118
|
self._scoped_context: ContextVar[dict[str, object] | None] = ContextVar(
|
|
122
119
|
"debugbundle_scoped_context",
|
|
@@ -147,6 +144,7 @@ class DebugBundleSdk:
|
|
|
147
144
|
|
|
148
145
|
@property
|
|
149
146
|
def status(self) -> str:
|
|
147
|
+
ensure_current_process(self)
|
|
150
148
|
with self._lock:
|
|
151
149
|
if not self._enabled:
|
|
152
150
|
return "disconnected"
|
|
@@ -158,6 +156,7 @@ class DebugBundleSdk:
|
|
|
158
156
|
|
|
159
157
|
@property
|
|
160
158
|
def last_event_at(self) -> float | None:
|
|
159
|
+
ensure_current_process(self)
|
|
161
160
|
with self._lock:
|
|
162
161
|
return self._last_event_at
|
|
163
162
|
|
|
@@ -181,6 +180,7 @@ class DebugBundleSdk:
|
|
|
181
180
|
before_send: BeforeSendHook | None = None,
|
|
182
181
|
probes_poll_interval: int = DEFAULT_PROBES_POLL_INTERVAL_MS,
|
|
183
182
|
) -> None:
|
|
183
|
+
ensure_current_process(self)
|
|
184
184
|
with self._lock:
|
|
185
185
|
self.dispose()
|
|
186
186
|
self._project_token = project_token.strip()
|
|
@@ -199,6 +199,10 @@ class DebugBundleSdk:
|
|
|
199
199
|
self._max_probe_entries_per_label = max(1, max_probe_entries_per_label)
|
|
200
200
|
self._probe_flush_on_error = probe_flush_on_error
|
|
201
201
|
self._buffer = []
|
|
202
|
+
self._buffer_sizes = {}
|
|
203
|
+
self._buffer_bytes = 0
|
|
204
|
+
self._pending_low_priority = 0
|
|
205
|
+
self._generation += 1
|
|
202
206
|
self._context = {}
|
|
203
207
|
self._probe_buffers = {}
|
|
204
208
|
self._suppression = EventSuppressionTracker()
|
|
@@ -211,14 +215,25 @@ class DebugBundleSdk:
|
|
|
211
215
|
self._configured_probes_poll_interval_ms = max(1, int(probes_poll_interval))
|
|
212
216
|
self._remote_config_etag = None
|
|
213
217
|
self._remote_config_snapshot = None
|
|
214
|
-
self._capture_policy = BALANCED_CAPTURE_POLICY
|
|
218
|
+
self._capture_policy = MINIMAL_CAPTURE_POLICY if fetch_impl is not None else BALANCED_CAPTURE_POLICY
|
|
215
219
|
self._transport = self._transport_override
|
|
216
220
|
if self._transport is None and self._enabled:
|
|
217
221
|
self._http_transport = HttpTransport(self._endpoint)
|
|
218
222
|
self._transport = self._http_transport
|
|
219
223
|
self.capture_exceptions()
|
|
220
224
|
if self._enabled and self._fetch_impl is not None:
|
|
221
|
-
self.
|
|
225
|
+
self._initial_config_ready.clear()
|
|
226
|
+
self._remote_config_timer = threading.Timer(0.0, self._refresh_initial_remote_config)
|
|
227
|
+
self._remote_config_timer.daemon = True
|
|
228
|
+
self._remote_config_timer.start()
|
|
229
|
+
else:
|
|
230
|
+
self._initial_config_ready.set()
|
|
231
|
+
|
|
232
|
+
def _refresh_initial_remote_config(self) -> None:
|
|
233
|
+
try:
|
|
234
|
+
self._refresh_remote_config(initial=True)
|
|
235
|
+
finally:
|
|
236
|
+
self._initial_config_ready.set()
|
|
222
237
|
|
|
223
238
|
def capture_exception(self, error: BaseException, context: Mapping[str, object] | None = None) -> None:
|
|
224
239
|
self._capture_exception(error, context=context, handled=True)
|
|
@@ -229,31 +244,33 @@ class DebugBundleSdk:
|
|
|
229
244
|
context: Mapping[str, object] | None = None,
|
|
230
245
|
handled: bool = True,
|
|
231
246
|
) -> None:
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
247
|
+
ensure_current_process(self)
|
|
248
|
+
if not self._enabled or not may_prepare_event(self, True, MAX_PENDING_EVENTS, MAX_PENDING_BYTES):
|
|
249
|
+
return
|
|
250
|
+
try:
|
|
251
|
+
name, message, stack = exception_details(error)
|
|
236
252
|
redacted_context = redact_mapping(dict(context or {}), self._redact_fields)
|
|
237
253
|
request_payload = backend_exception_request_payload(redacted_context.get("request"))
|
|
238
254
|
response_payload = backend_exception_response_payload(redacted_context.get("response"))
|
|
239
255
|
|
|
240
256
|
payload: dict[str, object] = {
|
|
241
|
-
"name":
|
|
242
|
-
"message":
|
|
243
|
-
"stack":
|
|
257
|
+
"name": name,
|
|
258
|
+
"message": message,
|
|
259
|
+
"stack": stack,
|
|
244
260
|
"handled": handled,
|
|
245
261
|
"request": request_payload,
|
|
246
262
|
"response": response_payload,
|
|
247
263
|
"runtime": runtime_process_facts(),
|
|
248
264
|
}
|
|
249
|
-
if self._probe_flush_on_error:
|
|
250
|
-
|
|
265
|
+
if self._probe_flush_on_error and try_capture_lock(self):
|
|
266
|
+
try:
|
|
267
|
+
probe_data = self._build_probe_data()
|
|
268
|
+
finally:
|
|
269
|
+
self._lock.release()
|
|
251
270
|
if probe_data is not None:
|
|
252
271
|
payload["probe_data"] = probe_data
|
|
253
272
|
|
|
254
|
-
event = self.
|
|
255
|
-
self._base_event("backend_exception", payload, context=redacted_context)
|
|
256
|
-
)
|
|
273
|
+
event = self._protect_event(self._base_event("backend_exception", payload, context=redacted_context))
|
|
257
274
|
if event is None or not self._passes_sample_rate():
|
|
258
275
|
return
|
|
259
276
|
event_payload = cast(dict[str, object], event["payload"])
|
|
@@ -261,9 +278,17 @@ class DebugBundleSdk:
|
|
|
261
278
|
f"{event['event_type']}:{event_payload.get('name', '')}:"
|
|
262
279
|
f"{event_payload.get('message', '')}:{event_payload.get('stack', '')}"
|
|
263
280
|
)
|
|
264
|
-
|
|
281
|
+
event_bytes = self._event_bytes(event)
|
|
282
|
+
if not try_capture_lock(self):
|
|
283
|
+
self._pressure_drops += 1
|
|
265
284
|
return
|
|
266
|
-
|
|
285
|
+
try:
|
|
286
|
+
if self._enabled and self._suppression.should_capture(suppression_key, self._time_provider()):
|
|
287
|
+
self._offer_protected_event(event, event_bytes)
|
|
288
|
+
finally:
|
|
289
|
+
self._lock.release()
|
|
290
|
+
except BaseException:
|
|
291
|
+
return
|
|
267
292
|
|
|
268
293
|
def capture_error(self, error: BaseException, context: Mapping[str, object] | None = None) -> None:
|
|
269
294
|
self.capture_exception(error, context=context)
|
|
@@ -274,9 +299,16 @@ class DebugBundleSdk:
|
|
|
274
299
|
level: str = DEFAULT_LOG_LEVEL,
|
|
275
300
|
context: Mapping[str, object] | None = None,
|
|
276
301
|
) -> None:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if not self._enabled
|
|
302
|
+
try:
|
|
303
|
+
normalized_level = normalize_level(level)
|
|
304
|
+
if not self._enabled or self._capture_policy.capture_logs == "off" or not level_enabled(
|
|
305
|
+
normalized_level, self._effective_log_threshold()
|
|
306
|
+
):
|
|
307
|
+
return
|
|
308
|
+
ensure_current_process(self)
|
|
309
|
+
if not may_prepare_event(
|
|
310
|
+
self, normalized_level in ("error", "critical"), MAX_PENDING_EVENTS, MAX_PENDING_BYTES
|
|
311
|
+
):
|
|
280
312
|
return
|
|
281
313
|
payload: dict[str, object] = {
|
|
282
314
|
"message": message,
|
|
@@ -285,15 +317,23 @@ class DebugBundleSdk:
|
|
|
285
317
|
}
|
|
286
318
|
if context:
|
|
287
319
|
payload["attributes"] = redact_mapping(dict(context), self._redact_fields)
|
|
288
|
-
event = self.
|
|
320
|
+
event = self._protect_event(self._base_event("log_event", payload, context=context))
|
|
289
321
|
if (
|
|
290
322
|
event is None
|
|
291
323
|
or not self._passes_sample_rate()
|
|
292
|
-
or self._capture_policy.capture_logs == "off"
|
|
293
|
-
or not level_enabled(normalized_level, self._effective_log_threshold())
|
|
294
324
|
):
|
|
295
325
|
return
|
|
296
|
-
self.
|
|
326
|
+
event_bytes = self._event_bytes(event)
|
|
327
|
+
if not try_capture_lock(self):
|
|
328
|
+
self._pressure_drops += 1
|
|
329
|
+
return
|
|
330
|
+
try:
|
|
331
|
+
if self._log_level_eligible(normalized_level):
|
|
332
|
+
self._offer_protected_event(event, event_bytes)
|
|
333
|
+
finally:
|
|
334
|
+
self._lock.release()
|
|
335
|
+
except BaseException:
|
|
336
|
+
return
|
|
297
337
|
|
|
298
338
|
def capture_request(
|
|
299
339
|
self,
|
|
@@ -301,22 +341,34 @@ class DebugBundleSdk:
|
|
|
301
341
|
response: Mapping[str, object] | None = None,
|
|
302
342
|
context: Mapping[str, object] | None = None,
|
|
303
343
|
) -> None:
|
|
304
|
-
|
|
305
|
-
if not self._enabled:
|
|
344
|
+
try:
|
|
345
|
+
if not self._enabled or not should_capture_request_event(self._capture_policy, request, response):
|
|
346
|
+
return
|
|
347
|
+
ensure_current_process(self)
|
|
348
|
+
status = None if response is None else response.get("status_code") or response.get("response_status")
|
|
349
|
+
if not may_prepare_event(
|
|
350
|
+
self, type(status) is int and status >= 500, MAX_PENDING_EVENTS, MAX_PENDING_BYTES
|
|
351
|
+
):
|
|
306
352
|
return
|
|
307
353
|
payload = request_event_payload(
|
|
308
354
|
redact_mapping(dict(request), self._redact_fields),
|
|
309
355
|
redact_mapping(dict(response or {}), self._redact_fields),
|
|
310
356
|
redact_mapping(dict(context or {}), self._redact_fields),
|
|
311
357
|
)
|
|
312
|
-
event = self.
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
358
|
+
event = self._protect_event(self._base_event("request_event", payload, context=context))
|
|
359
|
+
if event is None or not self._passes_sample_rate():
|
|
360
|
+
return
|
|
361
|
+
event_bytes = self._event_bytes(event)
|
|
362
|
+
if not try_capture_lock(self):
|
|
363
|
+
self._pressure_drops += 1
|
|
318
364
|
return
|
|
319
|
-
|
|
365
|
+
try:
|
|
366
|
+
if self._enabled:
|
|
367
|
+
self._offer_protected_event(event, event_bytes)
|
|
368
|
+
finally:
|
|
369
|
+
self._lock.release()
|
|
370
|
+
except BaseException:
|
|
371
|
+
return
|
|
320
372
|
|
|
321
373
|
def capture_message(
|
|
322
374
|
self,
|
|
@@ -327,15 +379,22 @@ class DebugBundleSdk:
|
|
|
327
379
|
self.capture_log(message, level=level or DEFAULT_LOG_LEVEL, context=context)
|
|
328
380
|
|
|
329
381
|
def set_context(self, key: str, value: object) -> None:
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
self._context[key] = protected.get(key)
|
|
335
|
-
except UnsafeTelemetry:
|
|
382
|
+
try:
|
|
383
|
+
ensure_current_process(self)
|
|
384
|
+
protected = sanitize_telemetry({key: value}, self._redact_fields)
|
|
385
|
+
if not isinstance(protected, dict) or not try_capture_lock(self):
|
|
336
386
|
return
|
|
387
|
+
try:
|
|
388
|
+
next_context = dict(self._context)
|
|
389
|
+
next_context[key] = protected.get(key)
|
|
390
|
+
self._context = next_context
|
|
391
|
+
finally:
|
|
392
|
+
self._lock.release()
|
|
393
|
+
except BaseException:
|
|
394
|
+
return
|
|
337
395
|
|
|
338
396
|
def _bind_scoped_context(self, context: Mapping[str, object]) -> Token[dict[str, object] | None]:
|
|
397
|
+
ensure_current_process(self)
|
|
339
398
|
scoped_context = dict(self._scoped_context.get() or {})
|
|
340
399
|
for key, value in context.items():
|
|
341
400
|
if value is None:
|
|
@@ -351,129 +410,165 @@ class DebugBundleSdk:
|
|
|
351
410
|
self._scoped_context.reset(token)
|
|
352
411
|
|
|
353
412
|
def flush(self) -> None:
|
|
413
|
+
ensure_current_process(self)
|
|
354
414
|
with self._lock:
|
|
355
|
-
if not self._enabled or self._transport is None:
|
|
356
|
-
return
|
|
357
|
-
|
|
358
|
-
self._append_suppression_aggregates()
|
|
359
|
-
if not self._buffer:
|
|
415
|
+
if not self._enabled or self._transport is None or self._sending:
|
|
360
416
|
return
|
|
361
|
-
|
|
417
|
+
if self._timer is not None:
|
|
418
|
+
self._timer.cancel()
|
|
419
|
+
self._timer = None
|
|
362
420
|
now = self._time_provider()
|
|
363
421
|
if now < self._retry_after:
|
|
422
|
+
self._schedule_flush_locked(delay=self._retry_after - now)
|
|
364
423
|
return
|
|
424
|
+
self._sending = True
|
|
425
|
+
generation = self._generation
|
|
426
|
+
suppression = self._suppression.drain_aggregates(now)
|
|
427
|
+
pressure_count = (
|
|
428
|
+
self._pressure_drops
|
|
429
|
+
if self._pressure_drops and now - self._last_pressure_report_at >= 60.0
|
|
430
|
+
and len(self._buffer) < MAX_PENDING_EVENTS else 0
|
|
431
|
+
)
|
|
365
432
|
|
|
366
|
-
|
|
433
|
+
try:
|
|
434
|
+
aggregates, pressure = prepare_flush_aggregates(self, suppression, pressure_count)
|
|
435
|
+
except BaseException:
|
|
436
|
+
aggregates, pressure = [], None
|
|
437
|
+
|
|
438
|
+
with self._lock:
|
|
439
|
+
if generation != self._generation:
|
|
440
|
+
self._sending = False
|
|
441
|
+
self._inflight_count = 0
|
|
442
|
+
if self._buffer:
|
|
443
|
+
self._schedule_flush_locked()
|
|
444
|
+
return
|
|
445
|
+
for aggregate, aggregate_bytes in aggregates:
|
|
446
|
+
self._offer_protected_event(aggregate, aggregate_bytes)
|
|
447
|
+
if (pressure is not None and len(self._buffer) < MAX_PENDING_EVENTS
|
|
448
|
+
and self._buffer_bytes + pressure[1] <= MAX_PENDING_BYTES
|
|
449
|
+
and self._offer_protected_event(*pressure)):
|
|
450
|
+
self._pressure_drops = max(0, self._pressure_drops - pressure_count)
|
|
451
|
+
self._last_pressure_report_at = now
|
|
452
|
+
if not self._buffer:
|
|
453
|
+
self._sending = False
|
|
454
|
+
self._schedule_pressure_report_locked()
|
|
455
|
+
return
|
|
456
|
+
batch = list(self._buffer)
|
|
457
|
+
self._inflight_count = len(batch)
|
|
458
|
+
self._pending_low_priority = 0
|
|
459
|
+
transport = self._transport
|
|
367
460
|
request = {
|
|
368
461
|
"project_token": self._project_token,
|
|
369
462
|
"events": batch,
|
|
370
463
|
}
|
|
371
464
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
self.
|
|
465
|
+
finalized = finalize_batch(self, batch, generation, MAX_PENDING_BYTES)
|
|
466
|
+
|
|
467
|
+
with self._lock:
|
|
468
|
+
if generation != self._generation or finalized is None:
|
|
469
|
+
self._sending = False
|
|
470
|
+
self._inflight_count = 0
|
|
471
|
+
if self._buffer:
|
|
472
|
+
self._schedule_flush_locked()
|
|
377
473
|
return
|
|
474
|
+
batch = finalized
|
|
475
|
+
self._inflight_count = len(batch)
|
|
476
|
+
if not batch:
|
|
477
|
+
self._sending = False
|
|
478
|
+
if self._buffer:
|
|
479
|
+
self._schedule_flush_locked()
|
|
480
|
+
return
|
|
481
|
+
request["events"] = batch
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
response = coerce_transport_response(transport(request))
|
|
485
|
+
except BaseException:
|
|
486
|
+
response = None
|
|
378
487
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
488
|
+
with self._lock:
|
|
489
|
+
self._sending = False
|
|
490
|
+
self._inflight_count = 0
|
|
491
|
+
if generation != self._generation:
|
|
492
|
+
if self._buffer:
|
|
493
|
+
self._schedule_flush_locked()
|
|
494
|
+
return
|
|
495
|
+
try:
|
|
496
|
+
if response is None:
|
|
382
497
|
self._consecutive_failures += 1
|
|
383
|
-
|
|
384
|
-
self._retry_after = now + (retry_after_ms / 1000)
|
|
385
|
-
self._emit_diagnostic(
|
|
386
|
-
"ingestion_acknowledgement_invalid",
|
|
387
|
-
"sdk-python retained a batch after an invalid ingestion acknowledgement",
|
|
388
|
-
metadata={"reason": acknowledgement.reason or "invalid"},
|
|
389
|
-
)
|
|
390
|
-
self._schedule_flush_locked(delay=retry_after_ms / 1000)
|
|
498
|
+
self._schedule_flush_locked()
|
|
391
499
|
return
|
|
392
|
-
|
|
393
|
-
|
|
500
|
+
|
|
501
|
+
if 200 <= response.status_code < 300:
|
|
502
|
+
acknowledgement = decide_acknowledgement(response.body, len(batch))
|
|
503
|
+
if acknowledgement.kind == "protocol_failure":
|
|
504
|
+
self._consecutive_failures += 1
|
|
505
|
+
retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
|
|
506
|
+
self._retry_after = now + (retry_after_ms / 1000)
|
|
507
|
+
self._emit_diagnostic(
|
|
508
|
+
"ingestion_acknowledgement_invalid",
|
|
509
|
+
"sdk-python retained a batch after an invalid ingestion acknowledgement",
|
|
510
|
+
metadata={"reason": acknowledgement.reason or "invalid"},
|
|
511
|
+
)
|
|
512
|
+
self._schedule_flush_locked(delay=retry_after_ms / 1000)
|
|
513
|
+
return
|
|
514
|
+
if acknowledgement.kind == "legacy":
|
|
515
|
+
self._buffer = self._buffer[len(batch) :]
|
|
516
|
+
self._retry_after = 0.0
|
|
517
|
+
self._last_event_at = self._time_provider() * 1000
|
|
518
|
+
self._consecutive_failures = 0
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
trailing_events = self._buffer[len(batch) :]
|
|
522
|
+
self._buffer = [
|
|
523
|
+
batch[index] for index in acknowledgement.retryable_indices if 0 <= index < len(batch)
|
|
524
|
+
] + trailing_events
|
|
525
|
+
if acknowledgement.terminal_errors:
|
|
526
|
+
self._emit_diagnostic(
|
|
527
|
+
"ingestion_events_rejected",
|
|
528
|
+
"sdk-python removed terminally rejected ingestion events",
|
|
529
|
+
metadata={
|
|
530
|
+
"rejected_count": len(acknowledgement.terminal_errors),
|
|
531
|
+
"reasons": sorted({reason for _, reason in acknowledgement.terminal_errors}),
|
|
532
|
+
},
|
|
533
|
+
)
|
|
534
|
+
if acknowledgement.accepted > 0:
|
|
535
|
+
self._last_event_at = self._time_provider() * 1000
|
|
536
|
+
if acknowledgement.retryable_indices:
|
|
537
|
+
self._consecutive_failures += 1
|
|
538
|
+
retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
|
|
539
|
+
self._retry_after = now + (retry_after_ms / 1000)
|
|
540
|
+
self._schedule_flush_locked(delay=retry_after_ms / 1000)
|
|
541
|
+
return
|
|
394
542
|
self._retry_after = 0.0
|
|
395
|
-
self.
|
|
396
|
-
self._consecutive_failures = 0
|
|
543
|
+
self._consecutive_failures = 0 if acknowledgement.accepted > 0 else 3
|
|
397
544
|
return
|
|
398
545
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
batch[index] for index in acknowledgement.retryable_indices if 0 <= index < len(batch)
|
|
402
|
-
] + trailing_events
|
|
403
|
-
if acknowledgement.terminal_errors:
|
|
404
|
-
self._emit_diagnostic(
|
|
405
|
-
"ingestion_events_rejected",
|
|
406
|
-
"sdk-python removed terminally rejected ingestion events",
|
|
407
|
-
metadata={
|
|
408
|
-
"rejected_count": len(acknowledgement.terminal_errors),
|
|
409
|
-
"reasons": sorted({reason for _, reason in acknowledgement.terminal_errors}),
|
|
410
|
-
},
|
|
411
|
-
)
|
|
412
|
-
if acknowledgement.accepted > 0:
|
|
413
|
-
self._last_event_at = self._time_provider() * 1000
|
|
414
|
-
if acknowledgement.retryable_indices:
|
|
415
|
-
self._consecutive_failures += 1
|
|
546
|
+
self._consecutive_failures += 1
|
|
547
|
+
if response.status_code == 429:
|
|
416
548
|
retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
|
|
417
549
|
self._retry_after = now + (retry_after_ms / 1000)
|
|
418
550
|
self._schedule_flush_locked(delay=retry_after_ms / 1000)
|
|
419
551
|
return
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
self._consecutive_failures += 1
|
|
425
|
-
if response.status_code == 429:
|
|
426
|
-
retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
|
|
427
|
-
self._retry_after = now + (retry_after_ms / 1000)
|
|
428
|
-
self._schedule_flush_locked(delay=retry_after_ms / 1000)
|
|
429
|
-
return
|
|
430
|
-
|
|
431
|
-
if 400 <= response.status_code < 500:
|
|
432
|
-
self._buffer = []
|
|
433
|
-
self._retry_after = 0.0
|
|
434
|
-
return
|
|
435
|
-
|
|
436
|
-
self._schedule_flush_locked()
|
|
437
|
-
|
|
438
|
-
def probe(self, label: str, data: object | Callable[[], object], opts: Mapping[str, object] | None = None) -> None:
|
|
439
|
-
with self._lock:
|
|
440
|
-
if not self._enabled:
|
|
441
|
-
return
|
|
442
|
-
options = dict(opts or {})
|
|
443
|
-
now_ms = int(self._time_provider() * 1000)
|
|
444
|
-
matching_directives = self._find_matching_probe_directives(label, now_ms)
|
|
445
|
-
is_heavy = options.get("heavy") is True
|
|
446
|
-
if is_heavy and not matching_directives:
|
|
447
|
-
return
|
|
448
|
-
if label not in self._probe_buffers and len(self._probe_buffers) >= self._max_probe_labels:
|
|
449
|
-
return
|
|
450
|
-
|
|
451
|
-
try:
|
|
452
|
-
protected_label = sanitize_telemetry(label, self._redact_fields)
|
|
453
|
-
value = data() if callable(data) else data
|
|
454
|
-
if not isinstance(value, Mapping):
|
|
455
|
-
value = {"value": value}
|
|
456
|
-
redacted_value = sanitize_telemetry(dict(value), self._redact_fields)
|
|
457
|
-
if not isinstance(protected_label, str) or not isinstance(redacted_value, dict):
|
|
552
|
+
if 400 <= response.status_code < 500:
|
|
553
|
+
self._buffer = self._buffer[len(batch) :]
|
|
554
|
+
self._retry_after = 0.0
|
|
458
555
|
return
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
self.
|
|
465
|
-
|
|
556
|
+
self._schedule_flush_locked()
|
|
557
|
+
finally:
|
|
558
|
+
self._buffer_sizes = {id(event): self._buffer_sizes[id(event)]
|
|
559
|
+
for event in self._buffer}
|
|
560
|
+
self._buffer_bytes = sum(self._buffer_sizes.values())
|
|
561
|
+
self._pending_low_priority = sum(not high_priority_event(event) for event in self._buffer)
|
|
562
|
+
if self._buffer and self._timer is None and self._retry_after <= self._time_provider():
|
|
563
|
+
self._schedule_flush_locked()
|
|
564
|
+
elif not self._buffer and self._timer is None:
|
|
565
|
+
self._schedule_pressure_report_locked()
|
|
466
566
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
data=redacted_value,
|
|
470
|
-
timestamp=iso_now(self._time_provider),
|
|
471
|
-
)
|
|
472
|
-
bucket = self._probe_buffers.setdefault(label, deque(maxlen=self._max_probe_entries_per_label))
|
|
473
|
-
bucket.append(entry)
|
|
474
|
-
self._emit_probe_events(label, redacted_value, matching_directives)
|
|
567
|
+
def probe(self, label: str, data: object | Callable[[], object], opts: Mapping[str, object] | None = None) -> None:
|
|
568
|
+
capture_probe(self, label, data, opts)
|
|
475
569
|
|
|
476
570
|
def capture_exceptions(self) -> None:
|
|
571
|
+
ensure_current_process(self)
|
|
477
572
|
with self._lock:
|
|
478
573
|
if self._original_excepthook is None:
|
|
479
574
|
self._original_excepthook = sys.excepthook
|
|
@@ -486,6 +581,7 @@ class DebugBundleSdk:
|
|
|
486
581
|
sys.excepthook = handler
|
|
487
582
|
|
|
488
583
|
def capture_logging(self, logger: logging.Logger | None = None) -> None:
|
|
584
|
+
ensure_current_process(self)
|
|
489
585
|
with self._lock:
|
|
490
586
|
target_logger = logger or logging.getLogger()
|
|
491
587
|
logger_id = id(target_logger)
|
|
@@ -500,6 +596,7 @@ class DebugBundleSdk:
|
|
|
500
596
|
self._optional_logging_restorers = attach_optional_integrations(self, self._on_diagnostic)
|
|
501
597
|
|
|
502
598
|
def capture_async(self, loop: asyncio.AbstractEventLoop | None = None) -> None:
|
|
599
|
+
ensure_current_process(self)
|
|
503
600
|
with self._lock:
|
|
504
601
|
target_loop = loop or asyncio.get_event_loop()
|
|
505
602
|
if target_loop in self._async_handlers:
|
|
@@ -517,6 +614,7 @@ class DebugBundleSdk:
|
|
|
517
614
|
target_loop.set_exception_handler(handler)
|
|
518
615
|
|
|
519
616
|
def dispose(self) -> None:
|
|
617
|
+
ensure_current_process(self)
|
|
520
618
|
with self._lock:
|
|
521
619
|
if self._timer is not None:
|
|
522
620
|
self._timer.cancel()
|
|
@@ -544,32 +642,43 @@ class DebugBundleSdk:
|
|
|
544
642
|
with self._lock:
|
|
545
643
|
if not self._enabled or self._fetch_impl is None:
|
|
546
644
|
return
|
|
547
|
-
|
|
548
645
|
request_headers: dict[str, str] = {}
|
|
549
646
|
if self._remote_config_etag is not None:
|
|
550
647
|
request_headers["if-none-match"] = self._remote_config_etag
|
|
648
|
+
fetch = self._fetch_impl
|
|
649
|
+
endpoint = sdk_config_endpoint(self._endpoint)
|
|
650
|
+
poll_interval = self._configured_probes_poll_interval_ms
|
|
651
|
+
generation = self._generation
|
|
551
652
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
},
|
|
559
|
-
)
|
|
560
|
-
status_code = getattr(response, "status_code", None)
|
|
561
|
-
if status_code == 304:
|
|
562
|
-
self._schedule_next_remote_config_refresh()
|
|
563
|
-
return
|
|
564
|
-
if status_code != 200:
|
|
565
|
-
raise RuntimeError(f"unexpected config status {status_code}")
|
|
566
|
-
|
|
567
|
-
payload = response.json()
|
|
653
|
+
try:
|
|
654
|
+
response = fetch(endpoint, {"method": "GET", "headers": request_headers})
|
|
655
|
+
status_code = getattr(response, "status_code", None)
|
|
656
|
+
if status_code == 304:
|
|
657
|
+
snapshot = None
|
|
658
|
+
elif status_code == 200:
|
|
568
659
|
snapshot = parse_remote_config(
|
|
569
|
-
|
|
570
|
-
|
|
660
|
+
response.json(),
|
|
661
|
+
poll_interval,
|
|
571
662
|
int(self._time_provider() * 1000),
|
|
572
663
|
)
|
|
664
|
+
else:
|
|
665
|
+
raise RuntimeError(f"unexpected config status {status_code}")
|
|
666
|
+
headers = response.headers or {}
|
|
667
|
+
etag = headers.get("etag")
|
|
668
|
+
failure: BaseException | None = None
|
|
669
|
+
except BaseException as error:
|
|
670
|
+
status_code = None
|
|
671
|
+
snapshot = None
|
|
672
|
+
etag = None
|
|
673
|
+
failure = error
|
|
674
|
+
|
|
675
|
+
with self._lock:
|
|
676
|
+
if generation != self._generation or not self._enabled:
|
|
677
|
+
return
|
|
678
|
+
if failure is None and status_code == 304:
|
|
679
|
+
self._schedule_next_remote_config_refresh()
|
|
680
|
+
return
|
|
681
|
+
if failure is None:
|
|
573
682
|
if snapshot is None:
|
|
574
683
|
self._emit_diagnostic(
|
|
575
684
|
"remote_probe_config_invalid",
|
|
@@ -582,16 +691,14 @@ class DebugBundleSdk:
|
|
|
582
691
|
|
|
583
692
|
self._remote_config_snapshot = snapshot
|
|
584
693
|
self._capture_policy = snapshot.capture_policy
|
|
585
|
-
headers = response.headers or {}
|
|
586
|
-
etag = headers.get("etag")
|
|
587
694
|
if isinstance(etag, str) and len(etag) > 0:
|
|
588
695
|
self._remote_config_etag = etag
|
|
589
696
|
self._schedule_next_remote_config_refresh()
|
|
590
|
-
|
|
697
|
+
else:
|
|
591
698
|
self._emit_diagnostic(
|
|
592
699
|
"remote_probe_config_failed",
|
|
593
700
|
"sdk-python failed to refresh remote probe config",
|
|
594
|
-
metadata={"error": serialize_error(
|
|
701
|
+
metadata={"error": serialize_error(failure) if isinstance(failure, Exception) else "fetch_failed"},
|
|
595
702
|
)
|
|
596
703
|
if initial:
|
|
597
704
|
self._capture_policy = MINIMAL_CAPTURE_POLICY
|
|
@@ -646,37 +753,58 @@ class DebugBundleSdk:
|
|
|
646
753
|
except UnsafeTelemetry:
|
|
647
754
|
return None
|
|
648
755
|
|
|
649
|
-
def _enqueue_event(self, event: dict[str, object]) ->
|
|
756
|
+
def _enqueue_event(self, event: dict[str, object]) -> bool:
|
|
650
757
|
protected = self._protect_event(event)
|
|
651
758
|
if protected is None:
|
|
652
|
-
return
|
|
653
|
-
self.
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
759
|
+
return False
|
|
760
|
+
return self._offer_protected_event(protected, self._event_bytes(protected))
|
|
761
|
+
|
|
762
|
+
def _offer_protected_event(self, protected: dict[str, object], event_bytes: int) -> bool:
|
|
763
|
+
if event_bytes > MAX_PENDING_BYTES:
|
|
764
|
+
self._pressure_drops += 1
|
|
765
|
+
return False
|
|
766
|
+
high_priority = high_priority_event(protected)
|
|
767
|
+
under_pressure = len(self._buffer) >= MAX_PENDING_EVENTS or self._buffer_bytes + event_bytes > MAX_PENDING_BYTES
|
|
768
|
+
while len(self._buffer) >= MAX_PENDING_EVENTS or self._buffer_bytes + event_bytes > MAX_PENDING_BYTES:
|
|
769
|
+
if not high_priority or not evict_low_priority(self):
|
|
770
|
+
self._pressure_drops += 1
|
|
771
|
+
return False
|
|
772
|
+
insertion = self._inflight_count
|
|
773
|
+
if high_priority and (protected.get("event_type") != "request_event" or under_pressure):
|
|
774
|
+
while insertion < len(self._buffer) and high_priority_event(self._buffer[insertion]):
|
|
775
|
+
insertion += 1
|
|
776
|
+
self._buffer.insert(insertion, protected)
|
|
777
|
+
else:
|
|
778
|
+
self._buffer.append(protected)
|
|
779
|
+
self._pending_low_priority += 1
|
|
780
|
+
self._buffer_bytes += event_bytes
|
|
781
|
+
self._buffer_sizes[id(protected)] = event_bytes
|
|
782
|
+
self._schedule_flush_locked(delay=0.0 if len(self._buffer) >= self._batch_size else None)
|
|
783
|
+
return True
|
|
784
|
+
|
|
785
|
+
@staticmethod
|
|
786
|
+
def _event_bytes(event: dict[str, object]) -> int:
|
|
787
|
+
return len(json.dumps(event, separators=(",", ":")).encode("utf-8"))
|
|
658
788
|
|
|
659
789
|
def _schedule_flush_locked(self, delay: float | None = None) -> None:
|
|
660
|
-
if self.
|
|
661
|
-
|
|
790
|
+
if self._sending:
|
|
791
|
+
return
|
|
662
792
|
next_delay = self._flush_interval if delay is None else max(delay, 0.0)
|
|
793
|
+
due_at = time.monotonic() + next_delay
|
|
794
|
+
if self._timer is not None and self._timer.is_alive():
|
|
795
|
+
if self._timer_due_at <= due_at:
|
|
796
|
+
return
|
|
797
|
+
self._timer.cancel()
|
|
663
798
|
self._timer = threading.Timer(next_delay, self.flush)
|
|
664
799
|
self._timer.daemon = True
|
|
800
|
+
self._timer_due_at = due_at
|
|
665
801
|
self._timer.start()
|
|
666
802
|
|
|
667
|
-
def
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
if not isinstance(event_type, str) or not isinstance(payload, dict):
|
|
673
|
-
continue
|
|
674
|
-
aggregate.update(self._base_event(event_type, cast(dict[str, object], payload)))
|
|
675
|
-
prepared = self._apply_before_send_event(aggregate)
|
|
676
|
-
if prepared is not None:
|
|
677
|
-
protected = self._protect_event(prepared)
|
|
678
|
-
if protected is not None:
|
|
679
|
-
self._buffer.append(protected)
|
|
803
|
+
def _schedule_pressure_report_locked(self) -> None:
|
|
804
|
+
if self._pressure_drops == 0:
|
|
805
|
+
return
|
|
806
|
+
delay = max(self._flush_interval, self._last_pressure_report_at + 60.0 - self._time_provider())
|
|
807
|
+
self._schedule_flush_locked(delay=delay)
|
|
680
808
|
|
|
681
809
|
def _apply_before_send_event(self, event: dict[str, object]) -> dict[str, object] | None:
|
|
682
810
|
protected = self._protect_event(event)
|
|
@@ -712,60 +840,15 @@ class DebugBundleSdk:
|
|
|
712
840
|
policy_threshold = self._capture_policy.capture_logs
|
|
713
841
|
return self._log_level if LEVEL_RANKS[self._log_level] >= LEVEL_RANKS[policy_threshold] else policy_threshold
|
|
714
842
|
|
|
715
|
-
def
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
status_code = None
|
|
722
|
-
if response is not None:
|
|
723
|
-
candidate = response.get("status_code") or response.get("response_status")
|
|
724
|
-
if isinstance(candidate, int):
|
|
725
|
-
status_code = candidate
|
|
726
|
-
request_path = None
|
|
727
|
-
http_method = None
|
|
728
|
-
if request is not None:
|
|
729
|
-
path_candidate = request.get("path") or request.get("url")
|
|
730
|
-
method_candidate = request.get("method")
|
|
731
|
-
request_path = path_candidate if isinstance(path_candidate, str) else None
|
|
732
|
-
http_method = method_candidate if isinstance(method_candidate, str) else None
|
|
733
|
-
if is_immediate_request_incident_status(
|
|
734
|
-
status_code,
|
|
735
|
-
self._capture_policy.preset,
|
|
736
|
-
self._capture_policy.immediate_client_error_statuses,
|
|
737
|
-
request_path,
|
|
738
|
-
http_method,
|
|
739
|
-
self._capture_policy.immediate_client_error_path_rules,
|
|
740
|
-
):
|
|
741
|
-
return True
|
|
742
|
-
if policy == "off":
|
|
743
|
-
return False
|
|
744
|
-
if policy == "all":
|
|
745
|
-
return True
|
|
746
|
-
if response is None:
|
|
747
|
-
return policy == "filtered"
|
|
748
|
-
if status_code is None:
|
|
749
|
-
return policy == "filtered"
|
|
750
|
-
if policy == "failures_only":
|
|
751
|
-
return status_code >= 500
|
|
752
|
-
if policy == "filtered":
|
|
753
|
-
return False
|
|
754
|
-
return True
|
|
755
|
-
|
|
756
|
-
def _emit_probe_events(self, label: str, data: dict[str, object], directives: list[RemoteProbeDirective]) -> None:
|
|
757
|
-
for directive in directives:
|
|
758
|
-
payload = {
|
|
759
|
-
"label": label,
|
|
760
|
-
"activation_id": getattr(directive, "id"),
|
|
761
|
-
"probe_label_pattern": getattr(directive, "label_pattern"),
|
|
762
|
-
"data": dict(data),
|
|
763
|
-
}
|
|
764
|
-
event = self._apply_before_send_event(self._base_event("probe_event", payload))
|
|
765
|
-
if event is not None and self._capture_policy.capture_probe_events == "standalone_when_activated":
|
|
766
|
-
self._enqueue_event(event)
|
|
843
|
+
def _log_level_eligible(self, level: str) -> bool:
|
|
844
|
+
return (
|
|
845
|
+
self._enabled
|
|
846
|
+
and self._capture_policy.capture_logs != "off"
|
|
847
|
+
and level_enabled(normalize_level(level), self._effective_log_threshold())
|
|
848
|
+
)
|
|
767
849
|
|
|
768
850
|
def begin_request(self, request: dict[str, Any]) -> Token[list[RemoteProbeDirective] | None]:
|
|
851
|
+
ensure_current_process(self)
|
|
769
852
|
trigger_token_key = (
|
|
770
853
|
self._remote_config_snapshot.trigger_token_key if self._remote_config_snapshot is not None else None
|
|
771
854
|
)
|