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.
Files changed (54) hide show
  1. {scope_analytics-0.1.6/scope_analytics.egg-info → scope_analytics-0.1.7}/PKG-INFO +1 -1
  2. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/__init__.py +121 -13
  3. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/context.py +122 -7
  4. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/middleware.py +195 -50
  5. {scope_analytics-0.1.6 → scope_analytics-0.1.7/scope_analytics.egg-info}/PKG-INFO +1 -1
  6. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics.egg-info/SOURCES.txt +4 -0
  7. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/setup.py +1 -1
  8. scope_analytics-0.1.7/tests/test_identify_0_1_7.py +744 -0
  9. scope_analytics-0.1.7/tests/test_identify_pinned.py +455 -0
  10. scope_analytics-0.1.7/tests/test_tracking_0_1_7.py +402 -0
  11. scope_analytics-0.1.7/tests/test_tracking_pinned.py +627 -0
  12. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/LICENSE +0 -0
  13. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/MANIFEST.in +0 -0
  14. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/README.md +0 -0
  15. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/auto.py +0 -0
  16. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/cli.py +0 -0
  17. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/client.py +0 -0
  18. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/config.py +0 -0
  19. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/deployment.py +0 -0
  20. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/events.py +0 -0
  21. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/__init__.py +0 -0
  22. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/_capture.py +0 -0
  23. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/_raw_response.py +0 -0
  24. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/_streaming.py +0 -0
  25. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/anthropic_patch.py +0 -0
  26. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/gemini_patch.py +0 -0
  27. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/google_genai_patch.py +0 -0
  28. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/http_patch.py +0 -0
  29. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/patches/openai_patch.py +0 -0
  30. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/queue.py +0 -0
  31. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics/supported_versions.py +0 -0
  32. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics.egg-info/dependency_links.txt +0 -0
  33. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics.egg-info/entry_points.txt +0 -0
  34. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics.egg-info/requires.txt +0 -0
  35. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/scope_analytics.egg-info/top_level.txt +0 -0
  36. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/setup.cfg +0 -0
  37. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_capture_contract.py +0 -0
  38. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_capture_contract_google.py +0 -0
  39. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_cli_exec.py +0 -0
  40. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_client_ip.py +0 -0
  41. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_coverage_honesty.py +0 -0
  42. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_deployment.py +0 -0
  43. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_discard_reply.py +0 -0
  44. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_fork_safety.py +0 -0
  45. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_frameworks_langchain.py +0 -0
  46. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_http_patch.py +0 -0
  47. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_identify.py +0 -0
  48. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_identity_bridge.py +0 -0
  49. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_ids_0_1_6.py +0 -0
  50. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_manual_event_and_debug_output.py +0 -0
  51. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_patch_versions.py +0 -0
  52. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_query_scrub.py +0 -0
  53. {scope_analytics-0.1.6 → scope_analytics-0.1.7}/tests/test_redaction.py +0 -0
  54. {scope_analytics-0.1.6 → 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.6
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
@@ -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 _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
  """
@@ -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
- anonymous_id = ScopeContext.get_anonymous_id() or ScopeContext.get_user_id()
565
- 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)
566
672
 
567
673
  # Bind the identified user so later events in this request use it.
568
- ScopeContext.set_user_id(user_id)
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
- 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(),
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
- session_id_context.set(None)
251
- user_id_context.set(None)
252
- anonymous_id_context.set(None)
253
- url_context.set(None)
254
- referrer_context.set(None)
255
- thread_id_context.set(None)
256
- 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
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