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.
- {scope_analytics-0.1.5/scope_analytics.egg-info → scope_analytics-0.1.6}/PKG-INFO +2 -2
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/README.md +1 -1
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/__init__.py +16 -5
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/client.py +38 -3
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/context.py +73 -4
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/events.py +6 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/middleware.py +37 -24
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/supported_versions.py +13 -8
- {scope_analytics-0.1.5 → scope_analytics-0.1.6/scope_analytics.egg-info}/PKG-INFO +2 -2
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics.egg-info/SOURCES.txt +1 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/setup.py +1 -1
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_discard_reply.py +46 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_identity_bridge.py +46 -0
- scope_analytics-0.1.6/tests/test_ids_0_1_6.py +390 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/LICENSE +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/MANIFEST.in +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/auto.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/cli.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/config.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/deployment.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/__init__.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/_capture.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/_raw_response.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/_streaming.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/anthropic_patch.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/gemini_patch.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/google_genai_patch.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/http_patch.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/openai_patch.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/queue.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics.egg-info/dependency_links.txt +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics.egg-info/entry_points.txt +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics.egg-info/requires.txt +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics.egg-info/top_level.txt +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/setup.cfg +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_capture_contract.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_capture_contract_google.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_cli_exec.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_client_ip.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_coverage_honesty.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_deployment.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_fork_safety.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_frameworks_langchain.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_http_patch.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_identify.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_manual_event_and_debug_output.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_patch_versions.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_query_scrub.py +0 -0
- {scope_analytics-0.1.5 → scope_analytics-0.1.6}/tests/test_redaction.py +0 -0
- {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.
|
|
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
|
|
188
|
+
| `SCOPE_ENVIRONMENT` | No | Environment tag on every event (default: production). When it is unset or `production`, events sent with a key you created for Staging or Development (Setup → Keys) are tagged with that key's environment instead |
|
|
189
189
|
| `SCOPE_CAPTURE_HTTP` | No | Capture outbound calls to other services (default: on; `false` turns it off) |
|
|
190
190
|
| `HTTPS_PROXY` / `NO_PROXY` / `SSL_CERT_FILE` | No | Honoured for the SDK's own connection to Scope (read from the environment only) |
|
|
191
191
|
|
|
@@ -130,7 +130,7 @@ scope-run --dry-run uvicorn main:app
|
|
|
130
130
|
| `SCOPE_API_KEY` | Yes | Your Scope Analytics API key |
|
|
131
131
|
| `SCOPE_ENDPOINT` | No | Custom API endpoint |
|
|
132
132
|
| `SCOPE_DEBUG` | No | Set to 'true' for debug logging |
|
|
133
|
-
| `SCOPE_ENVIRONMENT` | No | Environment
|
|
133
|
+
| `SCOPE_ENVIRONMENT` | No | Environment tag on every event (default: production). When it is unset or `production`, events sent with a key you created for Staging or Development (Setup → Keys) are tagged with that key's environment instead |
|
|
134
134
|
| `SCOPE_CAPTURE_HTTP` | No | Capture outbound calls to other services (default: on; `false` turns it off) |
|
|
135
135
|
| `HTTPS_PROXY` / `NO_PROXY` / `SSL_CERT_FILE` | No | Honoured for the SDK's own connection to Scope (read from the environment only) |
|
|
136
136
|
|
|
@@ -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:
|
|
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,
|
|
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
|
|
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
|
-
# (
|
|
197
|
-
# generic pointer rather than a guess.
|
|
200
|
+
# (the datacenter filter that never ran was removed from ingest on 2026-10-09); anything
|
|
201
|
+
# else gets the generic pointer rather than a guess.
|
|
198
202
|
if reason.endswith("_ip"):
|
|
199
203
|
where = (
|
|
200
204
|
"Events from this machine's IP are excluded for this project — see Setup → "
|
|
@@ -208,6 +212,37 @@ class ScopeAPIClient:
|
|
|
208
212
|
f"This is said once per reason."
|
|
209
213
|
)
|
|
210
214
|
|
|
215
|
+
def _note_refusals(self, response) -> None:
|
|
216
|
+
"""The ingest judges each event on its own: one it cannot keep is refused alone, and one
|
|
217
|
+
it keeps with a change (an id it cannot keep, half of a character) is stored without it
|
|
218
|
+
— and the 200 says so, under ``rejected`` and ``repaired``: each event's place in the
|
|
219
|
+
batch, the field and the reason. Until the ingest judged each event, one such event
|
|
220
|
+
refused the whole batch. Said once per (field, reason), like a discard."""
|
|
221
|
+
try:
|
|
222
|
+
body = response.json()
|
|
223
|
+
except Exception: # noqa: BLE001 - a non-JSON 200 is a stored batch as far as we know
|
|
224
|
+
return
|
|
225
|
+
if not isinstance(body, dict):
|
|
226
|
+
return
|
|
227
|
+
said = {
|
|
228
|
+
"rejected": "Scope refused an event ({why}); each event of a batch is judged on its own. "
|
|
229
|
+
"This is said once per reason.",
|
|
230
|
+
"repaired": "Scope stored an event with a change ({why}). This is said once per reason.",
|
|
231
|
+
}
|
|
232
|
+
for kind, text in said.items():
|
|
233
|
+
problems = body.get(kind)
|
|
234
|
+
for problem in problems if isinstance(problems, list) else []:
|
|
235
|
+
if not isinstance(problem, dict):
|
|
236
|
+
continue
|
|
237
|
+
field, reason = problem.get("field"), problem.get("reason")
|
|
238
|
+
# a reply not shaped as ours is not ours to say (nor to fail the shipped batch over)
|
|
239
|
+
if not isinstance(reason, str) or not (field is None or isinstance(field, str)):
|
|
240
|
+
continue
|
|
241
|
+
if (kind, field, reason) in self._warned_refusals:
|
|
242
|
+
continue
|
|
243
|
+
self._warned_refusals.add((kind, field, reason))
|
|
244
|
+
self.config.warn(text.format(why=f"{field}: {reason}" if field else reason))
|
|
245
|
+
|
|
211
246
|
def test_connection(self) -> bool:
|
|
212
247
|
"""
|
|
213
248
|
Test connection to API
|
|
@@ -6,7 +6,9 @@ Handles session ID propagation across async boundaries
|
|
|
6
6
|
import contextvars
|
|
7
7
|
import functools
|
|
8
8
|
import inspect
|
|
9
|
-
|
|
9
|
+
import logging
|
|
10
|
+
import numbers
|
|
11
|
+
from typing import Any, Optional
|
|
10
12
|
from contextlib import contextmanager
|
|
11
13
|
import uuid
|
|
12
14
|
|
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if user_id
|
|
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
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
|
|
233
|
-
|
|
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,
|
|
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
|
-
|
|
41
|
-
"
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
87
|
-
"
|
|
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.
|
|
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
|
|
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
|
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics/patches/google_genai_patch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scope_analytics-0.1.5 → scope_analytics-0.1.6}/scope_analytics.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|