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.
Files changed (57) hide show
  1. {scope_analytics-0.1.6/scope_analytics.egg-info → scope_analytics-0.1.8}/PKG-INFO +1 -1
  2. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/__init__.py +144 -15
  3. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/client.py +137 -23
  4. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/context.py +122 -7
  5. scope_analytics-0.1.8/scope_analytics/drops.py +60 -0
  6. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/middleware.py +195 -50
  7. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/queue.py +16 -0
  8. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/supported_versions.py +4 -3
  9. {scope_analytics-0.1.6 → scope_analytics-0.1.8/scope_analytics.egg-info}/PKG-INFO +1 -1
  10. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics.egg-info/SOURCES.txt +7 -0
  11. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/setup.py +1 -1
  12. scope_analytics-0.1.8/tests/test_drop_reports_0_1_8.py +429 -0
  13. scope_analytics-0.1.8/tests/test_identify_0_1_7.py +744 -0
  14. scope_analytics-0.1.8/tests/test_identify_pinned.py +455 -0
  15. scope_analytics-0.1.8/tests/test_shipping_pinned.py +264 -0
  16. scope_analytics-0.1.8/tests/test_tracking_0_1_7.py +402 -0
  17. scope_analytics-0.1.8/tests/test_tracking_pinned.py +627 -0
  18. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/LICENSE +0 -0
  19. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/MANIFEST.in +0 -0
  20. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/README.md +0 -0
  21. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/auto.py +0 -0
  22. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/cli.py +0 -0
  23. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/config.py +0 -0
  24. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/deployment.py +0 -0
  25. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/events.py +0 -0
  26. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/__init__.py +0 -0
  27. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/_capture.py +0 -0
  28. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/_raw_response.py +0 -0
  29. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/_streaming.py +0 -0
  30. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/anthropic_patch.py +0 -0
  31. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/gemini_patch.py +0 -0
  32. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/google_genai_patch.py +0 -0
  33. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/http_patch.py +0 -0
  34. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics/patches/openai_patch.py +0 -0
  35. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics.egg-info/dependency_links.txt +0 -0
  36. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics.egg-info/entry_points.txt +0 -0
  37. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics.egg-info/requires.txt +0 -0
  38. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/scope_analytics.egg-info/top_level.txt +0 -0
  39. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/setup.cfg +0 -0
  40. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_capture_contract.py +0 -0
  41. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_capture_contract_google.py +0 -0
  42. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_cli_exec.py +0 -0
  43. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_client_ip.py +0 -0
  44. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_coverage_honesty.py +0 -0
  45. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_deployment.py +0 -0
  46. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_discard_reply.py +0 -0
  47. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_fork_safety.py +0 -0
  48. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_frameworks_langchain.py +0 -0
  49. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_http_patch.py +0 -0
  50. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_identify.py +0 -0
  51. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_identity_bridge.py +0 -0
  52. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_ids_0_1_6.py +0 -0
  53. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_manual_event_and_debug_output.py +0 -0
  54. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_patch_versions.py +0 -0
  55. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_query_scrub.py +0 -0
  56. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_redaction.py +0 -0
  57. {scope_analytics-0.1.6 → scope_analytics-0.1.8}/tests/test_user_facing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scope-analytics
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: AI-powered analytics SDK for backend applications with automatic LLM tracking
5
5
  Home-page: https://scopeai.dev
6
6
  Author: Scope AI
@@ -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 _connect_celery_shutdown). Workers started through
212
- # scope-run load Celery AFTER this point; the fork hook connects it in each child.
213
- self._connect_celery_shutdown()
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 _connect_celery_shutdown)."""
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._connect_celery_shutdown()
480
+ self._connect_celery()
411
481
 
412
- def _connect_celery_shutdown(self):
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
- anonymous_id = ScopeContext.get_anonymous_id() or ScopeContext.get_user_id()
565
- session_id = ScopeContext.get_session_id()
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
- ScopeContext.set_user_id(user_id)
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
- session_id=session_id,
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
- - Retry logic with exponential backoff
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
- return self._ship(events, timeout)
127
-
128
- def _ship(self, events: List[Dict[str, Any]], timeout: Optional[float] = None) -> bool:
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
- # Strict-first serialization with a LOUD lossy fallback: events are
139
- # sanitized at build time, so the strict path should always win — but a
140
- # single stray non-JSON value must degrade to its string form (with a
141
- # warning), never lose the whole batch.
142
- try:
143
- body = json.dumps(payload)
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 True
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 False
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 False
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 False
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 False
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