scope-analytics 0.1.5__tar.gz → 0.1.7__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.5/scope_analytics.egg-info → scope_analytics-0.1.7}/PKG-INFO +2 -2
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/README.md +1 -1
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/__init__.py +135 -16
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/client.py +38 -3
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/context.py +194 -10
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/events.py +6 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/middleware.py +230 -72
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/supported_versions.py +13 -8
- {scope_analytics-0.1.5 → scope_analytics-0.1.7/scope_analytics.egg-info}/PKG-INFO +2 -2
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics.egg-info/SOURCES.txt +5 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/setup.py +1 -1
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_discard_reply.py +46 -0
- scope_analytics-0.1.7/tests/test_identify_0_1_7.py +744 -0
- scope_analytics-0.1.7/tests/test_identify_pinned.py +455 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_identity_bridge.py +46 -0
- scope_analytics-0.1.7/tests/test_ids_0_1_6.py +390 -0
- scope_analytics-0.1.7/tests/test_tracking_0_1_7.py +402 -0
- scope_analytics-0.1.7/tests/test_tracking_pinned.py +627 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/LICENSE +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/MANIFEST.in +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/auto.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/cli.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/config.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/deployment.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/__init__.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/_capture.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/_raw_response.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/_streaming.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/anthropic_patch.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/gemini_patch.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/google_genai_patch.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/http_patch.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/openai_patch.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/queue.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics.egg-info/dependency_links.txt +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics.egg-info/entry_points.txt +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics.egg-info/requires.txt +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics.egg-info/top_level.txt +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/setup.cfg +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_capture_contract.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_capture_contract_google.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_cli_exec.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_client_ip.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_coverage_honesty.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_deployment.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_fork_safety.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_frameworks_langchain.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_http_patch.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_identify.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_manual_event_and_debug_output.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_patch_versions.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_query_scrub.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_redaction.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.7}/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.
|
|
3
|
+
Version: 0.1.7
|
|
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
|
|
@@ -185,7 +185,7 @@ scope-run --dry-run uvicorn main:app
|
|
|
185
185
|
| `SCOPE_API_KEY` | Yes | Your Scope Analytics API key |
|
|
186
186
|
| `SCOPE_ENDPOINT` | No | Custom API endpoint |
|
|
187
187
|
| `SCOPE_DEBUG` | No | Set to 'true' for debug logging |
|
|
188
|
-
| `SCOPE_ENVIRONMENT` | No | Environment
|
|
188
|
+
| `SCOPE_ENVIRONMENT` | No | Environment tag on every event (default: production). When it is unset or `production`, events sent with a key you created for Staging or Development (Setup → Keys) are tagged with that key's environment instead |
|
|
189
189
|
| `SCOPE_CAPTURE_HTTP` | No | Capture outbound calls to other services (default: on; `false` turns it off) |
|
|
190
190
|
| `HTTPS_PROXY` / `NO_PROXY` / `SSL_CERT_FILE` | No | Honoured for the SDK's own connection to Scope (read from the environment only) |
|
|
191
191
|
|
|
@@ -130,7 +130,7 @@ scope-run --dry-run uvicorn main:app
|
|
|
130
130
|
| `SCOPE_API_KEY` | Yes | Your Scope Analytics API key |
|
|
131
131
|
| `SCOPE_ENDPOINT` | No | Custom API endpoint |
|
|
132
132
|
| `SCOPE_DEBUG` | No | Set to 'true' for debug logging |
|
|
133
|
-
| `SCOPE_ENVIRONMENT` | No | Environment
|
|
133
|
+
| `SCOPE_ENVIRONMENT` | No | Environment tag on every event (default: production). When it is unset or `production`, events sent with a key you created for Staging or Development (Setup → Keys) are tagged with that key's environment instead |
|
|
134
134
|
| `SCOPE_CAPTURE_HTTP` | No | Capture outbound calls to other services (default: on; `false` turns it off) |
|
|
135
135
|
| `HTTPS_PROXY` / `NO_PROXY` / `SSL_CERT_FILE` | No | Honoured for the SDK's own connection to Scope (read from the environment only) |
|
|
136
136
|
|
|
@@ -4,12 +4,13 @@ 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
|
-
from typing import Optional
|
|
10
|
+
from typing import Any, Optional
|
|
10
11
|
|
|
11
12
|
from .config import ScopeConfig, DEFAULT_REDACT_PATTERNS
|
|
12
|
-
from .context import ScopeContext
|
|
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
|
|
@@ -83,6 +84,65 @@ def _set_sdk_instance(instance: "ScopeAnalytics") -> None:
|
|
|
83
84
|
_global_sdk_instance = instance
|
|
84
85
|
|
|
85
86
|
|
|
87
|
+
# Celery's task_prerun and task_postrun receivers (ScopeAnalytics._connect_celery): never into the task's way.
|
|
88
|
+
def _celery_task_starts(task_id=None, **_kwargs):
|
|
89
|
+
try:
|
|
90
|
+
ScopeContext.task_starts(task_id)
|
|
91
|
+
except Exception: # noqa: BLE001 - a task runs whatever the SDK does
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _celery_task_ends(task_id=None, **_kwargs):
|
|
96
|
+
try:
|
|
97
|
+
ScopeContext.task_ends(task_id)
|
|
98
|
+
except Exception: # noqa: BLE001
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _run_first(signal, receiver) -> None:
|
|
103
|
+
"""Move a receiver to the front of a Celery signal's receivers: Celery calls them in the order they were
|
|
104
|
+
connected, and under scope-run the SDK connects after the app (in each forked child, or at the first
|
|
105
|
+
identify). Scope's task start must run before the app's own task_prerun handlers — they set the task's
|
|
106
|
+
context, which Scope's start would otherwise keep (and hand back to the caller of an eager task) and then
|
|
107
|
+
clear. In place, under the signal's lock: a task tracer holds this very list. Never in a task's way."""
|
|
108
|
+
try:
|
|
109
|
+
receivers = signal.receivers
|
|
110
|
+
|
|
111
|
+
def ours(entry):
|
|
112
|
+
# (lookup key, receiver) — or longer, as Django's dispatcher (Celery's is a fork of it) grew to
|
|
113
|
+
return entry is receiver or (isinstance(entry, tuple) and len(entry) >= 2 and entry[1] is receiver)
|
|
114
|
+
|
|
115
|
+
lock = getattr(signal, "lock", None)
|
|
116
|
+
with lock if lock is not None else contextlib.nullcontext():
|
|
117
|
+
mine = [entry for entry in receivers if ours(entry)]
|
|
118
|
+
if mine:
|
|
119
|
+
receivers[:] = mine + [entry for entry in receivers if not ours(entry)]
|
|
120
|
+
cache = getattr(signal, "sender_receivers_cache", None)
|
|
121
|
+
if cache is not None:
|
|
122
|
+
cache.clear()
|
|
123
|
+
first = bool(receivers) and ours(receivers[0])
|
|
124
|
+
except Exception: # noqa: BLE001 - a signal of another shape keeps its order (and that is said, once)
|
|
125
|
+
first = False
|
|
126
|
+
if not first:
|
|
127
|
+
_say_not_first()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_said_not_first = False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _say_not_first() -> None:
|
|
134
|
+
"""Once per process: Scope's task start could not be put before the app's own task_prerun handlers."""
|
|
135
|
+
global _said_not_first
|
|
136
|
+
if _said_not_first:
|
|
137
|
+
return
|
|
138
|
+
_said_not_first = True
|
|
139
|
+
logging.getLogger("scope_analytics").warning(
|
|
140
|
+
"Scope could not run its Celery task start before your own task_prerun handlers (this Celery keeps "
|
|
141
|
+
"its receivers in a shape Scope does not know): a context your handlers give a task may be cleared "
|
|
142
|
+
"before the task runs."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
86
146
|
class ScopeAnalytics:
|
|
87
147
|
"""
|
|
88
148
|
Main SDK class for Scope Analytics
|
|
@@ -208,9 +268,10 @@ class ScopeAnalytics:
|
|
|
208
268
|
self.queue.start()
|
|
209
269
|
|
|
210
270
|
# 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
|
-
|
|
271
|
+
# flushes their last batch, and each task starts clean (see _connect_celery). Workers
|
|
272
|
+
# started through scope-run load Celery AFTER this point; the fork hook connects it in
|
|
273
|
+
# each child, and an identify in a worker that never forks.
|
|
274
|
+
self._connect_celery()
|
|
214
275
|
|
|
215
276
|
# Register shutdown hook
|
|
216
277
|
atexit.register(self.shutdown)
|
|
@@ -393,7 +454,7 @@ class ScopeAnalytics:
|
|
|
393
454
|
The inherited ship client shares its pooled sockets with the parent — two
|
|
394
455
|
processes writing one TCP connection interleave bytes — so the child gets its
|
|
395
456
|
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
|
|
457
|
+
a Celery child flushes on its own shutdown signal (see _connect_celery)."""
|
|
397
458
|
try:
|
|
398
459
|
# The inherited client is dropped, never closed: httpx's pool close() takes
|
|
399
460
|
# the pool's thread lock, and if the parent's flush thread held it at the
|
|
@@ -407,14 +468,21 @@ class ScopeAnalytics:
|
|
|
407
468
|
# A receiver connected in the parent is inherited and works here (it closes over
|
|
408
469
|
# this same instance), so connect only if nothing was connected before the fork
|
|
409
470
|
# — e.g. the scope-run path, where Celery was not yet imported at init.
|
|
410
|
-
self.
|
|
471
|
+
self._connect_celery()
|
|
411
472
|
|
|
412
|
-
def
|
|
473
|
+
def _connect_celery(self):
|
|
413
474
|
"""Celery's prefork children exit through os._exit(), which skips atexit — so
|
|
414
475
|
their last partial batch (under batch_size, under batch_timeout) would be lost.
|
|
415
476
|
When Celery is loaded (checked in sys.modules; never imported here to find out),
|
|
416
477
|
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.
|
|
478
|
+
limit, so a broken network can never hold a worker's shutdown.
|
|
479
|
+
|
|
480
|
+
And each task starts with none of what the task before it left in the SDK's context —
|
|
481
|
+
the user it identified, the session its AI calls made up — while whatever ran it gets its
|
|
482
|
+
own context back when it ends (``task_prerun`` / ``task_postrun``; an eager task runs
|
|
483
|
+
inline, inside its caller's context). A worker's tasks are for different people (0.1.7).
|
|
484
|
+
Scope's start runs first, before the app's own task_prerun handlers, whenever it was
|
|
485
|
+
connected; its end runs after the handlers the app connected before it."""
|
|
418
486
|
if self._celery_hooked or "celery" not in sys.modules:
|
|
419
487
|
return
|
|
420
488
|
try:
|
|
@@ -433,6 +501,17 @@ class ScopeAnalytics:
|
|
|
433
501
|
self.config.log("Celery worker_process_shutdown hook connected")
|
|
434
502
|
except Exception as e: # noqa: BLE001
|
|
435
503
|
self.config.log(f"Could not connect the Celery shutdown hook: {e}")
|
|
504
|
+
for name, receiver in (("task_prerun", _celery_task_starts), ("task_postrun", _celery_task_ends)):
|
|
505
|
+
signal = getattr(signals, name, None)
|
|
506
|
+
if signal is None:
|
|
507
|
+
continue
|
|
508
|
+
try:
|
|
509
|
+
signal.connect(receiver, weak=False) # module functions: connected once whatever the instance
|
|
510
|
+
except Exception as e: # noqa: BLE001
|
|
511
|
+
self.config.log(f"Could not connect the Celery {name} hook: {e}")
|
|
512
|
+
continue
|
|
513
|
+
if receiver is _celery_task_starts:
|
|
514
|
+
_run_first(signal, receiver)
|
|
436
515
|
|
|
437
516
|
def flush(self, timeout: Optional[float] = None):
|
|
438
517
|
"""
|
|
@@ -503,6 +582,12 @@ class ScopeAnalytics:
|
|
|
503
582
|
# version at all).
|
|
504
583
|
event.setdefault("sdk_version", self.config.sdk_version)
|
|
505
584
|
event.setdefault("environment", self.config.environment)
|
|
585
|
+
# The browser's visitor id rides an event the app records in a request a page running Scope's
|
|
586
|
+
# script made, as on the SDK's own events (0.1.6) — only then: a manual event's fields are the
|
|
587
|
+
# caller's.
|
|
588
|
+
anonymous_id = ScopeContext.get_anonymous_id()
|
|
589
|
+
if anonymous_id:
|
|
590
|
+
event.setdefault("anonymous_id", anonymous_id)
|
|
506
591
|
if "session_id" not in event:
|
|
507
592
|
# generate_temp (NOT ensure_session_id): ensure_ would PIN the temp id
|
|
508
593
|
# into the contextvar as a side effect — in a long-lived worker with no
|
|
@@ -522,7 +607,7 @@ class ScopeAnalytics:
|
|
|
522
607
|
f"Manual event '{event_type}' failed validation and was dropped."
|
|
523
608
|
)
|
|
524
609
|
|
|
525
|
-
def identify(self, user_id:
|
|
610
|
+
def identify(self, user_id: Any, traits: Optional[dict] = None, link: Optional[bool] = True):
|
|
526
611
|
"""
|
|
527
612
|
Identify a user and stitch their prior anonymous activity to them.
|
|
528
613
|
|
|
@@ -532,34 +617,68 @@ class ScopeAnalytics:
|
|
|
532
617
|
— and to attach the given traits. Also binds the identified user to the
|
|
533
618
|
current request context so subsequent events are attributed to them.
|
|
534
619
|
|
|
620
|
+
What it links through is what the request — or your own code — established
|
|
621
|
+
(ScopeContext.earlier_identity): in a request Scope's middleware set up — a
|
|
622
|
+
WebSocket included, from its handshake — the browser's visitor id, else the
|
|
623
|
+
request's user, and its session; outside one (a job, a script, a webhook loop,
|
|
624
|
+
a framework Scope does not patch) only an id or session your code set with
|
|
625
|
+
ScopeContext, never
|
|
626
|
+
the user an earlier identify bound nor a session an earlier event made up —
|
|
627
|
+
those are the person before (0.1.7). Call it in the request where the person
|
|
628
|
+
signs up or signs in.
|
|
629
|
+
|
|
630
|
+
To record someone other than the request's person — a teammate an admin
|
|
631
|
+
invites from the admin's own request — pass ``link=False``: the person and
|
|
632
|
+
their traits are recorded, nothing is linked to them (no earlier id, a session
|
|
633
|
+
of the identify's own), and the request stays the admin's (0.1.7).
|
|
634
|
+
|
|
535
635
|
Args:
|
|
536
|
-
user_id: Unique user identifier (whatever your app uses — email, sub,
|
|
636
|
+
user_id: Unique user identifier (whatever your app uses — email, sub, a database id) — text,
|
|
637
|
+
a whole number (kept as its digits, so 42 and "42" are one person), a UUID or an ObjectId,
|
|
638
|
+
up to 100 characters; anything else is ignored, and why said once (0.1.6)
|
|
537
639
|
traits: Optional user traits (e.g., email, name, plan)
|
|
640
|
+
link: True (the default) links the person's earlier activity, as above; False links
|
|
641
|
+
nothing and binds no one; None is read as True. Anything else is no answer: the
|
|
642
|
+
identify is not sent, and why said once (0.1.7)
|
|
538
643
|
"""
|
|
539
644
|
# Disabled construction has no formatter/queue (and possibly no config) —
|
|
540
645
|
# the one loud warning already fired at init, so just drop the identify.
|
|
541
646
|
if not self.enabled:
|
|
542
647
|
return
|
|
543
648
|
|
|
544
|
-
if
|
|
649
|
+
if user_id is None:
|
|
545
650
|
self.config.log("identify() called without a user_id - ignoring")
|
|
546
651
|
return
|
|
652
|
+
user_id = accepted_user_id(user_id, "identify")
|
|
653
|
+
if user_id is None:
|
|
654
|
+
return
|
|
655
|
+
link = accepted_link(link)
|
|
656
|
+
if link is None:
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
# A Celery loaded after the SDK (a worker started through scope-run with no fork: the solo or
|
|
660
|
+
# threads pool) gets its task hooks here, so the next task starts clean.
|
|
661
|
+
self._connect_celery()
|
|
547
662
|
|
|
548
663
|
# Capture the prior (anonymous) identity BEFORE overwriting the context,
|
|
549
664
|
# so the stitcher can link pre-identification events to this user. Prefer
|
|
550
665
|
# the bridged frontend anonymous id (the durable cross-visit visitor id)
|
|
551
666
|
# over the request's user_id, so the merge works even when this request
|
|
552
667
|
# already carries an authenticated user_id (e.g. a JWT issued at signup).
|
|
553
|
-
|
|
554
|
-
|
|
668
|
+
# Outside a request, never what the SDK left there for whoever came before (0.1.7).
|
|
669
|
+
# link=False: someone other than the request's person — nothing is linked to them, and
|
|
670
|
+
# the request (or the job) stays whoever it was.
|
|
671
|
+
anonymous_id, session_id = ScopeContext.earlier_identity() if link else (None, None)
|
|
555
672
|
|
|
556
673
|
# Bind the identified user so later events in this request use it.
|
|
557
|
-
|
|
674
|
+
if link:
|
|
675
|
+
ScopeContext.bind_identified_user(user_id)
|
|
558
676
|
|
|
559
677
|
event = self.event_formatter.format_identify(
|
|
560
678
|
identified_user_id=user_id,
|
|
561
679
|
anonymous_id=anonymous_id,
|
|
562
|
-
|
|
680
|
+
# a session of its own when there is none to link through, never one left in the context
|
|
681
|
+
session_id=session_id or ScopeContext.generate_temp_session_id(),
|
|
563
682
|
traits=traits,
|
|
564
683
|
)
|
|
565
684
|
|
|
@@ -42,6 +42,8 @@ class ScopeAPIClient:
|
|
|
42
42
|
|
|
43
43
|
# Discard reasons already said (see _note_discard) — once each, not per batch.
|
|
44
44
|
self._warned_discard_reasons = set()
|
|
45
|
+
# Single events refused or changed, by (kind, field, reason), already said (_note_refusals).
|
|
46
|
+
self._warned_refusals = set()
|
|
45
47
|
|
|
46
48
|
# Create HTTP client
|
|
47
49
|
self.client = self._build_http_client(config)
|
|
@@ -156,6 +158,7 @@ class ScopeAPIClient:
|
|
|
156
158
|
# Check response
|
|
157
159
|
if response.status_code == 200:
|
|
158
160
|
self._note_discard(response, len(events))
|
|
161
|
+
self._note_refusals(response)
|
|
159
162
|
self.config.log(f"✅ Successfully shipped {len(events)} events")
|
|
160
163
|
return True
|
|
161
164
|
else:
|
|
@@ -181,7 +184,8 @@ class ScopeAPIClient:
|
|
|
181
184
|
``{"status": "discarded", "reason": ...}`` for a batch it will not keep — an IP the
|
|
182
185
|
project excludes, for instance. Reading only the status code made such batches
|
|
183
186
|
vanish without a word (the SDK logged "shipped"; the dashboard showed nothing).
|
|
184
|
-
Said once per reason, with where to look.
|
|
187
|
+
Said once per reason, with where to look. (Single events it refused or changed:
|
|
188
|
+
_note_refusals.)"""
|
|
185
189
|
try:
|
|
186
190
|
body = response.json()
|
|
187
191
|
except Exception: # noqa: BLE001 - a non-JSON 200 is a stored batch as far as we know
|
|
@@ -193,8 +197,8 @@ class ScopeAPIClient:
|
|
|
193
197
|
return
|
|
194
198
|
self._warned_discard_reasons.add(reason)
|
|
195
199
|
# The ingest's IP-exclusion reasons are user_excluded_ip and auto_excluded_login_ip
|
|
196
|
-
# (
|
|
197
|
-
# generic pointer rather than a guess.
|
|
200
|
+
# (the datacenter filter that never ran was removed from ingest on 2026-10-09); anything
|
|
201
|
+
# else gets the generic pointer rather than a guess.
|
|
198
202
|
if reason.endswith("_ip"):
|
|
199
203
|
where = (
|
|
200
204
|
"Events from this machine's IP are excluded for this project — see Setup → "
|
|
@@ -208,6 +212,37 @@ class ScopeAPIClient:
|
|
|
208
212
|
f"This is said once per reason."
|
|
209
213
|
)
|
|
210
214
|
|
|
215
|
+
def _note_refusals(self, response) -> None:
|
|
216
|
+
"""The ingest judges each event on its own: one it cannot keep is refused alone, and one
|
|
217
|
+
it keeps with a change (an id it cannot keep, half of a character) is stored without it
|
|
218
|
+
— and the 200 says so, under ``rejected`` and ``repaired``: each event's place in the
|
|
219
|
+
batch, the field and the reason. Until the ingest judged each event, one such event
|
|
220
|
+
refused the whole batch. Said once per (field, reason), like a discard."""
|
|
221
|
+
try:
|
|
222
|
+
body = response.json()
|
|
223
|
+
except Exception: # noqa: BLE001 - a non-JSON 200 is a stored batch as far as we know
|
|
224
|
+
return
|
|
225
|
+
if not isinstance(body, dict):
|
|
226
|
+
return
|
|
227
|
+
said = {
|
|
228
|
+
"rejected": "Scope refused an event ({why}); each event of a batch is judged on its own. "
|
|
229
|
+
"This is said once per reason.",
|
|
230
|
+
"repaired": "Scope stored an event with a change ({why}). This is said once per reason.",
|
|
231
|
+
}
|
|
232
|
+
for kind, text in said.items():
|
|
233
|
+
problems = body.get(kind)
|
|
234
|
+
for problem in problems if isinstance(problems, list) else []:
|
|
235
|
+
if not isinstance(problem, dict):
|
|
236
|
+
continue
|
|
237
|
+
field, reason = problem.get("field"), problem.get("reason")
|
|
238
|
+
# a reply not shaped as ours is not ours to say (nor to fail the shipped batch over)
|
|
239
|
+
if not isinstance(reason, str) or not (field is None or isinstance(field, str)):
|
|
240
|
+
continue
|
|
241
|
+
if (kind, field, reason) in self._warned_refusals:
|
|
242
|
+
continue
|
|
243
|
+
self._warned_refusals.add((kind, field, reason))
|
|
244
|
+
self.config.warn(text.format(why=f"{field}: {reason}" if field else reason))
|
|
245
|
+
|
|
211
246
|
def test_connection(self) -> bool:
|
|
212
247
|
"""
|
|
213
248
|
Test connection to API
|
|
@@ -6,7 +6,9 @@ Handles session ID propagation across async boundaries
|
|
|
6
6
|
import contextvars
|
|
7
7
|
import functools
|
|
8
8
|
import inspect
|
|
9
|
-
|
|
9
|
+
import logging
|
|
10
|
+
import numbers
|
|
11
|
+
from typing import Any, Optional
|
|
10
12
|
from contextlib import contextmanager
|
|
11
13
|
import uuid
|
|
12
14
|
|
|
@@ -44,6 +46,33 @@ user_agent_context: contextvars.ContextVar[Optional[str]] = contextvars.ContextV
|
|
|
44
46
|
'scope_user_agent', default=None
|
|
45
47
|
)
|
|
46
48
|
|
|
49
|
+
# What the SDK itself left in a context, as opposed to what a request or the app set there (0.1.7): the temp
|
|
50
|
+
# session an event made up for want of one (ensure_session_id), and the user identify() bound. Outside a
|
|
51
|
+
# request they are whoever came before — a worker's previous task, a script's previous person — so identify()
|
|
52
|
+
# never links a person through them.
|
|
53
|
+
_made_up_session_context: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
54
|
+
'scope_made_up_session', default=None
|
|
55
|
+
)
|
|
56
|
+
_bound_by_identify_context: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
57
|
+
'scope_bound_by_identify', default=None
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# True for the length of a request a Scope middleware set up: every entry point reads the request's
|
|
61
|
+
# identity from its headers (middleware._auto_set_identity_from_headers), and its values are that
|
|
62
|
+
# request's, made up or not. Cleared with the request.
|
|
63
|
+
_in_request_context: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
|
64
|
+
'scope_in_request', default=False
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# The Celery tasks this context is running, innermost last: (task id, the context kept for the code around it,
|
|
68
|
+
# or None when the task runs inside a request) — ScopeContext.task_starts / task_ends. Never cleared by clear().
|
|
69
|
+
_tasks_started_context: contextvars.ContextVar[tuple] = contextvars.ContextVar('scope_tasks_started', default=())
|
|
70
|
+
|
|
71
|
+
# Everything a request — or a Celery task — has of its own: ScopeContext.clear() empties it, and a task
|
|
72
|
+
# keeps it for the code around it (with the request mark, which clear() resets on its own).
|
|
73
|
+
_PER_REQUEST = (session_id_context, user_id_context, anonymous_id_context, url_context, referrer_context,
|
|
74
|
+
thread_id_context, user_agent_context, _made_up_session_context, _bound_by_identify_context)
|
|
75
|
+
|
|
47
76
|
# Recursion guard - prevents infinite loops when Scope tracks its own LLM calls
|
|
48
77
|
# This is critical when Scope AI backend uses Scope SDK to track itself
|
|
49
78
|
_in_scope_context: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
|
@@ -70,6 +99,86 @@ _provider_call_in_flight: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
|
|
70
99
|
)
|
|
71
100
|
|
|
72
101
|
|
|
102
|
+
# The longest id Scope keeps — a session, a visitor, a user: the ingest refuses a longer one (the Node
|
|
103
|
+
# SDK's MAX_ID_LENGTH).
|
|
104
|
+
MAX_ID_LENGTH = 100
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fit_id(value: Any) -> Optional[str]:
|
|
108
|
+
"""A session or visitor id from a request header as Scope keeps it: the text without the spaces
|
|
109
|
+
around it, of 1 to MAX_ID_LENGTH characters, else None — never cut, since a cut id could name
|
|
110
|
+
another session or visitor (0.1.6; the Node SDK's fitId). A session header that is not one falls
|
|
111
|
+
back to a temp session, as a missing one does."""
|
|
112
|
+
if not isinstance(value, str):
|
|
113
|
+
return None
|
|
114
|
+
value = value.strip()
|
|
115
|
+
return value if 0 < len(value) <= MAX_ID_LENGTH else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Why an app's id was ignored, each said once per process (a refused type is named, never the id).
|
|
119
|
+
_warned_refused_user_id: set = set()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _refused(call: str, why: str) -> None:
|
|
123
|
+
if why not in _warned_refused_user_id:
|
|
124
|
+
_warned_refused_user_id.add(why)
|
|
125
|
+
logging.getLogger("scope_analytics").warning(
|
|
126
|
+
"[Scope SDK] %s() takes your app's id for the user — text, a whole number, a UUID or an ObjectId, "
|
|
127
|
+
"up to %d characters; one was ignored (%s)", call, MAX_ID_LENGTH, why)
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def accepted_user_id(user_id: Any, call: str) -> Optional[str]:
|
|
132
|
+
"""The app's id for a user as Scope keeps it — its text, of 1 to MAX_ID_LENGTH characters — or None,
|
|
133
|
+
and why said once per process (0.1.6). Text is kept as given; a whole number becomes its digits (42
|
|
134
|
+
and "42" are one person: a number used to ride the event as a number, and identify(0) was ignored as
|
|
135
|
+
if no id had been given); a UUID or an ObjectId (a Mongo id, Beanie's too) becomes its text, as 0.1.5
|
|
136
|
+
sent it. True/False, a float, a list and any other object are no id: Scope would keep whatever its
|
|
137
|
+
text happens to be (the Node SDK's rule refuses an object for the same reason; it keeps any number).
|
|
138
|
+
None gives no id and says nothing."""
|
|
139
|
+
if user_id is None:
|
|
140
|
+
return None
|
|
141
|
+
if isinstance(user_id, bool):
|
|
142
|
+
return _refused(call, "true/false")
|
|
143
|
+
if isinstance(user_id, numbers.Integral):
|
|
144
|
+
# past 100 digits it is no id; refused before it is turned into text, which Python 3.11+ refuses
|
|
145
|
+
# for one of more than 4,300 digits by raising — into the app's own call
|
|
146
|
+
if int(user_id).bit_length() > 333: # 10**100 is 333 bits: anything longer has over 100 digits
|
|
147
|
+
return _refused(call, f"over {MAX_ID_LENGTH} characters")
|
|
148
|
+
text = str(int(user_id))
|
|
149
|
+
elif isinstance(user_id, str):
|
|
150
|
+
text = user_id
|
|
151
|
+
elif isinstance(user_id, uuid.UUID) or any(c.__name__ == "ObjectId" for c in type(user_id).__mro__):
|
|
152
|
+
text = str(user_id)
|
|
153
|
+
else:
|
|
154
|
+
return _refused(call, f"a {type(user_id).__name__}")
|
|
155
|
+
if not text:
|
|
156
|
+
return _refused(call, "empty")
|
|
157
|
+
if len(text) > MAX_ID_LENGTH:
|
|
158
|
+
return _refused(call, f"over {MAX_ID_LENGTH} characters")
|
|
159
|
+
return text
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Why an identify's link was not read, each said once per process (the type is named, never the value).
|
|
163
|
+
_warned_refused_link: set = set()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def accepted_link(link: Any) -> Optional[bool]:
|
|
167
|
+
"""identify()'s ``link`` as Scope reads it: True or False, and None as the default, True (0.1.7). Anything
|
|
168
|
+
else is no answer: that identify is not sent, and why said once per process — a person linked to the wrong
|
|
169
|
+
activity stays linked, so a link Scope cannot read is never guessed either way."""
|
|
170
|
+
if link is None:
|
|
171
|
+
return True
|
|
172
|
+
if isinstance(link, bool):
|
|
173
|
+
return link
|
|
174
|
+
why = type(link).__name__
|
|
175
|
+
if why not in _warned_refused_link:
|
|
176
|
+
_warned_refused_link.add(why)
|
|
177
|
+
logging.getLogger("scope_analytics").warning(
|
|
178
|
+
"[Scope SDK] identify() takes link as True or False; an identify was not sent (a %s)", why)
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
73
182
|
class ScopeContext:
|
|
74
183
|
"""
|
|
75
184
|
Manages request-scoped context using Python's contextvars.
|
|
@@ -87,6 +196,7 @@ class ScopeContext:
|
|
|
87
196
|
session_id: Session ID from X-Scope-Session-ID header
|
|
88
197
|
"""
|
|
89
198
|
session_id_context.set(session_id)
|
|
199
|
+
_made_up_session_context.set(None)
|
|
90
200
|
|
|
91
201
|
@staticmethod
|
|
92
202
|
def get_session_id() -> Optional[str]:
|
|
@@ -99,14 +209,60 @@ class ScopeContext:
|
|
|
99
209
|
return session_id_context.get()
|
|
100
210
|
|
|
101
211
|
@staticmethod
|
|
102
|
-
def set_user_id(user_id:
|
|
212
|
+
def set_user_id(user_id: Any) -> None:
|
|
103
213
|
"""
|
|
104
214
|
Set the user ID for the current context
|
|
105
215
|
|
|
106
216
|
Args:
|
|
107
|
-
user_id:
|
|
217
|
+
user_id: The app's id for the user — text, a whole number (kept as its digits), a UUID or an
|
|
218
|
+
ObjectId, up to 100 characters; anything else is ignored, and why said once (0.1.6). None
|
|
219
|
+
clears it.
|
|
108
220
|
"""
|
|
221
|
+
if user_id is None:
|
|
222
|
+
user_id_context.set(None)
|
|
223
|
+
_bound_by_identify_context.set(None)
|
|
224
|
+
return
|
|
225
|
+
accepted = accepted_user_id(user_id, "set_user_id")
|
|
226
|
+
if accepted is not None:
|
|
227
|
+
user_id_context.set(accepted)
|
|
228
|
+
_bound_by_identify_context.set(None)
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def bind_identified_user(user_id: str) -> None:
|
|
232
|
+
"""Bind the user identify() named, so the rest of the request — or of the job — carries them (0.1.7:
|
|
233
|
+
marked as identify's own, never an earlier id for the next identify outside a request)."""
|
|
109
234
|
user_id_context.set(user_id)
|
|
235
|
+
_bound_by_identify_context.set(user_id)
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def enter_request() -> None:
|
|
239
|
+
"""Mark this context as a request a Scope middleware set up, until the request clears it."""
|
|
240
|
+
_in_request_context.set(True)
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def in_request() -> bool:
|
|
244
|
+
"""Whether this context is a request a Scope middleware set up."""
|
|
245
|
+
return _in_request_context.get()
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def earlier_identity() -> tuple:
|
|
249
|
+
"""The earlier id and the session identify() links a person through: (earlier id, session or None).
|
|
250
|
+
|
|
251
|
+
Inside a request a Scope middleware set up (a WebSocket included, from 0.1.7), the request's own — its
|
|
252
|
+
browser's visitor id, else its user (its login token's, one the app set, or one an identify in it
|
|
253
|
+
bound), and its session — as before 0.1.7. Outside one (a job, a script, a webhook loop, a framework
|
|
254
|
+
Scope does not patch) only what the app set itself with set_anonymous_id / set_user_id / set_session_id: never the
|
|
255
|
+
user an earlier identify() bound, nor the temp session an earlier event made up — those belong to
|
|
256
|
+
whoever came before (0.1.7)."""
|
|
257
|
+
if _in_request_context.get():
|
|
258
|
+
return anonymous_id_context.get() or user_id_context.get(), session_id_context.get()
|
|
259
|
+
user = user_id_context.get()
|
|
260
|
+
if user is not None and user == _bound_by_identify_context.get():
|
|
261
|
+
user = None
|
|
262
|
+
session = session_id_context.get()
|
|
263
|
+
if session is not None and session == _made_up_session_context.get():
|
|
264
|
+
session = None
|
|
265
|
+
return anonymous_id_context.get() or user, session
|
|
110
266
|
|
|
111
267
|
@staticmethod
|
|
112
268
|
def get_user_id() -> Optional[str]:
|
|
@@ -178,13 +334,40 @@ class ScopeContext:
|
|
|
178
334
|
Clear all context variables
|
|
179
335
|
Should be called after request completes to prevent leaks
|
|
180
336
|
"""
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
337
|
+
for var in _PER_REQUEST:
|
|
338
|
+
var.set(None)
|
|
339
|
+
_in_request_context.set(False)
|
|
340
|
+
|
|
341
|
+
# A Celery task starts with none of what the task before it left — the user it identified, the session its
|
|
342
|
+
# AI calls made up, a session or user the app set for it — and whatever ran it gets its own context back when
|
|
343
|
+
# it ends: an eager task runs inline, in its caller's context (0.1.7). The contexts kept are a stack of the
|
|
344
|
+
# context's own (_tasks_started_context), innermost last: a task's end finds its own start even when nested
|
|
345
|
+
# eager tasks share an id, and threads (-P threads), greenlets and asyncio tasks each keep their own.
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def task_starts(task_id: Any) -> None:
|
|
349
|
+
"""Celery's task_prerun: keep the context around the task, and start the task with an empty one. An
|
|
350
|
+
eager task run inside a request is part of that request, and keeps its context (its start still keeps
|
|
351
|
+
its place, so its end gives back nothing)."""
|
|
352
|
+
kept = None if _in_request_context.get() else tuple(var.get() for var in _PER_REQUEST)
|
|
353
|
+
_tasks_started_context.set(_tasks_started_context.get() + ((task_id, kept),))
|
|
354
|
+
if kept is not None:
|
|
355
|
+
ScopeContext.clear()
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def task_ends(task_id: Any) -> None:
|
|
359
|
+
"""Celery's task_postrun: give back the context the task started from — and outside a request again.
|
|
360
|
+
A task whose end never came (a killed child) is dropped with it."""
|
|
361
|
+
started = _tasks_started_context.get()
|
|
362
|
+
for at in range(len(started) - 1, -1, -1):
|
|
363
|
+
if started[at][0] == task_id:
|
|
364
|
+
_tasks_started_context.set(started[:at])
|
|
365
|
+
kept = started[at][1]
|
|
366
|
+
if kept is not None:
|
|
367
|
+
for var, value in zip(_PER_REQUEST, kept):
|
|
368
|
+
var.set(value)
|
|
369
|
+
_in_request_context.set(False)
|
|
370
|
+
return
|
|
188
371
|
|
|
189
372
|
# One id for everything this process does with no visitor attached. Machine-driven
|
|
190
373
|
# events (a cron job's outbound calls, a worker's retries) have no session, but the
|
|
@@ -230,6 +413,7 @@ class ScopeContext:
|
|
|
230
413
|
if not session_id:
|
|
231
414
|
session_id = ScopeContext.generate_temp_session_id()
|
|
232
415
|
session_id_context.set(session_id)
|
|
416
|
+
_made_up_session_context.set(session_id)
|
|
233
417
|
return session_id
|
|
234
418
|
|
|
235
419
|
@staticmethod
|
|
@@ -153,6 +153,10 @@ class EventFormatter:
|
|
|
153
153
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
154
154
|
"session_id": session_id or ScopeContext.ensure_session_id(),
|
|
155
155
|
"user_id": user_id,
|
|
156
|
+
# the browser's visitor id (X-Scope-Anonymous-ID), when a page running Scope's script made the
|
|
157
|
+
# request — beside user_id, which a login token's user replaces (0.1.6): the sign-in and the
|
|
158
|
+
# browser it was used in, recorded together. None when no page sent one.
|
|
159
|
+
"anonymous_id": ScopeContext.get_anonymous_id(),
|
|
156
160
|
|
|
157
161
|
# Request context for conversation correlation
|
|
158
162
|
# This enables the fallback chain: thread_id → session_id+url → session_id
|
|
@@ -234,6 +238,7 @@ class EventFormatter:
|
|
|
234
238
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
235
239
|
"session_id": session_id or ScopeContext.ensure_session_id(),
|
|
236
240
|
"user_id": user_id,
|
|
241
|
+
"anonymous_id": ScopeContext.get_anonymous_id(), # as on llm_call (0.1.6)
|
|
237
242
|
|
|
238
243
|
# HTTP request specific fields
|
|
239
244
|
"method": method,
|
|
@@ -330,6 +335,7 @@ class EventFormatter:
|
|
|
330
335
|
# count. One background session per process says what actually happened.
|
|
331
336
|
"session_id": session_id or ScopeContext.background_session_id(),
|
|
332
337
|
"user_id": user_id,
|
|
338
|
+
"anonymous_id": ScopeContext.get_anonymous_id(), # as on llm_call (0.1.6)
|
|
333
339
|
|
|
334
340
|
# API call specific fields
|
|
335
341
|
"method": method,
|