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.
Files changed (54) hide show
  1. {scope_analytics-0.1.5/scope_analytics.egg-info → scope_analytics-0.1.7}/PKG-INFO +2 -2
  2. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/README.md +1 -1
  3. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/__init__.py +135 -16
  4. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/client.py +38 -3
  5. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/context.py +194 -10
  6. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/events.py +6 -0
  7. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/middleware.py +230 -72
  8. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/supported_versions.py +13 -8
  9. {scope_analytics-0.1.5 → scope_analytics-0.1.7/scope_analytics.egg-info}/PKG-INFO +2 -2
  10. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics.egg-info/SOURCES.txt +5 -0
  11. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/setup.py +1 -1
  12. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_discard_reply.py +46 -0
  13. scope_analytics-0.1.7/tests/test_identify_0_1_7.py +744 -0
  14. scope_analytics-0.1.7/tests/test_identify_pinned.py +455 -0
  15. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_identity_bridge.py +46 -0
  16. scope_analytics-0.1.7/tests/test_ids_0_1_6.py +390 -0
  17. scope_analytics-0.1.7/tests/test_tracking_0_1_7.py +402 -0
  18. scope_analytics-0.1.7/tests/test_tracking_pinned.py +627 -0
  19. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/LICENSE +0 -0
  20. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/MANIFEST.in +0 -0
  21. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/auto.py +0 -0
  22. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/cli.py +0 -0
  23. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/config.py +0 -0
  24. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/deployment.py +0 -0
  25. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/__init__.py +0 -0
  26. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/_capture.py +0 -0
  27. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/_raw_response.py +0 -0
  28. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/_streaming.py +0 -0
  29. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/anthropic_patch.py +0 -0
  30. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/gemini_patch.py +0 -0
  31. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/google_genai_patch.py +0 -0
  32. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/http_patch.py +0 -0
  33. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/patches/openai_patch.py +0 -0
  34. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics/queue.py +0 -0
  35. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics.egg-info/dependency_links.txt +0 -0
  36. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics.egg-info/entry_points.txt +0 -0
  37. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics.egg-info/requires.txt +0 -0
  38. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/scope_analytics.egg-info/top_level.txt +0 -0
  39. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/setup.cfg +0 -0
  40. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_capture_contract.py +0 -0
  41. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_capture_contract_google.py +0 -0
  42. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_cli_exec.py +0 -0
  43. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_client_ip.py +0 -0
  44. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_coverage_honesty.py +0 -0
  45. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_deployment.py +0 -0
  46. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_fork_safety.py +0 -0
  47. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_frameworks_langchain.py +0 -0
  48. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_http_patch.py +0 -0
  49. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_identify.py +0 -0
  50. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_manual_event_and_debug_output.py +0 -0
  51. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_patch_versions.py +0 -0
  52. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_query_scrub.py +0 -0
  53. {scope_analytics-0.1.5 → scope_analytics-0.1.7}/tests/test_redaction.py +0 -0
  54. {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.5
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 name (default: production) |
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 name (default: production) |
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 _connect_celery_shutdown). Workers started through
212
- # scope-run load Celery AFTER this point; the fork hook connects it in each child.
213
- self._connect_celery_shutdown()
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 _connect_celery_shutdown)."""
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._connect_celery_shutdown()
471
+ self._connect_celery()
411
472
 
412
- def _connect_celery_shutdown(self):
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: str, traits: Optional[dict] = None):
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, etc.)
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 not user_id:
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
- anonymous_id = ScopeContext.get_anonymous_id() or ScopeContext.get_user_id()
554
- session_id = ScopeContext.get_session_id()
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
- ScopeContext.set_user_id(user_id)
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
- session_id=session_id,
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
- # (datacenter_ip exists but never fires for backend events); anything else gets the
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
- from typing import Optional
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: str) -> None:
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: User ID from backend application
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
- session_id_context.set(None)
182
- user_id_context.set(None)
183
- anonymous_id_context.set(None)
184
- url_context.set(None)
185
- referrer_context.set(None)
186
- thread_id_context.set(None)
187
- user_agent_context.set(None)
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,