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.
- {scope_analytics-0.1.7/scope_analytics.egg-info → scope_analytics-0.1.8}/PKG-INFO +1 -1
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/__init__.py +23 -2
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/client.py +137 -23
- scope_analytics-0.1.8/scope_analytics/drops.py +60 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/queue.py +16 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/supported_versions.py +4 -3
- {scope_analytics-0.1.7 → scope_analytics-0.1.8/scope_analytics.egg-info}/PKG-INFO +1 -1
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics.egg-info/SOURCES.txt +3 -0
- {scope_analytics-0.1.7 → 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_shipping_pinned.py +264 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/LICENSE +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/MANIFEST.in +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/README.md +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/auto.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/cli.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/config.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/context.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/deployment.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/events.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/middleware.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/__init__.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/_capture.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/_raw_response.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/_streaming.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/anthropic_patch.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/gemini_patch.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/google_genai_patch.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/http_patch.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics/patches/openai_patch.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics.egg-info/dependency_links.txt +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics.egg-info/entry_points.txt +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics.egg-info/requires.txt +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/scope_analytics.egg-info/top_level.txt +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/setup.cfg +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_capture_contract.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_capture_contract_google.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_cli_exec.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_client_ip.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_coverage_honesty.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_deployment.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_discard_reply.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_fork_safety.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_frameworks_langchain.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_http_patch.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_identify.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_identify_0_1_7.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_identify_pinned.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_identity_bridge.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_ids_0_1_6.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_manual_event_and_debug_output.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_patch_versions.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_query_scrub.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_redaction.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_tracking_0_1_7.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_tracking_pinned.py +0 -0
- {scope_analytics-0.1.7 → scope_analytics-0.1.8}/tests/test_user_facing.py +0 -0
|
@@ -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
|
-
-
|
|
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
|
|
@@ -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
|
-
|
|
42
|
-
"
|
|
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",
|
|
@@ -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.
|
|
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",
|