scope-analytics 0.1.7__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.7/scope_analytics.egg-info → scope_analytics-0.1.8}/PKG-INFO +1 -1
  2. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/__init__.py +23 -2
  3. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/client.py +137 -23
  4. scope_analytics-0.1.8/scope_analytics/drops.py +60 -0
  5. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/queue.py +16 -0
  6. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/supported_versions.py +4 -3
  7. {scope_analytics-0.1.7 → scope_analytics-0.1.8/scope_analytics.egg-info}/PKG-INFO +1 -1
  8. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics.egg-info/SOURCES.txt +3 -0
  9. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/setup.py +1 -1
  10. scope_analytics-0.1.8/tests/test_drop_reports_0_1_8.py +429 -0
  11. scope_analytics-0.1.8/tests/test_shipping_pinned.py +264 -0
  12. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/LICENSE +0 -0
  13. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/MANIFEST.in +0 -0
  14. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/README.md +0 -0
  15. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/auto.py +0 -0
  16. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/cli.py +0 -0
  17. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/config.py +0 -0
  18. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/context.py +0 -0
  19. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/deployment.py +0 -0
  20. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/events.py +0 -0
  21. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/middleware.py +0 -0
  22. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/__init__.py +0 -0
  23. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/_capture.py +0 -0
  24. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/_raw_response.py +0 -0
  25. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/_streaming.py +0 -0
  26. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/anthropic_patch.py +0 -0
  27. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/gemini_patch.py +0 -0
  28. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/google_genai_patch.py +0 -0
  29. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/http_patch.py +0 -0
  30. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/openai_patch.py +0 -0
  31. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics.egg-info/dependency_links.txt +0 -0
  32. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics.egg-info/entry_points.txt +0 -0
  33. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics.egg-info/requires.txt +0 -0
  34. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics.egg-info/top_level.txt +0 -0
  35. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/setup.cfg +0 -0
  36. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_capture_contract.py +0 -0
  37. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_capture_contract_google.py +0 -0
  38. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_cli_exec.py +0 -0
  39. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_client_ip.py +0 -0
  40. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_coverage_honesty.py +0 -0
  41. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_deployment.py +0 -0
  42. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_discard_reply.py +0 -0
  43. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_fork_safety.py +0 -0
  44. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_frameworks_langchain.py +0 -0
  45. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_http_patch.py +0 -0
  46. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_identify.py +0 -0
  47. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_identify_0_1_7.py +0 -0
  48. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_identify_pinned.py +0 -0
  49. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_identity_bridge.py +0 -0
  50. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_ids_0_1_6.py +0 -0
  51. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_manual_event_and_debug_output.py +0 -0
  52. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_patch_versions.py +0 -0
  53. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_query_scrub.py +0 -0
  54. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_redaction.py +0 -0
  55. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_tracking_0_1_7.py +0 -0
  56. {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_tracking_pinned.py +0 -0
  57. {scope_analytics-0.1.7 → 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.7
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
@@ -14,7 +14,8 @@ from .context import ScopeContext, accepted_link, accepted_user_id
14
14
  from .deployment import DeploymentContext
15
15
  from .events import EventFormatter
16
16
  from .queue import EventQueue
17
- from .client import ScopeAPIClient
17
+ from .client import MAX_EVENTS_PER_REQUEST, ScopeAPIClient
18
+ from .drops import DropCounts
18
19
  from .patches.openai_patch import OpenAIPatcher
19
20
  from .patches.anthropic_patch import AnthropicPatcher
20
21
  from .patches.gemini_patch import GeminiPatcher
@@ -246,6 +247,7 @@ class ScopeAnalytics:
246
247
  config=self.config,
247
248
  event_enricher=self.deployment.stamp,
248
249
  on_fork=self._after_fork,
250
+ on_drop=self._note_drop,
249
251
  )
250
252
 
251
253
  # Initialize patchers
@@ -464,6 +466,13 @@ class ScopeAnalytics:
464
466
  self.client = ScopeAPIClient(self.config)
465
467
  except Exception as e: # noqa: BLE001 - never let a fork hook break the child
466
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
467
476
  ScopeContext.reset_background_session()
468
477
  # A receiver connected in the parent is inherited and works here (it closes over
469
478
  # this same instance), so connect only if nothing was connected before the fork
@@ -491,6 +500,9 @@ class ScopeAnalytics:
491
500
  return
492
501
 
493
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
494
506
  self.flush(timeout=2.0)
495
507
 
496
508
  # Celery holds signal receivers weakly by default; keep a strong reference.
@@ -691,6 +703,13 @@ class ScopeAnalytics:
691
703
  if traits:
692
704
  self.config.log(f"User traits: {traits}")
693
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
+
694
713
  def _flush_events(self, events: list, timeout: Optional[float] = None):
695
714
  """
696
715
  Callback for flushing events to API
@@ -713,8 +732,9 @@ class ScopeAnalytics:
713
732
  # more since outbound capture: more events means the per-IP ingest limit
714
733
  # is reachable, and a dropped batch takes llm_call events with it.
715
734
  self._warned_ship_failure = True
735
+ some = "some of " if len(events) > MAX_EVENTS_PER_REQUEST else "" # a flush sent as several
716
736
  self.config.warn(
717
- 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. "
718
738
  f"Check the API key and the endpoint; if this is volume, "
719
739
  f"SCOPE_CAPTURE_HTTP=false turns off outbound HTTP capture. "
720
740
  f"This warning is shown once."
@@ -737,6 +757,7 @@ class ScopeAnalytics:
737
757
  # so a hung/cold backend can't hold a customer's CLI or cron job hostage.
738
758
  if self.client:
739
759
  self.client.ship_timeout = 5.0
760
+ self.client.exiting = True # its last sends carry what it gave up on (drops.py)
740
761
 
741
762
  # Remove the outbound-HTTP wrappers FIRST: anything captured after the queue
742
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
@@ -0,0 +1,60 @@
1
+ """
2
+ What this process gave up on before Scope had it, counted by kind and told to Scope on a later request
3
+ (0.1.8): Setup, the nightly data watcher and the daily system check read it there. The kinds are Scope's
4
+ own names (backend/app/services/arrival_counts.py DROP_REASONS); Scope ignores a kind it doesn't know.
5
+ """
6
+
7
+ import threading
8
+ from typing import Dict, Optional
9
+
10
+ QUEUE_FULL = "queue_full" # more events waiting than the queue holds: the oldest went
11
+ REFUSED = "refused" # Scope answered no for good (a wrong key, a request it will never take)
12
+ UNSENDABLE = "unsendable" # an event JSON cannot write even as text (a cycle, nesting too deep)
13
+ UNREACHABLE = "unreachable" # no answer: the connection failed, dropped, or timed out
14
+ RATE_LIMITED = "rate_limited" # Scope answered 429: more requests a minute than the key may send
15
+ SERVER_ERROR = "server_error" # Scope answered 5xx or 408: its own error, or no time to read the request
16
+
17
+ # One report's count of one kind at most (Scope keeps no more of one); the rest waits for the next report.
18
+ MAX_REPORTED = 50_000
19
+
20
+
21
+ def failed_send_kind(status: Optional[int]) -> str:
22
+ """Why a batch was given up on, from Scope's answer to it (None: there was no answer)."""
23
+ if status is None:
24
+ return UNREACHABLE
25
+ if status == 429:
26
+ return RATE_LIMITED
27
+ if status == 408 or status >= 500:
28
+ return SERVER_ERROR
29
+ return REFUSED
30
+
31
+
32
+ class DropCounts:
33
+ """Counts by kind, safe across the queue's shipping threads. A report taken into a request comes off
34
+ the counts; put back, it goes with a later request."""
35
+
36
+ def __init__(self):
37
+ self._lock = threading.Lock()
38
+ self._counts: Dict[str, int] = {}
39
+
40
+ def add(self, kind: str, n: int = 1) -> None:
41
+ if n > 0:
42
+ with self._lock:
43
+ self._counts[kind] = self._counts.get(kind, 0) + n
44
+
45
+ def take(self) -> Optional[Dict[str, int]]:
46
+ """The report for the request being built (None when nothing was dropped)."""
47
+ with self._lock:
48
+ report = {kind: min(n, MAX_REPORTED) for kind, n in self._counts.items() if n > 0}
49
+ for kind, n in report.items():
50
+ self._counts[kind] -= n
51
+ return report or None
52
+
53
+ def put_back(self, report: Optional[Dict[str, int]]) -> None:
54
+ for kind, n in (report or {}).items():
55
+ self.add(kind, n)
56
+
57
+ def counted(self) -> Dict[str, int]:
58
+ """What waits to be reported, by kind."""
59
+ with self._lock:
60
+ return {kind: n for kind, n in self._counts.items() if n > 0}
@@ -10,6 +10,8 @@ import weakref
10
10
  from collections import deque
11
11
  from typing import Dict, Any, Callable, Optional
12
12
 
13
+ from .drops import QUEUE_FULL
14
+
13
15
 
14
16
  class EventQueue:
15
17
  # Ceiling on concurrent shipping threads (see _flush_batch).
@@ -44,6 +46,7 @@ class EventQueue:
44
46
  config,
45
47
  event_enricher: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
46
48
  on_fork: Optional[Callable[[], None]] = None,
49
+ on_drop: Optional[Callable[[str, int], None]] = None,
47
50
  ):
48
51
  """
49
52
  Initialize event queue
@@ -61,6 +64,8 @@ class EventQueue:
61
64
  on_fork: Optional callable run in a forked CHILD right after this queue
62
65
  restarts its flush thread there (the SDK uses it to replace the ship
63
66
  client the child inherited).
67
+ on_drop: Optional callable told (kind, count) of events this queue lets go
68
+ (the SDK counts them for its next report to Scope; drops.py).
64
69
  """
65
70
  self.batch_size = batch_size
66
71
  self.batch_timeout_seconds = batch_timeout_seconds
@@ -69,6 +74,7 @@ class EventQueue:
69
74
  self.config = config
70
75
  self.event_enricher = event_enricher
71
76
  self.on_fork = on_fork
77
+ self.on_drop = on_drop
72
78
 
73
79
  self.queue = deque(maxlen=max_queue_size)
74
80
  self._warned_full = False # the queue-full warning is emitted once, not per event
@@ -201,6 +207,7 @@ class EventQueue:
201
207
  f"Raise max_queue_size, or reduce volume (SCOPE_CAPTURE_HTTP=false "
202
208
  f"turns off outbound HTTP capture). This warning is shown once."
203
209
  )
210
+ self._note_drop(QUEUE_FULL, 1) # the deque lets the oldest go as this one goes in
204
211
 
205
212
  self.queue.append(event)
206
213
 
@@ -312,8 +319,17 @@ class EventQueue:
312
319
  self.config.log(f"Error flushing batch: {e}")
313
320
  # Re-queue events on failure
314
321
  with self.lock:
322
+ # past the queue's size, the deque lets go of what came in last (counted)
323
+ self._note_drop(QUEUE_FULL, len(self.queue) + len(batch) - self.max_queue_size)
315
324
  self.queue.extendleft(reversed(batch))
316
325
 
326
+ def _note_drop(self, kind: str, count: int) -> None:
327
+ if count > 0 and self.on_drop is not None:
328
+ try:
329
+ self.on_drop(kind, count)
330
+ except Exception: # noqa: BLE001 - counting a drop must never lose an event
331
+ pass
332
+
317
333
  def size(self) -> int:
318
334
  """Get current queue size"""
319
335
  with self.lock:
@@ -37,9 +37,10 @@ SUPPORTED_VERSIONS = {
37
37
  # both capture surfaces are unchanged — verified by the contract cells below (3.8.0 at
38
38
  # 0.1.3; 3.14.1 at 0.1.4; 3.24.0 at 0.1.5, which also added the with_raw_response cells; 3.26.1
39
39
  # by the 0.1.5 publish dry run, 2026-10-09 — the whole suite green on it but this drift check;
40
- # 3.42.1 by the 0.1.6 publish dry run, 2026-11-03, the same way, and its contract cell).
41
- "max_tested": "3.42.1",
42
- "ci_matrix": ["1.0.0", "1.30.0", "1.57.0", "2.41.0", "2.46.0", "3.8.0", "3.14.1", "3.24.0", "3.26.1", "3.42.1"],
40
+ # 3.42.1 by the 0.1.6 publish dry run, 2026-11-03, the same way, and its contract cell; 3.44.0 by
41
+ # the 0.1.8 publish dry run, 2026-11-04, the same way, and its contract cell).
42
+ "max_tested": "3.44.0",
43
+ "ci_matrix": ["1.0.0", "1.30.0", "1.57.0", "2.41.0", "2.46.0", "3.8.0", "3.14.1", "3.24.0", "3.26.1", "3.42.1", "3.44.0"],
43
44
  },
44
45
  "anthropic": {
45
46
  "import_name": "anthropic",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scope-analytics
3
- Version: 0.1.7
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
@@ -9,6 +9,7 @@ scope_analytics/client.py
9
9
  scope_analytics/config.py
10
10
  scope_analytics/context.py
11
11
  scope_analytics/deployment.py
12
+ scope_analytics/drops.py
12
13
  scope_analytics/events.py
13
14
  scope_analytics/middleware.py
14
15
  scope_analytics/queue.py
@@ -35,6 +36,7 @@ tests/test_client_ip.py
35
36
  tests/test_coverage_honesty.py
36
37
  tests/test_deployment.py
37
38
  tests/test_discard_reply.py
39
+ tests/test_drop_reports_0_1_8.py
38
40
  tests/test_fork_safety.py
39
41
  tests/test_frameworks_langchain.py
40
42
  tests/test_http_patch.py
@@ -47,6 +49,7 @@ tests/test_manual_event_and_debug_output.py
47
49
  tests/test_patch_versions.py
48
50
  tests/test_query_scrub.py
49
51
  tests/test_redaction.py
52
+ tests/test_shipping_pinned.py
50
53
  tests/test_tracking_0_1_7.py
51
54
  tests/test_tracking_pinned.py
52
55
  tests/test_user_facing.py
@@ -11,7 +11,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
11
11
 
12
12
  setup(
13
13
  name="scope-analytics",
14
- version="0.1.7",
14
+ version="0.1.8",
15
15
  author="Scope AI",
16
16
  author_email="support@scopeai.dev",
17
17
  description="AI-powered analytics SDK for backend applications with automatic LLM tracking",