scope-analytics 0.1.5__tar.gz → 0.1.6__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 (50) hide show
  1. {scope_analytics-0.1.5/scope_analytics.egg-info → scope_analytics-0.1.6}/PKG-INFO +2 -2
  2. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/README.md +1 -1
  3. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/__init__.py +16 -5
  4. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/client.py +38 -3
  5. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/context.py +73 -4
  6. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/events.py +6 -0
  7. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/middleware.py +37 -24
  8. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/supported_versions.py +13 -8
  9. {scope_analytics-0.1.5 → scope_analytics-0.1.6/scope_analytics.egg-info}/PKG-INFO +2 -2
  10. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics.egg-info/SOURCES.txt +1 -0
  11. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/setup.py +1 -1
  12. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_discard_reply.py +46 -0
  13. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_identity_bridge.py +46 -0
  14. scope_analytics-0.1.6/tests/test_ids_0_1_6.py +390 -0
  15. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/LICENSE +0 -0
  16. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/MANIFEST.in +0 -0
  17. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/auto.py +0 -0
  18. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/cli.py +0 -0
  19. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/config.py +0 -0
  20. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/deployment.py +0 -0
  21. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/__init__.py +0 -0
  22. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/_capture.py +0 -0
  23. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/_raw_response.py +0 -0
  24. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/_streaming.py +0 -0
  25. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/anthropic_patch.py +0 -0
  26. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/gemini_patch.py +0 -0
  27. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/google_genai_patch.py +0 -0
  28. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/http_patch.py +0 -0
  29. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/openai_patch.py +0 -0
  30. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/queue.py +0 -0
  31. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics.egg-info/dependency_links.txt +0 -0
  32. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics.egg-info/entry_points.txt +0 -0
  33. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics.egg-info/requires.txt +0 -0
  34. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics.egg-info/top_level.txt +0 -0
  35. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/setup.cfg +0 -0
  36. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_capture_contract.py +0 -0
  37. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_capture_contract_google.py +0 -0
  38. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_cli_exec.py +0 -0
  39. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_client_ip.py +0 -0
  40. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_coverage_honesty.py +0 -0
  41. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_deployment.py +0 -0
  42. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_fork_safety.py +0 -0
  43. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_frameworks_langchain.py +0 -0
  44. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_http_patch.py +0 -0
  45. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_identify.py +0 -0
  46. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_manual_event_and_debug_output.py +0 -0
  47. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_patch_versions.py +0 -0
  48. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_query_scrub.py +0 -0
  49. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_redaction.py +0 -0
  50. {scope_analytics-0.1.5 → scope_analytics-0.1.6}/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.6
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
 
@@ -6,10 +6,10 @@ AI-powered analytics with automatic LLM conversation tracking
6
6
  import atexit
7
7
  import logging
8
8
  import sys
9
- from typing import Optional
9
+ from typing import Any, Optional
10
10
 
11
11
  from .config import ScopeConfig, DEFAULT_REDACT_PATTERNS
12
- from .context import ScopeContext
12
+ from .context import ScopeContext, accepted_user_id
13
13
  from .deployment import DeploymentContext
14
14
  from .events import EventFormatter
15
15
  from .queue import EventQueue
@@ -503,6 +503,12 @@ class ScopeAnalytics:
503
503
  # version at all).
504
504
  event.setdefault("sdk_version", self.config.sdk_version)
505
505
  event.setdefault("environment", self.config.environment)
506
+ # The browser's visitor id rides an event the app records in a request a page running Scope's
507
+ # script made, as on the SDK's own events (0.1.6) — only then: a manual event's fields are the
508
+ # caller's.
509
+ anonymous_id = ScopeContext.get_anonymous_id()
510
+ if anonymous_id:
511
+ event.setdefault("anonymous_id", anonymous_id)
506
512
  if "session_id" not in event:
507
513
  # generate_temp (NOT ensure_session_id): ensure_ would PIN the temp id
508
514
  # into the contextvar as a side effect — in a long-lived worker with no
@@ -522,7 +528,7 @@ class ScopeAnalytics:
522
528
  f"Manual event '{event_type}' failed validation and was dropped."
523
529
  )
524
530
 
525
- def identify(self, user_id: str, traits: Optional[dict] = None):
531
+ def identify(self, user_id: Any, traits: Optional[dict] = None):
526
532
  """
527
533
  Identify a user and stitch their prior anonymous activity to them.
528
534
 
@@ -533,7 +539,9 @@ class ScopeAnalytics:
533
539
  current request context so subsequent events are attributed to them.
534
540
 
535
541
  Args:
536
- user_id: Unique user identifier (whatever your app uses — email, sub, etc.)
542
+ user_id: Unique user identifier (whatever your app uses — email, sub, a database id) — text,
543
+ a whole number (kept as its digits, so 42 and "42" are one person), a UUID or an ObjectId,
544
+ up to 100 characters; anything else is ignored, and why said once (0.1.6)
537
545
  traits: Optional user traits (e.g., email, name, plan)
538
546
  """
539
547
  # Disabled construction has no formatter/queue (and possibly no config) —
@@ -541,9 +549,12 @@ class ScopeAnalytics:
541
549
  if not self.enabled:
542
550
  return
543
551
 
544
- if not user_id:
552
+ if user_id is None:
545
553
  self.config.log("identify() called without a user_id - ignoring")
546
554
  return
555
+ user_id = accepted_user_id(user_id, "identify")
556
+ if user_id is None:
557
+ return
547
558
 
548
559
  # Capture the prior (anonymous) identity BEFORE overwriting the context,
549
560
  # so the stitcher can link pre-identification events to this user. Prefer
@@ -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
 
@@ -70,6 +72,66 @@ _provider_call_in_flight: contextvars.ContextVar[bool] = contextvars.ContextVar(
70
72
  )
71
73
 
72
74
 
75
+ # The longest id Scope keeps — a session, a visitor, a user: the ingest refuses a longer one (the Node
76
+ # SDK's MAX_ID_LENGTH).
77
+ MAX_ID_LENGTH = 100
78
+
79
+
80
+ def fit_id(value: Any) -> Optional[str]:
81
+ """A session or visitor id from a request header as Scope keeps it: the text without the spaces
82
+ around it, of 1 to MAX_ID_LENGTH characters, else None — never cut, since a cut id could name
83
+ another session or visitor (0.1.6; the Node SDK's fitId). A session header that is not one falls
84
+ back to a temp session, as a missing one does."""
85
+ if not isinstance(value, str):
86
+ return None
87
+ value = value.strip()
88
+ return value if 0 < len(value) <= MAX_ID_LENGTH else None
89
+
90
+
91
+ # Why an app's id was ignored, each said once per process (a refused type is named, never the id).
92
+ _warned_refused_user_id: set = set()
93
+
94
+
95
+ def _refused(call: str, why: str) -> None:
96
+ if why not in _warned_refused_user_id:
97
+ _warned_refused_user_id.add(why)
98
+ logging.getLogger("scope_analytics").warning(
99
+ "[Scope SDK] %s() takes your app's id for the user — text, a whole number, a UUID or an ObjectId, "
100
+ "up to %d characters; one was ignored (%s)", call, MAX_ID_LENGTH, why)
101
+ return None
102
+
103
+
104
+ def accepted_user_id(user_id: Any, call: str) -> Optional[str]:
105
+ """The app's id for a user as Scope keeps it — its text, of 1 to MAX_ID_LENGTH characters — or None,
106
+ and why said once per process (0.1.6). Text is kept as given; a whole number becomes its digits (42
107
+ and "42" are one person: a number used to ride the event as a number, and identify(0) was ignored as
108
+ if no id had been given); a UUID or an ObjectId (a Mongo id, Beanie's too) becomes its text, as 0.1.5
109
+ sent it. True/False, a float, a list and any other object are no id: Scope would keep whatever its
110
+ text happens to be (the Node SDK's rule refuses an object for the same reason; it keeps any number).
111
+ None gives no id and says nothing."""
112
+ if user_id is None:
113
+ return None
114
+ if isinstance(user_id, bool):
115
+ return _refused(call, "true/false")
116
+ if isinstance(user_id, numbers.Integral):
117
+ # past 100 digits it is no id; refused before it is turned into text, which Python 3.11+ refuses
118
+ # for one of more than 4,300 digits by raising — into the app's own call
119
+ if int(user_id).bit_length() > 333: # 10**100 is 333 bits: anything longer has over 100 digits
120
+ return _refused(call, f"over {MAX_ID_LENGTH} characters")
121
+ text = str(int(user_id))
122
+ elif isinstance(user_id, str):
123
+ text = user_id
124
+ elif isinstance(user_id, uuid.UUID) or any(c.__name__ == "ObjectId" for c in type(user_id).__mro__):
125
+ text = str(user_id)
126
+ else:
127
+ return _refused(call, f"a {type(user_id).__name__}")
128
+ if not text:
129
+ return _refused(call, "empty")
130
+ if len(text) > MAX_ID_LENGTH:
131
+ return _refused(call, f"over {MAX_ID_LENGTH} characters")
132
+ return text
133
+
134
+
73
135
  class ScopeContext:
74
136
  """
75
137
  Manages request-scoped context using Python's contextvars.
@@ -99,14 +161,21 @@ class ScopeContext:
99
161
  return session_id_context.get()
100
162
 
101
163
  @staticmethod
102
- def set_user_id(user_id: str) -> None:
164
+ def set_user_id(user_id: Any) -> None:
103
165
  """
104
166
  Set the user ID for the current context
105
167
 
106
168
  Args:
107
- user_id: User ID from backend application
169
+ user_id: The app's id for the user — text, a whole number (kept as its digits), a UUID or an
170
+ ObjectId, up to 100 characters; anything else is ignored, and why said once (0.1.6). None
171
+ clears it.
108
172
  """
109
- user_id_context.set(user_id)
173
+ if user_id is None:
174
+ user_id_context.set(None)
175
+ return
176
+ accepted = accepted_user_id(user_id, "set_user_id")
177
+ if accepted is not None:
178
+ user_id_context.set(accepted)
110
179
 
111
180
  @staticmethod
112
181
  def get_user_id() -> Optional[str]:
@@ -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,
@@ -27,7 +27,7 @@ import time
27
27
  import base64
28
28
  import json
29
29
  from typing import Optional, Callable, Any
30
- from .context import ScopeContext
30
+ from .context import ScopeContext, MAX_ID_LENGTH, fit_id
31
31
 
32
32
  logger = logging.getLogger("scope_analytics.middleware")
33
33
 
@@ -184,13 +184,14 @@ def _extract_user_id_from_jwt(auth_header: Optional[str]) -> Optional[str]:
184
184
  payload_bytes = base64.b64decode(payload_b64)
185
185
  payload = json.loads(payload_bytes.decode("utf-8"))
186
186
 
187
- # Look for user ID in standard claims
187
+ # Look for user ID in standard claims — an object or a list names no user (the next claim
188
+ # may), and an id longer than Scope keeps is no user either: the browser's visitor id stands
189
+ # instead, as in the Node SDK (a longer one was dropped from every event it rode in)
188
190
  for claim in JWT_USER_ID_CLAIMS:
189
- if claim in payload:
190
- user_id = payload[claim]
191
- # Convert to string if needed
192
- if user_id is not None:
193
- return str(user_id)
191
+ user_id = payload.get(claim)
192
+ if user_id is not None and not isinstance(user_id, (dict, list)):
193
+ user_id = str(user_id)
194
+ return user_id if len(user_id) <= MAX_ID_LENGTH else None
194
195
 
195
196
  return None
196
197
 
@@ -226,11 +227,12 @@ def _auto_set_identity_from_headers(headers: dict) -> None:
226
227
  if key.lower() == "x-scope-anonymous-id":
227
228
  anonymous_id = value
228
229
  break
229
- # Defensive: ignore an absurd client-supplied value. It would be rejected at
230
- # ingestion anyway (user_id is length-capped), but don't carry it around or
231
- # let it become a runaway index key.
232
- if anonymous_id and len(anonymous_id) > 256:
233
- anonymous_id = None
230
+ # This id becomes the events' user_id, and Scope keeps a user id of at most
231
+ # MAX_ID_LENGTH characters (a longer one is dropped from its event; until
232
+ # 2026-10-29 it cost the whole batch). Longer is ignored, as if absent —
233
+ # never cut, since a cut id could name another visitor — and the spaces
234
+ # around it are not part of it (0.1.6, as the Node SDK reads it).
235
+ anonymous_id = fit_id(anonymous_id)
234
236
  if anonymous_id:
235
237
  ScopeContext.set_anonymous_id(anonymous_id)
236
238
 
@@ -267,22 +269,25 @@ def extract_session_from_headers(headers: dict) -> Optional[str]:
267
269
  headers: Dictionary of HTTP headers
268
270
 
269
271
  Returns:
270
- Session ID if found, None otherwise
272
+ Session ID if found and one Scope keeps (fit_id: 1–100 characters, the spaces around it
273
+ not part of it), None otherwise — so a session the ingest would refuse falls back to a temp
274
+ session for the whole request, as a missing one does (0.1.6), instead of each of the
275
+ request's events getting a session of its own at the ingest
271
276
  """
272
277
  # Try exact case first
273
278
  session_id = headers.get(SESSION_HEADER)
274
279
  if session_id:
275
- return session_id
280
+ return fit_id(session_id)
276
281
 
277
282
  # Try lowercase
278
283
  session_id = headers.get(SESSION_HEADER_LOWER)
279
284
  if session_id:
280
- return session_id
285
+ return fit_id(session_id)
281
286
 
282
287
  # Try case-insensitive search
283
288
  for key, value in headers.items():
284
289
  if key.lower() == SESSION_HEADER_LOWER:
285
- return value
290
+ return fit_id(value)
286
291
 
287
292
  return None
288
293
 
@@ -398,9 +403,9 @@ class ScopeSessionMiddleware:
398
403
  session_id = ScopeContext.generate_temp_session_id()
399
404
  ScopeContext.set_session_id(session_id)
400
405
  if self.log_missing_session:
401
- logger.debug(f"No X-Scope-Session-ID header, generated temp: {session_id}")
406
+ logger.debug(f"No usable X-Scope-Session-ID header, generated temp: {session_id}")
402
407
  elif self.log_missing_session:
403
- logger.warning("No X-Scope-Session-ID header and temp generation disabled")
408
+ logger.warning("No usable X-Scope-Session-ID header and temp generation disabled")
404
409
 
405
410
  # Auto-extract user_id from JWT (for identity stitching)
406
411
  _auto_set_identity_from_headers(headers)
@@ -528,9 +533,9 @@ class FlaskScopeMiddleware:
528
533
  session_id = ScopeContext.generate_temp_session_id()
529
534
  ScopeContext.set_session_id(session_id)
530
535
  if self.log_missing_session:
531
- logger.debug(f"No X-Scope-Session-ID header, generated temp: {session_id}")
536
+ logger.debug(f"No usable X-Scope-Session-ID header, generated temp: {session_id}")
532
537
  elif self.log_missing_session:
533
- logger.warning("No X-Scope-Session-ID header and temp generation disabled")
538
+ logger.warning("No usable X-Scope-Session-ID header and temp generation disabled")
534
539
 
535
540
  # Auto-extract user_id from JWT (for identity stitching)
536
541
  _auto_set_identity_from_headers(headers)
@@ -618,7 +623,7 @@ def init_flask_session_tracking(app, generate_temp_session: bool = True):
618
623
  # Start timing
619
624
  g.scope_start_time = time.time()
620
625
 
621
- session_id = request.headers.get(SESSION_HEADER)
626
+ session_id = fit_id(request.headers.get(SESSION_HEADER))
622
627
  if session_id:
623
628
  ScopeContext.set_session_id(session_id)
624
629
  g.scope_session_id = session_id
@@ -631,6 +636,11 @@ def init_flask_session_tracking(app, generate_temp_session: bool = True):
631
636
  flask_headers = dict(request.headers)
632
637
  _auto_set_identity_from_headers(flask_headers)
633
638
 
639
+ # The request's URL, referrer, thread id and User-Agent for the events it makes, as every other
640
+ # entry point sets them (0.1.6): until then an llm_call on a zero-code Flask app carried none, so
641
+ # an X-Scope-Thread-ID header was never read there
642
+ _set_request_context(flask_headers, request.path)
643
+
634
644
  @app.after_request
635
645
  def track_http_request_and_clear_context(response):
636
646
  # Track HTTP request
@@ -688,11 +698,13 @@ def get_scope_session():
688
698
  from fastapi import Request
689
699
 
690
700
  async def dependency(request: Request) -> str:
691
- session_id = request.headers.get(SESSION_HEADER)
701
+ session_id = fit_id(request.headers.get(SESSION_HEADER))
692
702
  if session_id:
693
703
  ScopeContext.set_session_id(session_id)
694
704
  else:
695
705
  session_id = ScopeContext.ensure_session_id()
706
+ # the login token's user and the page's visitor id, as the middleware reads them (0.1.6)
707
+ _auto_set_identity_from_headers(dict(request.headers))
696
708
  return session_id
697
709
 
698
710
  return dependency
@@ -741,6 +753,7 @@ class DjangoScopeMiddleware:
741
753
  # Also check for direct header (some configurations)
742
754
  if not session_id:
743
755
  session_id = request.headers.get(SESSION_HEADER)
756
+ session_id = fit_id(session_id)
744
757
 
745
758
  if session_id:
746
759
  ScopeContext.set_session_id(session_id)
@@ -748,9 +761,9 @@ class DjangoScopeMiddleware:
748
761
  session_id = ScopeContext.generate_temp_session_id()
749
762
  ScopeContext.set_session_id(session_id)
750
763
  if self.log_missing_session:
751
- logger.debug(f"No X-Scope-Session-ID header, generated temp: {session_id}")
764
+ logger.debug(f"No usable X-Scope-Session-ID header, generated temp: {session_id}")
752
765
  elif self.log_missing_session:
753
- logger.warning("No X-Scope-Session-ID header and temp generation disabled")
766
+ logger.warning("No usable X-Scope-Session-ID header and temp generation disabled")
754
767
 
755
768
  # Auto-extract user_id from JWT (for identity stitching)
756
769
  # Django stores headers in META with HTTP_ prefix
@@ -36,9 +36,10 @@ SUPPORTED_VERSIONS = {
36
36
  # 3.x (2026-09): the majors keep the modern client shape, so the structural routing and
37
37
  # both capture surfaces are unchanged — verified by the contract cells below (3.8.0 at
38
38
  # 0.1.3; 3.14.1 at 0.1.4; 3.24.0 at 0.1.5, which also added the with_raw_response cells; 3.26.1
39
- # by the 0.1.5 publish dry run, 2026-10-09 — the whole suite green on it but this drift check).
40
- "max_tested": "3.26.1",
41
- "ci_matrix": ["1.0.0", "1.30.0", "1.57.0", "2.41.0", "2.46.0", "3.8.0", "3.14.1", "3.24.0", "3.26.1"],
39
+ # by the 0.1.5 publish dry run, 2026-10-09 — the whole suite green on it but this drift check;
40
+ # 3.42.1 by the 0.1.6 publish dry run, 2026-11-03, the same way, and its contract cell).
41
+ "max_tested": "3.42.1",
42
+ "ci_matrix": ["1.0.0", "1.30.0", "1.57.0", "2.41.0", "2.46.0", "3.8.0", "3.14.1", "3.24.0", "3.26.1", "3.42.1"],
42
43
  },
43
44
  "anthropic": {
44
45
  "import_name": "anthropic",
@@ -53,8 +54,11 @@ SUPPORTED_VERSIONS = {
53
54
  # (1.4.0 verified at 0.1.3; 1.6.0 at 0.1.4; 1.11.0 at 0.1.5 — with_raw_response included,
54
55
  # whose wrapper in 1.x is APIResponse with a coroutine parse() on the async client; 1.12.1 by
55
56
  # the 0.1.5 publish dry run, 2026-10-09).
56
- "max_tested": "1.12.1",
57
- "ci_matrix": ["0.18.0", "0.30.0", "0.40.0", "0.116.0", "1.4.0", "1.6.0", "1.11.0", "1.12.1"],
57
+ # 2.x (2026-10-21): the 0.1.6 publish dry run (2026-11-03) ran the whole suite green on 2.4.0 but
58
+ # this drift check — the patch surfaces are there and captured — and the 2.0.0 and 2.4.0 contract
59
+ # cells hold it.
60
+ "max_tested": "2.4.0",
61
+ "ci_matrix": ["0.18.0", "0.30.0", "0.40.0", "0.116.0", "1.4.0", "1.6.0", "1.11.0", "1.12.1", "2.0.0", "2.4.0"],
58
62
  },
59
63
  "gemini": {
60
64
  # Targets the LEGACY google-generativeai SDK (import google.generativeai), which gemini_patch.py
@@ -82,9 +86,10 @@ SUPPORTED_VERSIONS = {
82
86
  "min_supported": "1.0.0", # the unified SDK GA'd at v1.x (~May 2025)
83
87
  # Verified by tests/test_capture_contract_google.py against 1.60.0 AND 2.11.0 (current as of
84
88
  # 2026-07; the 2.0 major's breaking changes were interactions-only — generate_content stable);
85
- # 2.22.0 at 0.1.3, 2.23.0 at 0.1.4, 2.28.0 at 0.1.5; 2.29.0 by the 0.1.5 publish dry run.
86
- "max_tested": "2.29.0",
87
- "ci_matrix": ["1.0.0", "1.60.0", "2.11.0", "2.22.0", "2.23.0", "2.28.0", "2.29.0"],
89
+ # 2.22.0 at 0.1.3, 2.23.0 at 0.1.4, 2.28.0 at 0.1.5; 2.29.0 by the 0.1.5 publish dry run; 2.34.0 by
90
+ # the 0.1.6 publish dry run (2026-11-03) and its contract cell.
91
+ "max_tested": "2.34.0",
92
+ "ci_matrix": ["1.0.0", "1.60.0", "2.11.0", "2.22.0", "2.23.0", "2.28.0", "2.29.0", "2.34.0"],
88
93
  },
89
94
  }
90
95
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scope-analytics
3
- Version: 0.1.5
3
+ Version: 0.1.6
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
 
@@ -40,6 +40,7 @@ tests/test_frameworks_langchain.py
40
40
  tests/test_http_patch.py
41
41
  tests/test_identify.py
42
42
  tests/test_identity_bridge.py
43
+ tests/test_ids_0_1_6.py
43
44
  tests/test_manual_event_and_debug_output.py
44
45
  tests/test_patch_versions.py
45
46
  tests/test_query_scrub.py
@@ -11,7 +11,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
11
11
 
12
12
  setup(
13
13
  name="scope-analytics",
14
- version="0.1.5",
14
+ version="0.1.6",
15
15
  author="Scope AI",
16
16
  author_email="support@scopeai.dev",
17
17
  description="AI-powered analytics SDK for backend applications with automatic LLM tracking",
@@ -137,3 +137,49 @@ def test_a_reason_that_merely_contains_ip_does_not_get_the_ip_pointer(caplog):
137
137
  finally:
138
138
  sdk.shutdown()
139
139
  sink.close()
140
+
141
+
142
+ def test_an_event_scope_refused_or_changed_is_said_once_per_reason(caplog):
143
+ """The ingest judges each event on its own and says, in a 200, which it refused or kept with
144
+ a change — before, one such event refused the whole batch and the SDK said "could not deliver"."""
145
+ caplog.set_level(logging.WARNING, logger="scope_analytics")
146
+ reply = {"status": "success", "events_received": 3, "events_stored": 2, "events_rejected": 1,
147
+ "events_repaired": 1,
148
+ "rejected": [{"index": 0, "field": "session_id", "reason": "String should have at most 100 characters"}],
149
+ "repaired": [{"index": 1, "field": "user_id",
150
+ "reason": "String should have at most 100 characters; the event was kept without it"}]}
151
+ sink = _Sink(reply)
152
+ sdk = _sdk(sink)
153
+ try:
154
+ for tag in ("a", "b"):
155
+ sdk.track_event("probe", {"tag": tag})
156
+ sdk.flush()
157
+ said = [w for w in _warnings(caplog) if "Scope refused" in w or "Scope stored" in w]
158
+ assert len(said) == 2, said # once per reason, not per batch
159
+ assert "(session_id: String should have at most 100 characters)" in said[0] and "judged on its own" in said[0]
160
+ assert "Scope stored an event with a change (user_id: String should have" in said[1]
161
+ sink.reply = {**reply, "rejected": [{"index": 0, "field": None, "reason": "An event must be a JSON object"}]}
162
+ sdk.track_event("probe", {"tag": "c"})
163
+ sdk.flush()
164
+ assert any("Scope refused an event (An event must be a JSON object)" in w for w in _warnings(caplog))
165
+ assert not [w for w in _warnings(caplog) if "discarded" in w.lower()]
166
+ finally:
167
+ sdk.shutdown()
168
+ sink.close()
169
+
170
+
171
+ def test_a_reply_not_shaped_as_scopes_says_nothing_and_fails_no_batch(caplog):
172
+ """A field or reason that is not text (a proxy's reply, say) is not Scope's to say — and must not
173
+ make a stored batch look undelivered."""
174
+ caplog.set_level(logging.WARNING, logger="scope_analytics")
175
+ sink = _Sink({"status": "success", "events_stored": 1, "events_rejected": 1,
176
+ "rejected": [{"index": 0, "field": ["x"], "reason": {"a": 1}}, "nonsense", {"reason": None}]})
177
+ sdk = _sdk(sink)
178
+ try:
179
+ sdk.track_event("probe", {"tag": "a"})
180
+ sdk.flush()
181
+ assert sink.batches == 1
182
+ assert not [w for w in _warnings(caplog) if "Scope refused" in w or "Could not deliver" in w]
183
+ finally:
184
+ sdk.shutdown()
185
+ sink.close()
@@ -86,6 +86,52 @@ def test_absurdly_long_anon_header_is_ignored():
86
86
  ScopeContext.clear()
87
87
 
88
88
 
89
+ def test_anon_header_is_kept_up_to_the_ingests_100_characters_and_never_cut():
90
+ """It becomes the events' user_id, and the ingest keeps a user id of at most 100
91
+ characters: until 2026-10-29 one of 101-256 refused the whole batch it rode in."""
92
+ from scope_analytics.middleware import MAX_ID_LENGTH
93
+
94
+ assert MAX_ID_LENGTH == 100
95
+ ScopeContext.clear()
96
+ _auto_set_identity_from_headers({"X-Scope-Anonymous-ID": "a" * 100})
97
+ assert ScopeContext.get_anonymous_id() == "a" * 100
98
+ assert ScopeContext.get_user_id() == "a" * 100
99
+ ScopeContext.clear()
100
+ _auto_set_identity_from_headers({"X-Scope-Anonymous-ID": "a" * 101})
101
+ assert ScopeContext.get_anonymous_id() is None # ignored, as if absent
102
+ assert ScopeContext.get_user_id() is None
103
+ ScopeContext.clear()
104
+ # a JWT's user still names the request when the header is ignored
105
+ _auto_set_identity_from_headers({"Authorization": f"Bearer {_unsigned_jwt({'sub': 'real_user'})}",
106
+ "X-Scope-Anonymous-ID": "a" * 101})
107
+ assert ScopeContext.get_user_id() == "real_user" and ScopeContext.get_anonymous_id() is None
108
+ ScopeContext.clear()
109
+
110
+
111
+ def test_a_token_user_past_100_characters_gives_way_to_the_visitor_id():
112
+ """Node's fitId, in Python: the ingest drops a user id over 100 characters from every event
113
+ it rides in, so the browser's visitor id names the request instead."""
114
+ ScopeContext.clear()
115
+ token = _unsigned_jwt({"sub": "s" * 101})
116
+ _auto_set_identity_from_headers({"Authorization": f"Bearer {token}", "X-Scope-Anonymous-ID": "user_anon_5"})
117
+ assert ScopeContext.get_user_id() == "user_anon_5"
118
+ ScopeContext.clear()
119
+ _auto_set_identity_from_headers({"Authorization": f"Bearer {_unsigned_jwt({'sub': 's' * 100})}"})
120
+ assert ScopeContext.get_user_id() == "s" * 100
121
+ ScopeContext.clear()
122
+
123
+
124
+ def test_a_token_claim_that_is_an_object_names_no_user_and_the_next_claim_may():
125
+ ScopeContext.clear()
126
+ token = _unsigned_jwt({"sub": {"id": 7, "email": "a@b.example"}, "user_id": "u_7"})
127
+ _auto_set_identity_from_headers({"Authorization": f"Bearer {token}"})
128
+ assert ScopeContext.get_user_id() == "u_7" # never "{'id': 7, 'email': ...}"
129
+ ScopeContext.clear()
130
+ _auto_set_identity_from_headers({"Authorization": f"Bearer {_unsigned_jwt({'user': ['a', 'b']})}"})
131
+ assert ScopeContext.get_user_id() is None
132
+ ScopeContext.clear()
133
+
134
+
89
135
  if __name__ == "__main__":
90
136
  tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
91
137
  for t in tests:
@@ -0,0 +1,390 @@
1
+ """Ids as Scope keeps them, and the browser's visitor id on every server event (scope-analytics 0.1.6,
2
+ the Node SDK's rules):
3
+
4
+ (a) a session header the ingest would refuse — over 100 characters, or only spaces — falls back to a
5
+ temp session for the whole request, at every entry point (ASGI, the WSGI wrapper, Flask's hooks,
6
+ FastAPI's dependency, Django), and the spaces around a header's id are not part of it. Until 0.1.6
7
+ the header's value was the session as it came: the ingest gave each of the request's events a
8
+ session of its own instead
9
+ (b) identify() and ScopeContext.set_user_id() take text, a whole number (kept as its digits — until
10
+ 0.1.6 a number rode the event as a number, and identify(0) was ignored as if no id were given), a
11
+ UUID or an ObjectId (as their text, as 0.1.5 sent them); anything else (over 100 characters,
12
+ empty, True, a float, a list, any other object) is ignored, why said once per process (the type
13
+ named, never the id); None says nothing; set_user_id(None) clears; a number of thousands of digits
14
+ is refused without raising into the app
15
+ (d) the Flask hooks set the request's URL, referrer, thread id and User-Agent for its events, and the
16
+ FastAPI dependency reads the token's user and the page's visitor id, as the middleware does
17
+ (c) every event the SDK makes in a request — llm_call, http_request, external_api_call — carries the
18
+ browser's visitor id (X-Scope-Anonymous-ID) as anonymous_id beside user_id: with a login token,
19
+ the sign-in and the browser it was used in, recorded together. None when no page sent one; an
20
+ app's own event (track_event) gets it only when there is one, and keeps its own
21
+
22
+ No network: every SDK here ships nowhere and records into a list.
23
+ """
24
+ import asyncio
25
+ import base64
26
+ import json
27
+ import logging
28
+ import uuid
29
+ from types import SimpleNamespace
30
+
31
+ import pytest
32
+
33
+ from scope_analytics import ScopeAnalytics
34
+ from scope_analytics import context as scope_context
35
+ from scope_analytics.context import MAX_ID_LENGTH, ScopeContext, accepted_user_id, fit_id
36
+ from scope_analytics.middleware import (
37
+ DjangoScopeMiddleware,
38
+ FlaskScopeMiddleware,
39
+ ScopeSessionMiddleware,
40
+ extract_session_from_headers,
41
+ get_scope_session,
42
+ )
43
+
44
+ KEY = "sk_test_fake_local_only"
45
+ LONG = "s" * (MAX_ID_LENGTH + 1)
46
+ EDGE = "s" * MAX_ID_LENGTH
47
+
48
+
49
+ @pytest.fixture(autouse=True)
50
+ def _clean(monkeypatch):
51
+ """No deployment event at construction, a clean context, and the once-per-process warning re-armed."""
52
+ from scope_analytics.deployment import DeploymentContext
53
+ monkeypatch.setattr(DeploymentContext, "should_emit_deployment_detected", lambda self: False)
54
+ monkeypatch.setattr(scope_context, "_warned_refused_user_id", set())
55
+ ScopeContext.clear()
56
+ yield
57
+ ScopeContext.clear()
58
+
59
+
60
+ @pytest.fixture
61
+ def sdk():
62
+ s = ScopeAnalytics(api_key=KEY, endpoint="http://127.0.0.1:1", capture_http=False, auto_patch=False,
63
+ batch_timeout_seconds=30)
64
+ s.client.ship_events = lambda events: True
65
+ s.captured = []
66
+ s.queue.enqueue = lambda event: s.captured.append(event)
67
+ yield s
68
+ s.shutdown()
69
+
70
+
71
+ def _jwt(claims):
72
+ payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=")
73
+ return f"hdr.{payload}.sig"
74
+
75
+
76
+ def _http_requests(sdk):
77
+ return [e for e in sdk.captured if e.get("event_type") == "http_request"]
78
+
79
+
80
+ # ---------------------------------------------------------------- (a) the session header
81
+
82
+ def test_fit_id_keeps_an_id_of_1_to_100_characters_without_the_spaces_around_it():
83
+ assert fit_id("sess_1") == "sess_1"
84
+ assert fit_id(" sess_1 \t") == "sess_1"
85
+ assert fit_id(EDGE) == EDGE
86
+ assert fit_id(LONG) is None, "never cut: a cut id could name another session"
87
+ assert fit_id(" ") is None
88
+ assert fit_id("") is None
89
+ assert fit_id(None) is None
90
+ assert fit_id(12345) is None
91
+
92
+
93
+ def test_extract_session_from_headers_fits_every_spelling():
94
+ assert extract_session_from_headers({"X-Scope-Session-ID": " sess_1 "}) == "sess_1"
95
+ assert extract_session_from_headers({"x-scope-session-id": LONG}) is None
96
+ assert extract_session_from_headers({"X-SCOPE-SESSION-ID": " "}) is None
97
+ assert extract_session_from_headers({"X-Scope-Session-Id": EDGE}) == EDGE
98
+
99
+
100
+ def _run_asgi(headers):
101
+ seen = {}
102
+
103
+ async def app(scope, receive, send):
104
+ seen["session"] = ScopeContext.get_session_id()
105
+ await send({"type": "http.response.start", "status": 200, "headers": []})
106
+ await send({"type": "http.response.body", "body": b"ok"})
107
+
108
+ scope = {"type": "http", "method": "GET", "path": "/api/chat", "query_string": b"", "client": ("10.0.0.9", 1),
109
+ "headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()]}
110
+
111
+ async def receive():
112
+ return {"type": "http.request", "body": b"", "more_body": False}
113
+
114
+ async def send(message):
115
+ pass
116
+
117
+ asyncio.run(ScopeSessionMiddleware(app)(scope, receive, send))
118
+ return seen["session"]
119
+
120
+
121
+ def _run_wsgi(environ_headers):
122
+ seen = {}
123
+
124
+ def app(environ, start_response):
125
+ seen["session"] = ScopeContext.get_session_id()
126
+ start_response("200 OK", [])
127
+ return [b"ok"]
128
+
129
+ environ = {"REQUEST_METHOD": "GET", "PATH_INFO": "/api/chat", "QUERY_STRING": "", "REMOTE_ADDR": "10.0.0.9",
130
+ **environ_headers}
131
+ list(FlaskScopeMiddleware(app)(environ, lambda status, headers, exc_info=None: None))
132
+ return seen["session"]
133
+
134
+
135
+ class _DjangoRequest:
136
+ """What DjangoScopeMiddleware reads from a Django request — no Django needed."""
137
+
138
+ def __init__(self, meta):
139
+ self.META = {"REMOTE_ADDR": "10.0.0.9", **meta}
140
+ self.method = "GET"
141
+ self.path = "/api/chat"
142
+ self.GET = {}
143
+ self.content_type = None
144
+ self.headers = {k[5:].replace("_", "-").title(): v for k, v in meta.items() if k.startswith("HTTP_")}
145
+
146
+
147
+ def _run_django(meta):
148
+ seen = {}
149
+
150
+ def get_response(request):
151
+ seen["session"] = ScopeContext.get_session_id()
152
+ return SimpleNamespace(status_code=200)
153
+
154
+ DjangoScopeMiddleware(get_response)(_DjangoRequest(meta))
155
+ return seen["session"]
156
+
157
+
158
+ @pytest.mark.parametrize("run, header", [
159
+ (_run_asgi, lambda v: {"X-Scope-Session-ID": v}),
160
+ (_run_wsgi, lambda v: {"HTTP_X_SCOPE_SESSION_ID": v}),
161
+ (_run_django, lambda v: {"HTTP_X_SCOPE_SESSION_ID": v}),
162
+ ], ids=["asgi", "wsgi", "django"])
163
+ def test_every_middleware_keeps_a_usable_session_and_falls_back_for_one_the_ingest_would_refuse(sdk, run, header):
164
+ assert run(header(" sess_browser ")) == "sess_browser", "the spaces around it are not part of it"
165
+ assert run(header(EDGE)) == EDGE
166
+ long_session = run(header(LONG))
167
+ assert long_session.startswith("temp_") and long_session != LONG, "over 100: a temp session, as if absent"
168
+ assert run(header(" ")).startswith("temp_")
169
+ # the request's own event carries the session the request ran in, so its events share one
170
+ sessions = [e["session_id"] for e in _http_requests(sdk)]
171
+ assert sessions[0] == "sess_browser" and sessions[2] == long_session
172
+
173
+
174
+ def test_flask_hooks_keep_a_usable_session_and_fall_back_for_one_the_ingest_would_refuse(sdk):
175
+ flask = pytest.importorskip("flask")
176
+ from scope_analytics.middleware import init_flask_session_tracking
177
+
178
+ app = flask.Flask(__name__)
179
+ init_flask_session_tracking(app)
180
+ seen = []
181
+
182
+ @app.route("/api/chat")
183
+ def chat():
184
+ seen.append(ScopeContext.get_session_id())
185
+ return "ok"
186
+
187
+ client = app.test_client()
188
+ client.get("/api/chat", headers={"X-Scope-Session-ID": " sess_browser "})
189
+ client.get("/api/chat", headers={"X-Scope-Session-ID": LONG})
190
+ assert seen[0] == "sess_browser"
191
+ assert seen[1].startswith("temp_")
192
+
193
+
194
+ def test_fastapi_dependency_keeps_a_usable_session_and_falls_back_for_one_the_ingest_would_refuse():
195
+ pytest.importorskip("fastapi")
196
+ dependency = get_scope_session()
197
+ kept = asyncio.run(dependency(SimpleNamespace(headers={"X-Scope-Session-ID": " sess_browser "})))
198
+ assert kept == "sess_browser"
199
+ ScopeContext.clear()
200
+ fallback = asyncio.run(dependency(SimpleNamespace(headers={"X-Scope-Session-ID": LONG})))
201
+ assert fallback.startswith("temp_")
202
+
203
+
204
+ def test_the_visitor_header_is_fitted_the_same_way(sdk):
205
+ _run_asgi({"X-Scope-Anonymous-ID": " user_v1 "})
206
+ _run_asgi({"X-Scope-Anonymous-ID": LONG})
207
+ first, second = _http_requests(sdk)
208
+ assert first["anonymous_id"] == "user_v1" and first["user_id"] == "user_v1"
209
+ assert second["anonymous_id"] is None and second["user_id"] is None
210
+
211
+
212
+ # ---------------------------------------------------------------- (b) the app's id for a user
213
+
214
+ class ObjectId:
215
+ """bson's ObjectId, as an app's Mongo id arrives — matched by its class name, no bson import."""
216
+
217
+ def __init__(self, hexid):
218
+ self.hexid = hexid
219
+
220
+ def __str__(self):
221
+ return self.hexid
222
+
223
+
224
+ class PydanticObjectId(ObjectId):
225
+ """Beanie's id: a subclass with another name."""
226
+
227
+
228
+ UID = uuid.UUID("6f1c2a52-0d4e-4f4b-9a9e-2b8a1c3d4e5f")
229
+
230
+
231
+ @pytest.mark.parametrize("given, kept", [
232
+ ("user_42", "user_42"), (42, "42"), (0, "0"), (-7, "-7"), ("0", "0"), (EDGE, EDGE), (" padded ", " padded "),
233
+ (UID, "6f1c2a52-0d4e-4f4b-9a9e-2b8a1c3d4e5f"),
234
+ (ObjectId("65a1b2c3d4e5f60718293a4b"), "65a1b2c3d4e5f60718293a4b"),
235
+ (PydanticObjectId("65a1b2c3d4e5f60718293a4c"), "65a1b2c3d4e5f60718293a4c"),
236
+ (10 ** 99, "1" + "0" * 99),
237
+ ])
238
+ def test_accepted_user_id_keeps_text_whole_numbers_uuids_and_objectids(given, kept):
239
+ assert accepted_user_id(given, "identify") == kept
240
+
241
+
242
+ @pytest.mark.parametrize("given", [LONG, "", True, False, 3.5, 42.0, {"id": 1}, ["u"], object(), 10 ** 100, 10 ** 5000],
243
+ ids=["long", "empty", "true", "false", "float", "whole-float", "dict", "list", "object",
244
+ "101-digits", "5001-digits"]) # a 5,001-digit number cannot even be a test's name
245
+ def test_accepted_user_id_refuses_anything_else_without_raising(given):
246
+ assert accepted_user_id(given, "identify") is None
247
+
248
+
249
+ def test_no_id_given_says_nothing(caplog):
250
+ with caplog.at_level(logging.WARNING, logger="scope_analytics"):
251
+ assert accepted_user_id(None, "identify") is None
252
+ assert [r for r in caplog.records if r.name == "scope_analytics"] == []
253
+
254
+
255
+ def test_why_an_id_was_ignored_is_said_once_per_reason_never_the_id(caplog):
256
+ with caplog.at_level(logging.WARNING, logger="scope_analytics"):
257
+ accepted_user_id(LONG, "identify")
258
+ accepted_user_id(LONG + "x", "identify")
259
+ accepted_user_id(3.5, "set_user_id")
260
+ accepted_user_id(2.5, "identify")
261
+ accepted_user_id(True, "identify")
262
+ accepted_user_id(None, "identify")
263
+ said = [r.getMessage() for r in caplog.records if r.name == "scope_analytics"]
264
+ assert len(said) == 3, said
265
+ assert "identify() takes your app's id for the user" in said[0] and "(over 100 characters)" in said[0]
266
+ assert "set_user_id() takes" in said[1] and "(a float)" in said[1]
267
+ assert "(true/false)" in said[2]
268
+ assert not any(LONG in m for m in said), "the id itself is never logged"
269
+
270
+
271
+ def test_set_user_id_keeps_a_number_as_its_digits_ignores_a_refused_id_and_none_clears(caplog):
272
+ ScopeContext.set_user_id(42)
273
+ assert ScopeContext.get_user_id() == "42"
274
+ ScopeContext.set_user_id(UID)
275
+ assert ScopeContext.get_user_id() == str(UID)
276
+ with caplog.at_level(logging.WARNING, logger="scope_analytics"):
277
+ ScopeContext.set_user_id(LONG)
278
+ assert ScopeContext.get_user_id() == str(UID), "a refused id leaves the one there"
279
+ assert any("set_user_id() takes" in r.getMessage() for r in caplog.records)
280
+ ScopeContext.set_user_id(None)
281
+ assert ScopeContext.get_user_id() is None
282
+
283
+
284
+ def test_identify_takes_a_number_as_its_digits_zero_is_an_id_and_a_uuid_as_its_text(sdk):
285
+ sdk.identify(42, {"plan": "pro"})
286
+ sdk.identify(0)
287
+ sdk.identify(UID)
288
+ identifies = [e for e in sdk.captured if e["event_type"] == "identify"]
289
+ assert [e["identified_user_id"] for e in identifies] == ["42", "0", str(UID)]
290
+ assert ScopeContext.get_user_id() == str(UID)
291
+
292
+
293
+ def test_identify_ignores_an_id_the_ingest_would_refuse_and_none_quietly(sdk, caplog):
294
+ with caplog.at_level(logging.WARNING, logger="scope_analytics"):
295
+ sdk.identify(None)
296
+ assert [r for r in caplog.records if r.name == "scope_analytics"] == [], "no id given: nothing to say"
297
+ sdk.identify(LONG)
298
+ sdk.identify("")
299
+ sdk.identify(10 ** 5000)
300
+ assert [e for e in sdk.captured if e["event_type"] == "identify"] == []
301
+ said = [r.getMessage() for r in caplog.records if "takes your app's id" in r.getMessage()]
302
+ assert len(said) == 2, said # over 100 characters (twice), empty
303
+ assert ScopeContext.get_user_id() is None
304
+
305
+
306
+ # ---------------------------------------------------------------- (d) the Flask hooks and the FastAPI dependency
307
+
308
+ def test_flask_hooks_set_the_requests_context_for_its_events(sdk):
309
+ flask = pytest.importorskip("flask")
310
+ from scope_analytics.middleware import init_flask_session_tracking
311
+
312
+ app = flask.Flask(__name__)
313
+ init_flask_session_tracking(app)
314
+ seen = {}
315
+
316
+ @app.route("/api/chat")
317
+ def chat():
318
+ seen.update(url=ScopeContext.get_url(), thread=ScopeContext.get_thread_id(),
319
+ referrer=ScopeContext.get_referrer(), visitor=ScopeContext.get_anonymous_id())
320
+ return "ok"
321
+
322
+ app.test_client().get("/api/chat", headers={
323
+ "X-Scope-Thread-ID": "thread-9", "Referer": "https://app.example.com/page", "X-Scope-Anonymous-ID": "user_v1"})
324
+ assert seen == {"url": "/api/chat", "thread": "thread-9", "referrer": "https://app.example.com/page",
325
+ "visitor": "user_v1"}
326
+
327
+
328
+ def test_fastapi_dependency_reads_the_token_user_and_the_visitor_id():
329
+ pytest.importorskip("fastapi")
330
+ dependency = get_scope_session()
331
+
332
+ async def request(): # the context the dependency sets is the request's own: read inside it
333
+ await dependency(SimpleNamespace(headers={
334
+ "X-Scope-Session-ID": "sess_browser", "X-Scope-Anonymous-ID": "user_v1",
335
+ "Authorization": "Bearer " + _jwt({"sub": "u_signed_in"})}))
336
+ return ScopeContext.get_user_id(), ScopeContext.get_anonymous_id()
337
+
338
+ assert asyncio.run(request()) == ("u_signed_in", "user_v1")
339
+
340
+
341
+ # ---------------------------------------------------------------- (c) the browser's visitor id on every server event
342
+
343
+ def _formatter(sdk):
344
+ return sdk.event_formatter
345
+
346
+
347
+ def test_every_event_the_sdk_makes_in_a_request_carries_the_visitor_id_beside_the_sign_in(sdk):
348
+ ScopeContext.set_session_id("sess_browser")
349
+ ScopeContext.set_anonymous_id("user_v1")
350
+ ScopeContext.set_user_id("u_signed_in")
351
+ f = _formatter(sdk)
352
+ events = [
353
+ f.format_llm_call(provider="openai", model="m", messages=[{"role": "user", "content": "hi"}], response="yo",
354
+ tokens={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, latency_ms=1.0),
355
+ f.format_http_request(method="GET", path="/api/chat", status_code=200, latency_ms=1.0),
356
+ f.format_external_api_call(method="GET", url="https://api.example.com/x", status_code=200, latency_ms=1.0),
357
+ ]
358
+ for event in events:
359
+ assert (event["user_id"], event["anonymous_id"]) == ("u_signed_in", "user_v1"), event["event_type"]
360
+
361
+
362
+ def test_with_no_page_the_visitor_id_is_none(sdk):
363
+ f = _formatter(sdk)
364
+ events = [
365
+ f.format_llm_call(provider="openai", model="m", messages=[], response="", latency_ms=1.0),
366
+ f.format_http_request(method="GET", path="/health", status_code=200, latency_ms=1.0),
367
+ f.format_external_api_call(method="GET", url="https://api.example.com/x", status_code=200, latency_ms=1.0),
368
+ ]
369
+ assert [e["anonymous_id"] for e in events] == [None, None, None]
370
+
371
+
372
+ def test_a_request_with_a_login_token_records_the_sign_in_and_the_browser_together(sdk):
373
+ _run_asgi({
374
+ "X-Scope-Session-ID": "sess_browser",
375
+ "X-Scope-Anonymous-ID": "user_v1",
376
+ "Authorization": "Bearer " + _jwt({"sub": "u_signed_in"}),
377
+ })
378
+ (event,) = _http_requests(sdk)
379
+ assert (event["session_id"], event["user_id"], event["anonymous_id"]) == ("sess_browser", "u_signed_in", "user_v1")
380
+
381
+
382
+ def test_an_apps_own_event_gets_the_visitor_id_only_when_there_is_one_and_keeps_its_own(sdk):
383
+ sdk.track_event("plan_upgraded", {"plan": "pro"})
384
+ ScopeContext.set_anonymous_id("user_v1")
385
+ sdk.track_event("plan_upgraded", {"plan": "team"})
386
+ sdk.track_event("plan_upgraded", {"plan": "max", "anonymous_id": "the_apps_own"})
387
+ no_page, in_request, own = [e for e in sdk.captured if e["event_type"] == "plan_upgraded"]
388
+ assert "anonymous_id" not in no_page, "an app's own event's fields are the caller's: nothing added when no page sent one"
389
+ assert in_request["anonymous_id"] == "user_v1"
390
+ assert own["anonymous_id"] == "the_apps_own"
File without changes