scope-analytics 0.1.6__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.6/scope_analytics.egg-info → scope_analytics-0.1.7}/PKG-INFO +1 -1
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/__init__.py +121 -13
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/context.py +122 -7
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/middleware.py +195 -50
- {scope_analytics-0.1.6 → scope_analytics-0.1.7/scope_analytics.egg-info}/PKG-INFO +1 -1
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics.egg-info/SOURCES.txt +4 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/setup.py +1 -1
- 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.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.6 → scope_analytics-0.1.7}/LICENSE +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/MANIFEST.in +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/README.md +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/auto.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/cli.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/client.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/config.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/deployment.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/events.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/__init__.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/_capture.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/_raw_response.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/_streaming.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/anthropic_patch.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/gemini_patch.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/google_genai_patch.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/http_patch.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/openai_patch.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/queue.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/supported_versions.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics.egg-info/dependency_links.txt +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics.egg-info/entry_points.txt +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics.egg-info/requires.txt +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics.egg-info/top_level.txt +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/setup.cfg +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_capture_contract.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_capture_contract_google.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_cli_exec.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_client_ip.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_coverage_honesty.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_deployment.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_discard_reply.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_fork_safety.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_frameworks_langchain.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_http_patch.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_identify.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_identity_bridge.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_ids_0_1_6.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_manual_event_and_debug_output.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_patch_versions.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_query_scrub.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_redaction.py +0 -0
- {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_user_facing.py +0 -0
|
@@ -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
10
|
from typing import Any, Optional
|
|
10
11
|
|
|
11
12
|
from .config import ScopeConfig, DEFAULT_REDACT_PATTERNS
|
|
12
|
-
from .context import ScopeContext, accepted_user_id
|
|
13
|
+
from .context import ScopeContext, accepted_link, accepted_user_id
|
|
13
14
|
from .deployment import DeploymentContext
|
|
14
15
|
from .events import EventFormatter
|
|
15
16
|
from .queue import EventQueue
|
|
@@ -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
|
"""
|
|
@@ -528,7 +607,7 @@ class ScopeAnalytics:
|
|
|
528
607
|
f"Manual event '{event_type}' failed validation and was dropped."
|
|
529
608
|
)
|
|
530
609
|
|
|
531
|
-
def identify(self, user_id: Any, traits: Optional[dict] = None):
|
|
610
|
+
def identify(self, user_id: Any, traits: Optional[dict] = None, link: Optional[bool] = True):
|
|
532
611
|
"""
|
|
533
612
|
Identify a user and stitch their prior anonymous activity to them.
|
|
534
613
|
|
|
@@ -538,11 +617,29 @@ class ScopeAnalytics:
|
|
|
538
617
|
— and to attach the given traits. Also binds the identified user to the
|
|
539
618
|
current request context so subsequent events are attributed to them.
|
|
540
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
|
+
|
|
541
635
|
Args:
|
|
542
636
|
user_id: Unique user identifier (whatever your app uses — email, sub, a database id) — text,
|
|
543
637
|
a whole number (kept as its digits, so 42 and "42" are one person), a UUID or an ObjectId,
|
|
544
638
|
up to 100 characters; anything else is ignored, and why said once (0.1.6)
|
|
545
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)
|
|
546
643
|
"""
|
|
547
644
|
# Disabled construction has no formatter/queue (and possibly no config) —
|
|
548
645
|
# the one loud warning already fired at init, so just drop the identify.
|
|
@@ -555,22 +652,33 @@ class ScopeAnalytics:
|
|
|
555
652
|
user_id = accepted_user_id(user_id, "identify")
|
|
556
653
|
if user_id is None:
|
|
557
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()
|
|
558
662
|
|
|
559
663
|
# Capture the prior (anonymous) identity BEFORE overwriting the context,
|
|
560
664
|
# so the stitcher can link pre-identification events to this user. Prefer
|
|
561
665
|
# the bridged frontend anonymous id (the durable cross-visit visitor id)
|
|
562
666
|
# over the request's user_id, so the merge works even when this request
|
|
563
667
|
# already carries an authenticated user_id (e.g. a JWT issued at signup).
|
|
564
|
-
|
|
565
|
-
|
|
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)
|
|
566
672
|
|
|
567
673
|
# Bind the identified user so later events in this request use it.
|
|
568
|
-
|
|
674
|
+
if link:
|
|
675
|
+
ScopeContext.bind_identified_user(user_id)
|
|
569
676
|
|
|
570
677
|
event = self.event_formatter.format_identify(
|
|
571
678
|
identified_user_id=user_id,
|
|
572
679
|
anonymous_id=anonymous_id,
|
|
573
|
-
|
|
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(),
|
|
574
682
|
traits=traits,
|
|
575
683
|
)
|
|
576
684
|
|
|
@@ -46,6 +46,33 @@ user_agent_context: contextvars.ContextVar[Optional[str]] = contextvars.ContextV
|
|
|
46
46
|
'scope_user_agent', default=None
|
|
47
47
|
)
|
|
48
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
|
+
|
|
49
76
|
# Recursion guard - prevents infinite loops when Scope tracks its own LLM calls
|
|
50
77
|
# This is critical when Scope AI backend uses Scope SDK to track itself
|
|
51
78
|
_in_scope_context: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
|
@@ -132,6 +159,26 @@ def accepted_user_id(user_id: Any, call: str) -> Optional[str]:
|
|
|
132
159
|
return text
|
|
133
160
|
|
|
134
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
|
+
|
|
135
182
|
class ScopeContext:
|
|
136
183
|
"""
|
|
137
184
|
Manages request-scoped context using Python's contextvars.
|
|
@@ -149,6 +196,7 @@ class ScopeContext:
|
|
|
149
196
|
session_id: Session ID from X-Scope-Session-ID header
|
|
150
197
|
"""
|
|
151
198
|
session_id_context.set(session_id)
|
|
199
|
+
_made_up_session_context.set(None)
|
|
152
200
|
|
|
153
201
|
@staticmethod
|
|
154
202
|
def get_session_id() -> Optional[str]:
|
|
@@ -172,10 +220,49 @@ class ScopeContext:
|
|
|
172
220
|
"""
|
|
173
221
|
if user_id is None:
|
|
174
222
|
user_id_context.set(None)
|
|
223
|
+
_bound_by_identify_context.set(None)
|
|
175
224
|
return
|
|
176
225
|
accepted = accepted_user_id(user_id, "set_user_id")
|
|
177
226
|
if accepted is not None:
|
|
178
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)."""
|
|
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
|
|
179
266
|
|
|
180
267
|
@staticmethod
|
|
181
268
|
def get_user_id() -> Optional[str]:
|
|
@@ -247,13 +334,40 @@ class ScopeContext:
|
|
|
247
334
|
Clear all context variables
|
|
248
335
|
Should be called after request completes to prevent leaks
|
|
249
336
|
"""
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
257
371
|
|
|
258
372
|
# One id for everything this process does with no visitor attached. Machine-driven
|
|
259
373
|
# events (a cron job's outbound calls, a worker's retries) have no session, but the
|
|
@@ -299,6 +413,7 @@ class ScopeContext:
|
|
|
299
413
|
if not session_id:
|
|
300
414
|
session_id = ScopeContext.generate_temp_session_id()
|
|
301
415
|
session_id_context.set(session_id)
|
|
416
|
+
_made_up_session_context.set(session_id)
|
|
302
417
|
return session_id
|
|
303
418
|
|
|
304
419
|
@staticmethod
|