scope-analytics 0.1.6__tar.gz → 0.1.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scope_analytics-0.1.6/scope_analytics.egg-info → scope_analytics-0.1.8}/PKG-INFO +1 -1
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/__init__.py +144 -15
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/client.py +137 -23
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/context.py +122 -7
- scope_analytics-0.1.8/scope_analytics/drops.py +60 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/middleware.py +195 -50
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/queue.py +16 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/supported_versions.py +4 -3
- {scope_analytics-0.1.6 → scope_analytics-0.1.8/scope_analytics.egg-info}/PKG-INFO +1 -1
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics.egg-info/SOURCES.txt +7 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/setup.py +1 -1
- scope_analytics-0.1.8/tests/test_drop_reports_0_1_8.py +429 -0
- scope_analytics-0.1.8/tests/test_identify_0_1_7.py +744 -0
- scope_analytics-0.1.8/tests/test_identify_pinned.py +455 -0
- scope_analytics-0.1.8/tests/test_shipping_pinned.py +264 -0
- scope_analytics-0.1.8/tests/test_tracking_0_1_7.py +402 -0
- scope_analytics-0.1.8/tests/test_tracking_pinned.py +627 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/LICENSE +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/MANIFEST.in +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/README.md +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/auto.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/cli.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/config.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/deployment.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/events.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/__init__.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/_capture.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/_raw_response.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/_streaming.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/anthropic_patch.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/gemini_patch.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/google_genai_patch.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/http_patch.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/openai_patch.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics.egg-info/dependency_links.txt +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics.egg-info/entry_points.txt +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics.egg-info/requires.txt +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics.egg-info/top_level.txt +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/setup.cfg +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_capture_contract.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_capture_contract_google.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_cli_exec.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_client_ip.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_coverage_honesty.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_deployment.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_discard_reply.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_fork_safety.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_frameworks_langchain.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_http_patch.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_identify.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_identity_bridge.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_ids_0_1_6.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_manual_event_and_debug_output.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_patch_versions.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_query_scrub.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_redaction.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_user_facing.py +0 -0
|
@@ -4,16 +4,18 @@ AI-powered analytics with automatic LLM conversation tracking
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import atexit
|
|
7
|
+
import contextlib
|
|
7
8
|
import logging
|
|
8
9
|
import sys
|
|
9
10
|
from typing import Any, Optional
|
|
10
11
|
|
|
11
12
|
from .config import ScopeConfig, DEFAULT_REDACT_PATTERNS
|
|
12
|
-
from .context import ScopeContext, accepted_user_id
|
|
13
|
+
from .context import ScopeContext, accepted_link, accepted_user_id
|
|
13
14
|
from .deployment import DeploymentContext
|
|
14
15
|
from .events import EventFormatter
|
|
15
16
|
from .queue import EventQueue
|
|
16
|
-
from .client import ScopeAPIClient
|
|
17
|
+
from .client import MAX_EVENTS_PER_REQUEST, ScopeAPIClient
|
|
18
|
+
from .drops import DropCounts
|
|
17
19
|
from .patches.openai_patch import OpenAIPatcher
|
|
18
20
|
from .patches.anthropic_patch import AnthropicPatcher
|
|
19
21
|
from .patches.gemini_patch import GeminiPatcher
|
|
@@ -83,6 +85,65 @@ def _set_sdk_instance(instance: "ScopeAnalytics") -> None:
|
|
|
83
85
|
_global_sdk_instance = instance
|
|
84
86
|
|
|
85
87
|
|
|
88
|
+
# Celery's task_prerun and task_postrun receivers (ScopeAnalytics._connect_celery): never into the task's way.
|
|
89
|
+
def _celery_task_starts(task_id=None, **_kwargs):
|
|
90
|
+
try:
|
|
91
|
+
ScopeContext.task_starts(task_id)
|
|
92
|
+
except Exception: # noqa: BLE001 - a task runs whatever the SDK does
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _celery_task_ends(task_id=None, **_kwargs):
|
|
97
|
+
try:
|
|
98
|
+
ScopeContext.task_ends(task_id)
|
|
99
|
+
except Exception: # noqa: BLE001
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _run_first(signal, receiver) -> None:
|
|
104
|
+
"""Move a receiver to the front of a Celery signal's receivers: Celery calls them in the order they were
|
|
105
|
+
connected, and under scope-run the SDK connects after the app (in each forked child, or at the first
|
|
106
|
+
identify). Scope's task start must run before the app's own task_prerun handlers — they set the task's
|
|
107
|
+
context, which Scope's start would otherwise keep (and hand back to the caller of an eager task) and then
|
|
108
|
+
clear. In place, under the signal's lock: a task tracer holds this very list. Never in a task's way."""
|
|
109
|
+
try:
|
|
110
|
+
receivers = signal.receivers
|
|
111
|
+
|
|
112
|
+
def ours(entry):
|
|
113
|
+
# (lookup key, receiver) — or longer, as Django's dispatcher (Celery's is a fork of it) grew to
|
|
114
|
+
return entry is receiver or (isinstance(entry, tuple) and len(entry) >= 2 and entry[1] is receiver)
|
|
115
|
+
|
|
116
|
+
lock = getattr(signal, "lock", None)
|
|
117
|
+
with lock if lock is not None else contextlib.nullcontext():
|
|
118
|
+
mine = [entry for entry in receivers if ours(entry)]
|
|
119
|
+
if mine:
|
|
120
|
+
receivers[:] = mine + [entry for entry in receivers if not ours(entry)]
|
|
121
|
+
cache = getattr(signal, "sender_receivers_cache", None)
|
|
122
|
+
if cache is not None:
|
|
123
|
+
cache.clear()
|
|
124
|
+
first = bool(receivers) and ours(receivers[0])
|
|
125
|
+
except Exception: # noqa: BLE001 - a signal of another shape keeps its order (and that is said, once)
|
|
126
|
+
first = False
|
|
127
|
+
if not first:
|
|
128
|
+
_say_not_first()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_said_not_first = False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _say_not_first() -> None:
|
|
135
|
+
"""Once per process: Scope's task start could not be put before the app's own task_prerun handlers."""
|
|
136
|
+
global _said_not_first
|
|
137
|
+
if _said_not_first:
|
|
138
|
+
return
|
|
139
|
+
_said_not_first = True
|
|
140
|
+
logging.getLogger("scope_analytics").warning(
|
|
141
|
+
"Scope could not run its Celery task start before your own task_prerun handlers (this Celery keeps "
|
|
142
|
+
"its receivers in a shape Scope does not know): a context your handlers give a task may be cleared "
|
|
143
|
+
"before the task runs."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
86
147
|
class ScopeAnalytics:
|
|
87
148
|
"""
|
|
88
149
|
Main SDK class for Scope Analytics
|
|
@@ -186,6 +247,7 @@ class ScopeAnalytics:
|
|
|
186
247
|
config=self.config,
|
|
187
248
|
event_enricher=self.deployment.stamp,
|
|
188
249
|
on_fork=self._after_fork,
|
|
250
|
+
on_drop=self._note_drop,
|
|
189
251
|
)
|
|
190
252
|
|
|
191
253
|
# Initialize patchers
|
|
@@ -208,9 +270,10 @@ class ScopeAnalytics:
|
|
|
208
270
|
self.queue.start()
|
|
209
271
|
|
|
210
272
|
# A Celery worker already loaded? Then the prefork children's shutdown signal
|
|
211
|
-
# flushes their last batch (see
|
|
212
|
-
# scope-run load Celery AFTER this point; the fork hook connects it in
|
|
213
|
-
|
|
273
|
+
# flushes their last batch, and each task starts clean (see _connect_celery). Workers
|
|
274
|
+
# started through scope-run load Celery AFTER this point; the fork hook connects it in
|
|
275
|
+
# each child, and an identify in a worker that never forks.
|
|
276
|
+
self._connect_celery()
|
|
214
277
|
|
|
215
278
|
# Register shutdown hook
|
|
216
279
|
atexit.register(self.shutdown)
|
|
@@ -393,7 +456,7 @@ class ScopeAnalytics:
|
|
|
393
456
|
The inherited ship client shares its pooled sockets with the parent — two
|
|
394
457
|
processes writing one TCP connection interleave bytes — so the child gets its
|
|
395
458
|
own. The background session id is per process, so the child mints its own. And
|
|
396
|
-
a Celery child flushes on its own shutdown signal (see
|
|
459
|
+
a Celery child flushes on its own shutdown signal (see _connect_celery)."""
|
|
397
460
|
try:
|
|
398
461
|
# The inherited client is dropped, never closed: httpx's pool close() takes
|
|
399
462
|
# the pool's thread lock, and if the parent's flush thread held it at the
|
|
@@ -403,18 +466,32 @@ class ScopeAnalytics:
|
|
|
403
466
|
self.client = ScopeAPIClient(self.config)
|
|
404
467
|
except Exception as e: # noqa: BLE001 - never let a fork hook break the child
|
|
405
468
|
self.config.log(f"Could not rebuild the ship client after fork: {e}")
|
|
469
|
+
# What the parent dropped is the parent's to report: the child counts its own (new
|
|
470
|
+
# counts, never the inherited lock, which the parent's threads may have held)
|
|
471
|
+
try:
|
|
472
|
+
self.client.drops = DropCounts()
|
|
473
|
+
self.client._hold_reports = False
|
|
474
|
+
except Exception: # noqa: BLE001
|
|
475
|
+
pass
|
|
406
476
|
ScopeContext.reset_background_session()
|
|
407
477
|
# A receiver connected in the parent is inherited and works here (it closes over
|
|
408
478
|
# this same instance), so connect only if nothing was connected before the fork
|
|
409
479
|
# — e.g. the scope-run path, where Celery was not yet imported at init.
|
|
410
|
-
self.
|
|
480
|
+
self._connect_celery()
|
|
411
481
|
|
|
412
|
-
def
|
|
482
|
+
def _connect_celery(self):
|
|
413
483
|
"""Celery's prefork children exit through os._exit(), which skips atexit — so
|
|
414
484
|
their last partial batch (under batch_size, under batch_timeout) would be lost.
|
|
415
485
|
When Celery is loaded (checked in sys.modules; never imported here to find out),
|
|
416
486
|
the child's ``worker_process_shutdown`` signal flushes with a hard two-second
|
|
417
|
-
limit, so a broken network can never hold a worker's shutdown.
|
|
487
|
+
limit, so a broken network can never hold a worker's shutdown.
|
|
488
|
+
|
|
489
|
+
And each task starts with none of what the task before it left in the SDK's context —
|
|
490
|
+
the user it identified, the session its AI calls made up — while whatever ran it gets its
|
|
491
|
+
own context back when it ends (``task_prerun`` / ``task_postrun``; an eager task runs
|
|
492
|
+
inline, inside its caller's context). A worker's tasks are for different people (0.1.7).
|
|
493
|
+
Scope's start runs first, before the app's own task_prerun handlers, whenever it was
|
|
494
|
+
connected; its end runs after the handlers the app connected before it."""
|
|
418
495
|
if self._celery_hooked or "celery" not in sys.modules:
|
|
419
496
|
return
|
|
420
497
|
try:
|
|
@@ -423,6 +500,9 @@ class ScopeAnalytics:
|
|
|
423
500
|
return
|
|
424
501
|
|
|
425
502
|
def _on_worker_process_shutdown(**_kwargs):
|
|
503
|
+
client = getattr(self, "client", None)
|
|
504
|
+
if client is not None:
|
|
505
|
+
client.exiting = True # the child's last sends carry what it gave up on
|
|
426
506
|
self.flush(timeout=2.0)
|
|
427
507
|
|
|
428
508
|
# Celery holds signal receivers weakly by default; keep a strong reference.
|
|
@@ -433,6 +513,17 @@ class ScopeAnalytics:
|
|
|
433
513
|
self.config.log("Celery worker_process_shutdown hook connected")
|
|
434
514
|
except Exception as e: # noqa: BLE001
|
|
435
515
|
self.config.log(f"Could not connect the Celery shutdown hook: {e}")
|
|
516
|
+
for name, receiver in (("task_prerun", _celery_task_starts), ("task_postrun", _celery_task_ends)):
|
|
517
|
+
signal = getattr(signals, name, None)
|
|
518
|
+
if signal is None:
|
|
519
|
+
continue
|
|
520
|
+
try:
|
|
521
|
+
signal.connect(receiver, weak=False) # module functions: connected once whatever the instance
|
|
522
|
+
except Exception as e: # noqa: BLE001
|
|
523
|
+
self.config.log(f"Could not connect the Celery {name} hook: {e}")
|
|
524
|
+
continue
|
|
525
|
+
if receiver is _celery_task_starts:
|
|
526
|
+
_run_first(signal, receiver)
|
|
436
527
|
|
|
437
528
|
def flush(self, timeout: Optional[float] = None):
|
|
438
529
|
"""
|
|
@@ -528,7 +619,7 @@ class ScopeAnalytics:
|
|
|
528
619
|
f"Manual event '{event_type}' failed validation and was dropped."
|
|
529
620
|
)
|
|
530
621
|
|
|
531
|
-
def identify(self, user_id: Any, traits: Optional[dict] = None):
|
|
622
|
+
def identify(self, user_id: Any, traits: Optional[dict] = None, link: Optional[bool] = True):
|
|
532
623
|
"""
|
|
533
624
|
Identify a user and stitch their prior anonymous activity to them.
|
|
534
625
|
|
|
@@ -538,11 +629,29 @@ class ScopeAnalytics:
|
|
|
538
629
|
— and to attach the given traits. Also binds the identified user to the
|
|
539
630
|
current request context so subsequent events are attributed to them.
|
|
540
631
|
|
|
632
|
+
What it links through is what the request — or your own code — established
|
|
633
|
+
(ScopeContext.earlier_identity): in a request Scope's middleware set up — a
|
|
634
|
+
WebSocket included, from its handshake — the browser's visitor id, else the
|
|
635
|
+
request's user, and its session; outside one (a job, a script, a webhook loop,
|
|
636
|
+
a framework Scope does not patch) only an id or session your code set with
|
|
637
|
+
ScopeContext, never
|
|
638
|
+
the user an earlier identify bound nor a session an earlier event made up —
|
|
639
|
+
those are the person before (0.1.7). Call it in the request where the person
|
|
640
|
+
signs up or signs in.
|
|
641
|
+
|
|
642
|
+
To record someone other than the request's person — a teammate an admin
|
|
643
|
+
invites from the admin's own request — pass ``link=False``: the person and
|
|
644
|
+
their traits are recorded, nothing is linked to them (no earlier id, a session
|
|
645
|
+
of the identify's own), and the request stays the admin's (0.1.7).
|
|
646
|
+
|
|
541
647
|
Args:
|
|
542
648
|
user_id: Unique user identifier (whatever your app uses — email, sub, a database id) — text,
|
|
543
649
|
a whole number (kept as its digits, so 42 and "42" are one person), a UUID or an ObjectId,
|
|
544
650
|
up to 100 characters; anything else is ignored, and why said once (0.1.6)
|
|
545
651
|
traits: Optional user traits (e.g., email, name, plan)
|
|
652
|
+
link: True (the default) links the person's earlier activity, as above; False links
|
|
653
|
+
nothing and binds no one; None is read as True. Anything else is no answer: the
|
|
654
|
+
identify is not sent, and why said once (0.1.7)
|
|
546
655
|
"""
|
|
547
656
|
# Disabled construction has no formatter/queue (and possibly no config) —
|
|
548
657
|
# the one loud warning already fired at init, so just drop the identify.
|
|
@@ -555,22 +664,33 @@ class ScopeAnalytics:
|
|
|
555
664
|
user_id = accepted_user_id(user_id, "identify")
|
|
556
665
|
if user_id is None:
|
|
557
666
|
return
|
|
667
|
+
link = accepted_link(link)
|
|
668
|
+
if link is None:
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
# A Celery loaded after the SDK (a worker started through scope-run with no fork: the solo or
|
|
672
|
+
# threads pool) gets its task hooks here, so the next task starts clean.
|
|
673
|
+
self._connect_celery()
|
|
558
674
|
|
|
559
675
|
# Capture the prior (anonymous) identity BEFORE overwriting the context,
|
|
560
676
|
# so the stitcher can link pre-identification events to this user. Prefer
|
|
561
677
|
# the bridged frontend anonymous id (the durable cross-visit visitor id)
|
|
562
678
|
# over the request's user_id, so the merge works even when this request
|
|
563
679
|
# already carries an authenticated user_id (e.g. a JWT issued at signup).
|
|
564
|
-
|
|
565
|
-
|
|
680
|
+
# Outside a request, never what the SDK left there for whoever came before (0.1.7).
|
|
681
|
+
# link=False: someone other than the request's person — nothing is linked to them, and
|
|
682
|
+
# the request (or the job) stays whoever it was.
|
|
683
|
+
anonymous_id, session_id = ScopeContext.earlier_identity() if link else (None, None)
|
|
566
684
|
|
|
567
685
|
# Bind the identified user so later events in this request use it.
|
|
568
|
-
|
|
686
|
+
if link:
|
|
687
|
+
ScopeContext.bind_identified_user(user_id)
|
|
569
688
|
|
|
570
689
|
event = self.event_formatter.format_identify(
|
|
571
690
|
identified_user_id=user_id,
|
|
572
691
|
anonymous_id=anonymous_id,
|
|
573
|
-
|
|
692
|
+
# a session of its own when there is none to link through, never one left in the context
|
|
693
|
+
session_id=session_id or ScopeContext.generate_temp_session_id(),
|
|
574
694
|
traits=traits,
|
|
575
695
|
)
|
|
576
696
|
|
|
@@ -583,6 +703,13 @@ class ScopeAnalytics:
|
|
|
583
703
|
if traits:
|
|
584
704
|
self.config.log(f"User traits: {traits}")
|
|
585
705
|
|
|
706
|
+
def _note_drop(self, kind: str, count: int) -> None:
|
|
707
|
+
"""Events the queue let go, counted where the next request's report is taken (the ship client's —
|
|
708
|
+
read at call time: a forked child has its own)."""
|
|
709
|
+
client = getattr(self, "client", None)
|
|
710
|
+
if client is not None:
|
|
711
|
+
client.drops.add(kind, count)
|
|
712
|
+
|
|
586
713
|
def _flush_events(self, events: list, timeout: Optional[float] = None):
|
|
587
714
|
"""
|
|
588
715
|
Callback for flushing events to API
|
|
@@ -605,8 +732,9 @@ class ScopeAnalytics:
|
|
|
605
732
|
# more since outbound capture: more events means the per-IP ingest limit
|
|
606
733
|
# is reachable, and a dropped batch takes llm_call events with it.
|
|
607
734
|
self._warned_ship_failure = True
|
|
735
|
+
some = "some of " if len(events) > MAX_EVENTS_PER_REQUEST else "" # a flush sent as several
|
|
608
736
|
self.config.warn(
|
|
609
|
-
f"Could not deliver {len(events)} events to Scope — they were dropped. "
|
|
737
|
+
f"Could not deliver {some}{len(events)} events to Scope — they were dropped. "
|
|
610
738
|
f"Check the API key and the endpoint; if this is volume, "
|
|
611
739
|
f"SCOPE_CAPTURE_HTTP=false turns off outbound HTTP capture. "
|
|
612
740
|
f"This warning is shown once."
|
|
@@ -629,6 +757,7 @@ class ScopeAnalytics:
|
|
|
629
757
|
# so a hung/cold backend can't hold a customer's CLI or cron job hostage.
|
|
630
758
|
if self.client:
|
|
631
759
|
self.client.ship_timeout = 5.0
|
|
760
|
+
self.client.exiting = True # its last sends carry what it gave up on (drops.py)
|
|
632
761
|
|
|
633
762
|
# Remove the outbound-HTTP wrappers FIRST: anything captured after the queue
|
|
634
763
|
# stops is appended to a queue that will never ship it, and the customer's own
|
|
@@ -13,6 +13,15 @@ import httpx
|
|
|
13
13
|
from typing import List, Dict, Any, Optional
|
|
14
14
|
|
|
15
15
|
from .context import ScopeContext
|
|
16
|
+
from .drops import UNREACHABLE, UNSENDABLE, DropCounts, failed_send_kind
|
|
17
|
+
|
|
18
|
+
# The ingest takes at most this many events in one request and refuses a larger batch whole (the
|
|
19
|
+
# backend's MAX_BATCH_EVENTS); a flush of more goes as several requests.
|
|
20
|
+
MAX_EVENTS_PER_REQUEST = 1_000
|
|
21
|
+
|
|
22
|
+
# How one send ended (_ship): Scope took its batch, answered without taking it (or it was never made), or
|
|
23
|
+
# never answered.
|
|
24
|
+
_TAKEN, _NOT_TAKEN, _UNANSWERED = "taken", "not taken", "unanswered"
|
|
16
25
|
|
|
17
26
|
|
|
18
27
|
class ScopeAPIClient:
|
|
@@ -22,7 +31,8 @@ class ScopeAPIClient:
|
|
|
22
31
|
Handles:
|
|
23
32
|
- Event shipping to /api/events endpoint
|
|
24
33
|
- Authentication with secret API key
|
|
25
|
-
-
|
|
34
|
+
- Each batch sent once; what is given up on is counted by why and reported to Scope with a later
|
|
35
|
+
request (0.1.8, drops.py)
|
|
26
36
|
- Error handling
|
|
27
37
|
"""
|
|
28
38
|
|
|
@@ -45,6 +55,20 @@ class ScopeAPIClient:
|
|
|
45
55
|
# Single events refused or changed, by (kind, field, reason), already said (_note_refusals).
|
|
46
56
|
self._warned_refusals = set()
|
|
47
57
|
|
|
58
|
+
# What this process gave up on, told to Scope on a later request (0.1.8; drops.py). A forked
|
|
59
|
+
# child builds its own client, so it never reports its parent's counts again.
|
|
60
|
+
self.drops = DropCounts()
|
|
61
|
+
# The last send did not get its batch taken (no answer, or an answer that refused it). Until one
|
|
62
|
+
# does, no report rides a request: Scope counts a report only with a batch it takes, and a report
|
|
63
|
+
# whose request got no answer is not sent again (it may have arrived; a count is never sent
|
|
64
|
+
# twice) — so while Scope is failing, a report riding every request would be lost with the
|
|
65
|
+
# first that goes unanswered.
|
|
66
|
+
self._hold_reports = False
|
|
67
|
+
# The process is ending (shutdown(), a Celery child's exit): no later send will come, so its last
|
|
68
|
+
# sends carry the counts whatever the hold.
|
|
69
|
+
self.exiting = False
|
|
70
|
+
self._warned_unsendable = False
|
|
71
|
+
|
|
48
72
|
# Create HTTP client
|
|
49
73
|
self.client = self._build_http_client(config)
|
|
50
74
|
|
|
@@ -123,61 +147,151 @@ class ScopeAPIClient:
|
|
|
123
147
|
# queue's background thread (contexts do not cross threads); setting it inside the
|
|
124
148
|
# call means it is always set on whichever thread is actually shipping.
|
|
125
149
|
with ScopeContext.scope_internal():
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
150
|
+
if len(events) <= MAX_EVENTS_PER_REQUEST:
|
|
151
|
+
return self._ship(events, timeout) == _TAKEN
|
|
152
|
+
# A flush of more than one request takes (a queue raised past it) goes as several (0.1.8; until
|
|
153
|
+
# then Scope refused it whole)
|
|
154
|
+
delivered = True
|
|
155
|
+
for start in range(0, len(events), MAX_EVENTS_PER_REQUEST):
|
|
156
|
+
outcome = self._ship(events[start:start + MAX_EVENTS_PER_REQUEST], timeout)
|
|
157
|
+
delivered = delivered and outcome == _TAKEN
|
|
158
|
+
if outcome == _UNANSWERED:
|
|
159
|
+
# Scope didn't answer: each request left would wait as long again (at shutdown, past the
|
|
160
|
+
# bound it keeps), so the rest are given up on now, counted as the unanswered one was
|
|
161
|
+
self.drops.add(UNREACHABLE, max(0, len(events) - start - MAX_EVENTS_PER_REQUEST))
|
|
162
|
+
break
|
|
163
|
+
return delivered
|
|
164
|
+
|
|
165
|
+
def _ship(self, events: List[Dict[str, Any]], timeout: Optional[float] = None) -> str:
|
|
166
|
+
report = None
|
|
167
|
+
posting = False
|
|
129
168
|
try:
|
|
130
169
|
self.config.log(f"Shipping {len(events)} events to {self.endpoint}")
|
|
131
170
|
|
|
132
|
-
# Prepare payload
|
|
171
|
+
# Prepare payload — with what this process gave up on since its last report that got through
|
|
133
172
|
payload = {
|
|
134
173
|
"events": events,
|
|
135
174
|
"source": self.config.sdk_source,
|
|
136
175
|
}
|
|
176
|
+
report = None if self._hold_reports and not self.exiting else self.drops.take()
|
|
177
|
+
if report:
|
|
178
|
+
payload["dropped"] = report
|
|
137
179
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
except (TypeError, ValueError):
|
|
145
|
-
body = json.dumps(payload, default=str)
|
|
146
|
-
self.config.warn(
|
|
147
|
-
"Non-JSON value in event batch — coerced via str(). "
|
|
148
|
-
"This indicates an event-sanitization gap; please report it."
|
|
149
|
-
)
|
|
180
|
+
body, events, left_out = self._serialized(payload)
|
|
181
|
+
self.drops.add(UNSENDABLE, left_out)
|
|
182
|
+
if body is None:
|
|
183
|
+
# no event JSON could write (each is counted): nothing to send, and the report waits
|
|
184
|
+
self.drops.put_back(report)
|
|
185
|
+
return _TAKEN
|
|
150
186
|
|
|
151
187
|
# Send POST request
|
|
152
188
|
kwargs = {"content": body}
|
|
153
189
|
limit = timeout if timeout is not None else self.ship_timeout
|
|
154
190
|
if limit is not None:
|
|
155
191
|
kwargs["timeout"] = limit
|
|
192
|
+
posting = True
|
|
156
193
|
response = self.client.post(self.endpoint, **kwargs)
|
|
194
|
+
self._hold_reports = response.status_code != 200
|
|
157
195
|
|
|
158
196
|
# Check response
|
|
159
197
|
if response.status_code == 200:
|
|
160
198
|
self._note_discard(response, len(events))
|
|
161
199
|
self._note_refusals(response)
|
|
162
200
|
self.config.log(f"✅ Successfully shipped {len(events)} events")
|
|
163
|
-
return
|
|
201
|
+
return _TAKEN
|
|
164
202
|
else:
|
|
203
|
+
# Scope answered without taking the batch: the report goes with a later request, and
|
|
204
|
+
# the batch's events are counted by why they were given up on
|
|
205
|
+
self.drops.put_back(report)
|
|
206
|
+
self.drops.add(failed_send_kind(response.status_code), len(events))
|
|
165
207
|
self.config.log(
|
|
166
208
|
f"❌ Failed to ship events: HTTP {response.status_code} - {response.text}"
|
|
167
209
|
)
|
|
168
|
-
return
|
|
210
|
+
return _NOT_TAKEN
|
|
169
211
|
|
|
170
|
-
except httpx.TimeoutException:
|
|
212
|
+
except httpx.TimeoutException as e:
|
|
171
213
|
self.config.log("❌ Request timeout while shipping events")
|
|
172
|
-
return
|
|
214
|
+
return self._gave_up_unanswered(report, e, posting, len(events))
|
|
173
215
|
|
|
174
216
|
except httpx.HTTPError as e:
|
|
175
217
|
self.config.log(f"❌ HTTP error while shipping events: {e}")
|
|
176
|
-
return
|
|
218
|
+
return self._gave_up_unanswered(report, e, posting, len(events))
|
|
177
219
|
|
|
178
220
|
except Exception as e:
|
|
179
221
|
self.config.log(f"❌ Unexpected error while shipping events: {e}")
|
|
180
|
-
return
|
|
222
|
+
return self._gave_up_unanswered(report, e, posting, len(events))
|
|
223
|
+
|
|
224
|
+
# A send that failed with one of these never left this machine: no connection was made.
|
|
225
|
+
_NEVER_LEFT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout, httpx.ProxyError,
|
|
226
|
+
httpx.UnsupportedProtocol)
|
|
227
|
+
|
|
228
|
+
def _gave_up_unanswered(self, report, error: Exception, posting: bool, count: int) -> str:
|
|
229
|
+
"""A send that got no answer; its events are counted. A report whose request may have arrived is
|
|
230
|
+
not sent again (a count is never sent twice); one whose request never left goes with a later
|
|
231
|
+
request. A request that went to Scope unanswered holds the reports until a batch is taken; a
|
|
232
|
+
send that failed before its request was made (building it raised) says nothing about Scope, and
|
|
233
|
+
its events count as unsendable."""
|
|
234
|
+
if not posting:
|
|
235
|
+
self.drops.put_back(report)
|
|
236
|
+
self.drops.add(UNSENDABLE, count)
|
|
237
|
+
return _NOT_TAKEN
|
|
238
|
+
self._hold_reports = True
|
|
239
|
+
if isinstance(error, self._NEVER_LEFT):
|
|
240
|
+
self.drops.put_back(report)
|
|
241
|
+
self.drops.add(UNREACHABLE, count)
|
|
242
|
+
return _UNANSWERED
|
|
243
|
+
|
|
244
|
+
def _serialized(self, payload: Dict[str, Any]):
|
|
245
|
+
"""The request body, the events in it and how many were left out — the body None when no event
|
|
246
|
+
could be written.
|
|
247
|
+
|
|
248
|
+
Strict-first serialization with a LOUD lossy fallback: events are sanitized at build time, so the
|
|
249
|
+
strict path should always win — but a single stray non-JSON value must degrade to its string form
|
|
250
|
+
(with a warning), never lose the whole batch. An event JSON cannot write even as text — a cycle,
|
|
251
|
+
nesting too deep, a value whose text form raises — is left out alone (0.1.8; in 0.1.7 it lost the
|
|
252
|
+
whole batch before it was sent). The caller counts what was left out once the body is built."""
|
|
253
|
+
events = payload["events"]
|
|
254
|
+
try:
|
|
255
|
+
return json.dumps(payload), events, 0
|
|
256
|
+
except Exception: # noqa: BLE001 - a value JSON cannot write as it is
|
|
257
|
+
pass
|
|
258
|
+
left_out = 0
|
|
259
|
+
try:
|
|
260
|
+
body = json.dumps(payload, default=str)
|
|
261
|
+
except Exception: # noqa: BLE001 - a cycle, nesting too deep, a str() that raises
|
|
262
|
+
kept = self._writable(events)
|
|
263
|
+
left_out = len(events) - len(kept)
|
|
264
|
+
if not kept:
|
|
265
|
+
return None, kept, left_out
|
|
266
|
+
events, payload = kept, {**payload, "events": kept}
|
|
267
|
+
try:
|
|
268
|
+
return json.dumps(payload), events, left_out
|
|
269
|
+
except Exception: # noqa: BLE001
|
|
270
|
+
body = json.dumps(payload, default=str)
|
|
271
|
+
self.config.warn(
|
|
272
|
+
"Non-JSON value in event batch — coerced via str(). "
|
|
273
|
+
"This indicates an event-sanitization gap; please report it."
|
|
274
|
+
)
|
|
275
|
+
return body, events, left_out
|
|
276
|
+
|
|
277
|
+
def _writable(self, events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
278
|
+
"""The events JSON can write, at least as text, where they sit in a request; what is left out is
|
|
279
|
+
said once."""
|
|
280
|
+
kept = []
|
|
281
|
+
for event in events:
|
|
282
|
+
try:
|
|
283
|
+
json.dumps({"events": [event]}, default=str) # as deep as it sits in the request
|
|
284
|
+
kept.append(event)
|
|
285
|
+
except Exception: # noqa: BLE001 - a cycle, nesting too deep, a str() that raises
|
|
286
|
+
pass
|
|
287
|
+
if len(kept) < len(events) and not self._warned_unsendable:
|
|
288
|
+
self._warned_unsendable = True
|
|
289
|
+
self.config.warn(
|
|
290
|
+
f"{len(events) - len(kept)} event(s) held a value JSON cannot write even as text (a cycle, "
|
|
291
|
+
f"nesting too deep, or a value whose text form raises) and were left out; the rest of the "
|
|
292
|
+
f"batch was sent. Scope is told how many on a later request. This warning is shown once."
|
|
293
|
+
)
|
|
294
|
+
return kept
|
|
181
295
|
|
|
182
296
|
def _note_discard(self, response, count: int) -> None:
|
|
183
297
|
"""A 200 can still mean "received and thrown away": the ingest service answers
|