scope-analytics 0.1.4__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.4/scope_analytics.egg-info → scope_analytics-0.1.6}/PKG-INFO +17 -6
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/README.md +14 -5
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/__init__.py +117 -8
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/cli.py +59 -2
- scope_analytics-0.1.6/scope_analytics/client.py +276 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/config.py +5 -1
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/context.py +79 -4
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/events.py +71 -2
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/middleware.py +100 -30
- scope_analytics-0.1.6/scope_analytics/patches/_raw_response.py +137 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/anthropic_patch.py +18 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/openai_patch.py +18 -24
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/queue.py +89 -14
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/supported_versions.py +43 -11
- {scope_analytics-0.1.4 → scope_analytics-0.1.6/scope_analytics.egg-info}/PKG-INFO +17 -6
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics.egg-info/SOURCES.txt +8 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics.egg-info/requires.txt +2 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/setup.py +5 -1
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_capture_contract.py +376 -0
- scope_analytics-0.1.6/tests/test_cli_exec.py +250 -0
- scope_analytics-0.1.6/tests/test_client_ip.py +96 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_coverage_honesty.py +6 -0
- scope_analytics-0.1.6/tests/test_discard_reply.py +185 -0
- scope_analytics-0.1.6/tests/test_fork_safety.py +582 -0
- scope_analytics-0.1.6/tests/test_frameworks_langchain.py +215 -0
- {scope_analytics-0.1.4 → 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.6/tests/test_manual_event_and_debug_output.py +43 -0
- scope_analytics-0.1.6/tests/test_query_scrub.py +122 -0
- scope_analytics-0.1.4/scope_analytics/client.py +0 -155
- scope_analytics-0.1.4/tests/test_cli_exec.py +0 -130
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/LICENSE +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/MANIFEST.in +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/auto.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/deployment.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/__init__.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/_capture.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/_streaming.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/gemini_patch.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/google_genai_patch.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/http_patch.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics.egg-info/dependency_links.txt +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics.egg-info/entry_points.txt +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics.egg-info/top_level.txt +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/setup.cfg +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_capture_contract_google.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_deployment.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_http_patch.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_identify.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_patch_versions.py +0 -0
- {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_redaction.py +0 -0
- {scope_analytics-0.1.4 → 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
|
|
@@ -27,6 +27,8 @@ Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
|
|
|
27
27
|
Requires-Dist: requests>=2.25.0; extra == "dev"
|
|
28
28
|
Requires-Dist: aiohttp>=3.8.0; extra == "dev"
|
|
29
29
|
Requires-Dist: httpx2>=2.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: gunicorn>=20.0; extra == "dev"
|
|
31
|
+
Requires-Dist: flask>=2.0; extra == "dev"
|
|
30
32
|
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
31
33
|
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
|
32
34
|
Provides-Extra: openai
|
|
@@ -91,15 +93,24 @@ export SCOPE_API_KEY="sk_live_..."
|
|
|
91
93
|
# Instead of: uvicorn main:app --reload
|
|
92
94
|
# Run: scope-run uvicorn main:app --reload
|
|
93
95
|
|
|
94
|
-
# Instead of: gunicorn app:app -w 4
|
|
96
|
+
# Instead of: gunicorn app:app -w 4 (Flask, Django)
|
|
95
97
|
# Run: scope-run gunicorn app:app -w 4
|
|
96
98
|
|
|
99
|
+
# Instead of: gunicorn main:app -w 4 -k uvicorn_worker.UvicornWorker (FastAPI; pip install uvicorn-worker)
|
|
100
|
+
# Run: scope-run gunicorn main:app -w 4 -k uvicorn_worker.UvicornWorker
|
|
101
|
+
|
|
97
102
|
# Instead of: flask run --port 5000
|
|
98
103
|
# Run: scope-run flask run --port 5000
|
|
99
104
|
```
|
|
100
105
|
|
|
101
106
|
That's it! Your LLM calls are now automatically tracked AND correlated
|
|
102
|
-
with frontend sessions (for FastAPI, Flask, Django).
|
|
107
|
+
with frontend sessions (for FastAPI, Flask, Django). Workers forked by gunicorn or
|
|
108
|
+
Celery ship on their own timer (0.1.5). LangChain / LangGraph, LlamaIndex, PydanticAI,
|
|
109
|
+
Instructor and the OpenAI Agents SDK are captured through the SDK they call
|
|
110
|
+
(LangChain needs 0.1.5).
|
|
111
|
+
|
|
112
|
+
**Serverless / notebooks** — no start command to prefix: create `scope = ScopeAnalytics()`
|
|
113
|
+
at module import and call `scope.flush(timeout=2.0)` before the handler returns (0.1.5).
|
|
103
114
|
|
|
104
115
|
### Option B: Code-Based Installation
|
|
105
116
|
|
|
@@ -174,8 +185,9 @@ scope-run --dry-run uvicorn main:app
|
|
|
174
185
|
| `SCOPE_API_KEY` | Yes | Your Scope Analytics API key |
|
|
175
186
|
| `SCOPE_ENDPOINT` | No | Custom API endpoint |
|
|
176
187
|
| `SCOPE_DEBUG` | No | Set to 'true' for debug logging |
|
|
177
|
-
| `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 |
|
|
178
189
|
| `SCOPE_CAPTURE_HTTP` | No | Capture outbound calls to other services (default: on; `false` turns it off) |
|
|
190
|
+
| `HTTPS_PROXY` / `NO_PROXY` / `SSL_CERT_FILE` | No | Honoured for the SDK's own connection to Scope (read from the environment only) |
|
|
179
191
|
|
|
180
192
|
## Configuration (Code-Based)
|
|
181
193
|
|
|
@@ -230,8 +242,7 @@ def my_request_handler(request):
|
|
|
230
242
|
Add the frontend SDK to link user interactions with backend LLM calls:
|
|
231
243
|
|
|
232
244
|
```html
|
|
233
|
-
<script src="https://cdn.scopeai.dev/v1/sdk.js"
|
|
234
|
-
data-api-key="pk_live_your_public_key"></script>
|
|
245
|
+
<script src="https://cdn.scopeai.dev/v1/sdk.js?token=pk_live_your_public_key" async></script>
|
|
235
246
|
```
|
|
236
247
|
|
|
237
248
|
The frontend SDK automatically:
|
|
@@ -38,15 +38,24 @@ export SCOPE_API_KEY="sk_live_..."
|
|
|
38
38
|
# Instead of: uvicorn main:app --reload
|
|
39
39
|
# Run: scope-run uvicorn main:app --reload
|
|
40
40
|
|
|
41
|
-
# Instead of: gunicorn app:app -w 4
|
|
41
|
+
# Instead of: gunicorn app:app -w 4 (Flask, Django)
|
|
42
42
|
# Run: scope-run gunicorn app:app -w 4
|
|
43
43
|
|
|
44
|
+
# Instead of: gunicorn main:app -w 4 -k uvicorn_worker.UvicornWorker (FastAPI; pip install uvicorn-worker)
|
|
45
|
+
# Run: scope-run gunicorn main:app -w 4 -k uvicorn_worker.UvicornWorker
|
|
46
|
+
|
|
44
47
|
# Instead of: flask run --port 5000
|
|
45
48
|
# Run: scope-run flask run --port 5000
|
|
46
49
|
```
|
|
47
50
|
|
|
48
51
|
That's it! Your LLM calls are now automatically tracked AND correlated
|
|
49
|
-
with frontend sessions (for FastAPI, Flask, Django).
|
|
52
|
+
with frontend sessions (for FastAPI, Flask, Django). Workers forked by gunicorn or
|
|
53
|
+
Celery ship on their own timer (0.1.5). LangChain / LangGraph, LlamaIndex, PydanticAI,
|
|
54
|
+
Instructor and the OpenAI Agents SDK are captured through the SDK they call
|
|
55
|
+
(LangChain needs 0.1.5).
|
|
56
|
+
|
|
57
|
+
**Serverless / notebooks** — no start command to prefix: create `scope = ScopeAnalytics()`
|
|
58
|
+
at module import and call `scope.flush(timeout=2.0)` before the handler returns (0.1.5).
|
|
50
59
|
|
|
51
60
|
### Option B: Code-Based Installation
|
|
52
61
|
|
|
@@ -121,8 +130,9 @@ scope-run --dry-run uvicorn main:app
|
|
|
121
130
|
| `SCOPE_API_KEY` | Yes | Your Scope Analytics API key |
|
|
122
131
|
| `SCOPE_ENDPOINT` | No | Custom API endpoint |
|
|
123
132
|
| `SCOPE_DEBUG` | No | Set to 'true' for debug logging |
|
|
124
|
-
| `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 |
|
|
125
134
|
| `SCOPE_CAPTURE_HTTP` | No | Capture outbound calls to other services (default: on; `false` turns it off) |
|
|
135
|
+
| `HTTPS_PROXY` / `NO_PROXY` / `SSL_CERT_FILE` | No | Honoured for the SDK's own connection to Scope (read from the environment only) |
|
|
126
136
|
|
|
127
137
|
## Configuration (Code-Based)
|
|
128
138
|
|
|
@@ -177,8 +187,7 @@ def my_request_handler(request):
|
|
|
177
187
|
Add the frontend SDK to link user interactions with backend LLM calls:
|
|
178
188
|
|
|
179
189
|
```html
|
|
180
|
-
<script src="https://cdn.scopeai.dev/v1/sdk.js"
|
|
181
|
-
data-api-key="pk_live_your_public_key"></script>
|
|
190
|
+
<script src="https://cdn.scopeai.dev/v1/sdk.js?token=pk_live_your_public_key" async></script>
|
|
182
191
|
```
|
|
183
192
|
|
|
184
193
|
The frontend SDK automatically:
|
|
@@ -5,10 +5,11 @@ AI-powered analytics with automatic LLM conversation tracking
|
|
|
5
5
|
|
|
6
6
|
import atexit
|
|
7
7
|
import logging
|
|
8
|
-
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any, Optional
|
|
9
10
|
|
|
10
11
|
from .config import ScopeConfig, DEFAULT_REDACT_PATTERNS
|
|
11
|
-
from .context import ScopeContext
|
|
12
|
+
from .context import ScopeContext, accepted_user_id
|
|
12
13
|
from .deployment import DeploymentContext
|
|
13
14
|
from .events import EventFormatter
|
|
14
15
|
from .queue import EventQueue
|
|
@@ -45,7 +46,7 @@ def _resolve_version() -> str:
|
|
|
45
46
|
|
|
46
47
|
__version__ = _resolve_version()
|
|
47
48
|
__all__ = [
|
|
48
|
-
"ScopeAnalytics",
|
|
49
|
+
"ScopeAnalytics", # .flush(timeout=) ships queued events now (serverless, notebooks)
|
|
49
50
|
"ScopeContext",
|
|
50
51
|
"ScopeConfig",
|
|
51
52
|
"DEFAULT_REDACT_PATTERNS", # default credential-scrub patterns (on by default; [] to opt out)
|
|
@@ -184,6 +185,7 @@ class ScopeAnalytics:
|
|
|
184
185
|
flush_callback=self._flush_events,
|
|
185
186
|
config=self.config,
|
|
186
187
|
event_enricher=self.deployment.stamp,
|
|
188
|
+
on_fork=self._after_fork,
|
|
187
189
|
)
|
|
188
190
|
|
|
189
191
|
# Initialize patchers
|
|
@@ -200,10 +202,16 @@ class ScopeAnalytics:
|
|
|
200
202
|
# in it would claim LLM coverage this SDK does not have.
|
|
201
203
|
self.outbound_http_libraries = []
|
|
202
204
|
self._llm_sdks_detected = [] # Recognized SDKs whose import succeeded (≠ patched)
|
|
205
|
+
self._celery_hooked = False
|
|
203
206
|
|
|
204
207
|
# Start queue background thread
|
|
205
208
|
self.queue.start()
|
|
206
209
|
|
|
210
|
+
# A Celery worker already loaded? Then the prefork children's shutdown signal
|
|
211
|
+
# flushes their last batch (see _connect_celery_shutdown). Workers started through
|
|
212
|
+
# scope-run load Celery AFTER this point; the fork hook connects it in each child.
|
|
213
|
+
self._connect_celery_shutdown()
|
|
214
|
+
|
|
207
215
|
# Register shutdown hook
|
|
208
216
|
atexit.register(self.shutdown)
|
|
209
217
|
|
|
@@ -380,6 +388,87 @@ class ScopeAnalytics:
|
|
|
380
388
|
# Coverage reporting must never break startup.
|
|
381
389
|
self.config.log(f"Coverage-gap check failed: {e}")
|
|
382
390
|
|
|
391
|
+
def _after_fork(self):
|
|
392
|
+
"""Runs in a forked CHILD once the queue has restarted its flush thread there.
|
|
393
|
+
The inherited ship client shares its pooled sockets with the parent — two
|
|
394
|
+
processes writing one TCP connection interleave bytes — so the child gets its
|
|
395
|
+
own. The background session id is per process, so the child mints its own. And
|
|
396
|
+
a Celery child flushes on its own shutdown signal (see _connect_celery_shutdown)."""
|
|
397
|
+
try:
|
|
398
|
+
# The inherited client is dropped, never closed: httpx's pool close() takes
|
|
399
|
+
# the pool's thread lock, and if the parent's flush thread held it at the
|
|
400
|
+
# instant of the fork the child would block forever inside this hook (the
|
|
401
|
+
# thread that holds it does not exist here). Unreferenced, the sockets close
|
|
402
|
+
# by garbage collection, which takes no pool lock.
|
|
403
|
+
self.client = ScopeAPIClient(self.config)
|
|
404
|
+
except Exception as e: # noqa: BLE001 - never let a fork hook break the child
|
|
405
|
+
self.config.log(f"Could not rebuild the ship client after fork: {e}")
|
|
406
|
+
ScopeContext.reset_background_session()
|
|
407
|
+
# A receiver connected in the parent is inherited and works here (it closes over
|
|
408
|
+
# this same instance), so connect only if nothing was connected before the fork
|
|
409
|
+
# — e.g. the scope-run path, where Celery was not yet imported at init.
|
|
410
|
+
self._connect_celery_shutdown()
|
|
411
|
+
|
|
412
|
+
def _connect_celery_shutdown(self):
|
|
413
|
+
"""Celery's prefork children exit through os._exit(), which skips atexit — so
|
|
414
|
+
their last partial batch (under batch_size, under batch_timeout) would be lost.
|
|
415
|
+
When Celery is loaded (checked in sys.modules; never imported here to find out),
|
|
416
|
+
the child's ``worker_process_shutdown`` signal flushes with a hard two-second
|
|
417
|
+
limit, so a broken network can never hold a worker's shutdown."""
|
|
418
|
+
if self._celery_hooked or "celery" not in sys.modules:
|
|
419
|
+
return
|
|
420
|
+
try:
|
|
421
|
+
from celery import signals
|
|
422
|
+
except Exception: # noqa: BLE001 - a half-imported celery is not ours to break
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
def _on_worker_process_shutdown(**_kwargs):
|
|
426
|
+
self.flush(timeout=2.0)
|
|
427
|
+
|
|
428
|
+
# Celery holds signal receivers weakly by default; keep a strong reference.
|
|
429
|
+
self._celery_shutdown_handler = _on_worker_process_shutdown
|
|
430
|
+
try:
|
|
431
|
+
signals.worker_process_shutdown.connect(_on_worker_process_shutdown, weak=False)
|
|
432
|
+
self._celery_hooked = True
|
|
433
|
+
self.config.log("Celery worker_process_shutdown hook connected")
|
|
434
|
+
except Exception as e: # noqa: BLE001
|
|
435
|
+
self.config.log(f"Could not connect the Celery shutdown hook: {e}")
|
|
436
|
+
|
|
437
|
+
def flush(self, timeout: Optional[float] = None):
|
|
438
|
+
"""
|
|
439
|
+
Ship every queued event now.
|
|
440
|
+
|
|
441
|
+
For the places the background timer can't be trusted to fire: a serverless
|
|
442
|
+
handler about to be frozen, a notebook cell, a worker process about to exit.
|
|
443
|
+
Without ``timeout`` the events are shipped on the calling thread and the call
|
|
444
|
+
returns when they are delivered (the client's normal request timeout applies).
|
|
445
|
+
With ``timeout`` the call returns within that many seconds whatever the network
|
|
446
|
+
does: the delivery runs on a helper thread with that limit per request and the
|
|
447
|
+
caller waits for it only until the deadline, so a slow batch still lands if the
|
|
448
|
+
process lives on, and a stuck one never holds the caller past the budget.
|
|
449
|
+
Batches already in flight on the batch-size path are waited for within the same
|
|
450
|
+
budget. Capture stays live afterwards — unlike shutdown(), which also unpatches.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
timeout: Optional hard limit, in seconds, on how long this call may take.
|
|
454
|
+
"""
|
|
455
|
+
if not self.enabled:
|
|
456
|
+
return
|
|
457
|
+
if timeout is None:
|
|
458
|
+
self.queue.flush(synchronous=True)
|
|
459
|
+
self.queue.wait_inflight(self.client.ship_timeout or 30.0)
|
|
460
|
+
return
|
|
461
|
+
import threading
|
|
462
|
+
import time
|
|
463
|
+
deadline = time.monotonic() + timeout
|
|
464
|
+
worker = threading.Thread(
|
|
465
|
+
target=self.queue.flush, kwargs={"synchronous": True, "timeout": timeout},
|
|
466
|
+
daemon=True, name="ScopeAnalytics-Flush",
|
|
467
|
+
)
|
|
468
|
+
worker.start()
|
|
469
|
+
worker.join(max(0.0, deadline - time.monotonic()))
|
|
470
|
+
self.queue.wait_inflight(max(0.0, deadline - time.monotonic()))
|
|
471
|
+
|
|
383
472
|
def track_event(self, event_type: str, properties: dict):
|
|
384
473
|
"""
|
|
385
474
|
Manually track an event
|
|
@@ -409,6 +498,17 @@ class ScopeAnalytics:
|
|
|
409
498
|
# libraries — was silently dropped at validation.
|
|
410
499
|
from datetime import datetime, timezone
|
|
411
500
|
event.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
|
|
501
|
+
# The two stamps every auto-captured event carries, so a manual event is the same
|
|
502
|
+
# to the SDK-status and environment views (a manual-only install used to show no
|
|
503
|
+
# version at all).
|
|
504
|
+
event.setdefault("sdk_version", self.config.sdk_version)
|
|
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)
|
|
412
512
|
if "session_id" not in event:
|
|
413
513
|
# generate_temp (NOT ensure_session_id): ensure_ would PIN the temp id
|
|
414
514
|
# into the contextvar as a side effect — in a long-lived worker with no
|
|
@@ -428,7 +528,7 @@ class ScopeAnalytics:
|
|
|
428
528
|
f"Manual event '{event_type}' failed validation and was dropped."
|
|
429
529
|
)
|
|
430
530
|
|
|
431
|
-
def identify(self, user_id:
|
|
531
|
+
def identify(self, user_id: Any, traits: Optional[dict] = None):
|
|
432
532
|
"""
|
|
433
533
|
Identify a user and stitch their prior anonymous activity to them.
|
|
434
534
|
|
|
@@ -439,7 +539,9 @@ class ScopeAnalytics:
|
|
|
439
539
|
current request context so subsequent events are attributed to them.
|
|
440
540
|
|
|
441
541
|
Args:
|
|
442
|
-
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)
|
|
443
545
|
traits: Optional user traits (e.g., email, name, plan)
|
|
444
546
|
"""
|
|
445
547
|
# Disabled construction has no formatter/queue (and possibly no config) —
|
|
@@ -447,9 +549,12 @@ class ScopeAnalytics:
|
|
|
447
549
|
if not self.enabled:
|
|
448
550
|
return
|
|
449
551
|
|
|
450
|
-
if
|
|
552
|
+
if user_id is None:
|
|
451
553
|
self.config.log("identify() called without a user_id - ignoring")
|
|
452
554
|
return
|
|
555
|
+
user_id = accepted_user_id(user_id, "identify")
|
|
556
|
+
if user_id is None:
|
|
557
|
+
return
|
|
453
558
|
|
|
454
559
|
# Capture the prior (anonymous) identity BEFORE overwriting the context,
|
|
455
560
|
# so the stitcher can link pre-identification events to this user. Prefer
|
|
@@ -478,15 +583,19 @@ class ScopeAnalytics:
|
|
|
478
583
|
if traits:
|
|
479
584
|
self.config.log(f"User traits: {traits}")
|
|
480
585
|
|
|
481
|
-
def _flush_events(self, events: list):
|
|
586
|
+
def _flush_events(self, events: list, timeout: Optional[float] = None):
|
|
482
587
|
"""
|
|
483
588
|
Callback for flushing events to API
|
|
484
589
|
Called by event queue when batch is ready
|
|
485
590
|
|
|
486
591
|
Args:
|
|
487
592
|
events: List of events to flush
|
|
593
|
+
timeout: Per-request limit for this batch only (set by ``flush(timeout=)``)
|
|
488
594
|
"""
|
|
489
|
-
|
|
595
|
+
# The limit is passed on only when set, so a one-argument stand-in for ship_events
|
|
596
|
+
# (tests, custom sinks) keeps working.
|
|
597
|
+
success = (self.client.ship_events(events) if timeout is None
|
|
598
|
+
else self.client.ship_events(events, timeout=timeout))
|
|
490
599
|
|
|
491
600
|
if not success:
|
|
492
601
|
self.config.log(f"⚠️ Failed to ship {len(events)} events")
|
|
@@ -21,6 +21,7 @@ Environment Variables:
|
|
|
21
21
|
|
|
22
22
|
import os
|
|
23
23
|
import sys
|
|
24
|
+
import shutil
|
|
24
25
|
import subprocess
|
|
25
26
|
import argparse
|
|
26
27
|
import tempfile
|
|
@@ -88,8 +89,31 @@ import os
|
|
|
88
89
|
if os.environ.get('SCOPE_AUTO_INSTRUMENT') == 'true':
|
|
89
90
|
try:
|
|
90
91
|
import scope_analytics.auto
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
_launcher = os.environ.get('SCOPE_RUN_VERSION')
|
|
93
|
+
_running = getattr(scope_analytics, '__version__', None)
|
|
94
|
+
if _launcher and _running and _launcher != _running:
|
|
95
|
+
# The app's interpreter found a DIFFERENT copy of scope-analytics than the one
|
|
96
|
+
# scope-run was launched from (a global uvicorn next to an un-activated venv is
|
|
97
|
+
# the usual shape). That copy is what instruments the app; say so.
|
|
98
|
+
import sys
|
|
99
|
+
print(
|
|
100
|
+
"[Scope SDK] scope-run is scope-analytics " + _launcher + " but the interpreter "
|
|
101
|
+
"running your app (" + sys.executable + ") imported scope-analytics " + _running
|
|
102
|
+
+ " — that copy is what instruments the app. To run " + _launcher + " here: "
|
|
103
|
+
+ sys.executable + " -m pip install scope-analytics==" + _launcher,
|
|
104
|
+
file=sys.stderr,
|
|
105
|
+
)
|
|
106
|
+
except ImportError as e:
|
|
107
|
+
# The interpreter running the app is not the one scope-analytics was installed into
|
|
108
|
+
# (a pyenv shim, a system python, a multi-stage image). The user ran scope-run to be
|
|
109
|
+
# instrumented, so silence here would be the worst outcome: say it, once, loudly.
|
|
110
|
+
import sys
|
|
111
|
+
print(
|
|
112
|
+
"[Scope SDK] scope-analytics could not be imported by this interpreter ("
|
|
113
|
+
+ sys.executable + "): " + str(e) + " — NOTHING is instrumented. Install it for "
|
|
114
|
+
"this interpreter: " + sys.executable + " -m pip install scope-analytics",
|
|
115
|
+
file=sys.stderr,
|
|
116
|
+
)
|
|
93
117
|
except Exception as e:
|
|
94
118
|
import sys
|
|
95
119
|
print(f"[Scope SDK] Warning: Auto-instrumentation failed: {e}", file=sys.stderr)
|
|
@@ -187,6 +211,38 @@ Environment Variables:
|
|
|
187
211
|
env = os.environ.copy()
|
|
188
212
|
env['SCOPE_AUTO_INSTRUMENT'] = 'true'
|
|
189
213
|
|
|
214
|
+
# The command is resolved on PATH, so scope-run invoked from a virtualenv that is not
|
|
215
|
+
# activated (its absolute path in a Procfile, a Dockerfile, a systemd unit) used to
|
|
216
|
+
# launch whatever `uvicorn` / `gunicorn` came first on PATH — a different interpreter
|
|
217
|
+
# with a different scope-analytics, or none — and nothing said so. The launcher's own
|
|
218
|
+
# bin directory goes first, which is exactly what activating the virtualenv does; and
|
|
219
|
+
# the sitecustomize compares this version with the one the app's interpreter imports,
|
|
220
|
+
# saying so on stderr if they differ.
|
|
221
|
+
launcher_bin = os.path.dirname(os.path.abspath(sys.executable))
|
|
222
|
+
env['PATH'] = launcher_bin + os.pathsep + env.get('PATH', '')
|
|
223
|
+
version = _get_version()
|
|
224
|
+
if version != 'unknown' and not version.startswith('0.0.0'):
|
|
225
|
+
env['SCOPE_RUN_VERSION'] = version
|
|
226
|
+
|
|
227
|
+
# macOS: a Python process that runs a thread (the SDK's flush thread) and then forks
|
|
228
|
+
# workers — gunicorn, Celery's prefork pool, multiprocessing — trips the Objective-C
|
|
229
|
+
# runtime's fork-safety check in every child the first time it touches a system
|
|
230
|
+
# framework (the app's own HTTP client doing its proxy lookup is enough), and the child
|
|
231
|
+
# aborts: "+[NSCharacterSet initialize] may have been in progress in another thread when
|
|
232
|
+
# fork() was called". Measured with `scope-run gunicorn -w 2`: 0 of 10 runs healthy
|
|
233
|
+
# without this, 8 of 8 with it. This variable is the documented remedy for Python-and-fork
|
|
234
|
+
# on macOS; it means nothing anywhere else, so a Linux deployment is untouched. A value
|
|
235
|
+
# the user already set wins — and when scope-run sets it, it says so: nothing silent.
|
|
236
|
+
if sys.platform == 'darwin' and 'OBJC_DISABLE_INITIALIZE_FORK_SAFETY' not in env:
|
|
237
|
+
env['OBJC_DISABLE_INITIALIZE_FORK_SAFETY'] = 'YES'
|
|
238
|
+
print(
|
|
239
|
+
"[Scope SDK] macOS: set OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES for this command — "
|
|
240
|
+
"without it a server that forks workers (gunicorn, Celery) aborts in every child on "
|
|
241
|
+
"macOS's Objective-C fork-safety check. Set the variable yourself, to any value, to "
|
|
242
|
+
"choose otherwise.",
|
|
243
|
+
file=sys.stderr,
|
|
244
|
+
)
|
|
245
|
+
|
|
190
246
|
if args.debug:
|
|
191
247
|
env['SCOPE_DEBUG'] = 'true'
|
|
192
248
|
|
|
@@ -211,6 +267,7 @@ Environment Variables:
|
|
|
211
267
|
print("[Scope SDK] Dry run - would execute:")
|
|
212
268
|
print(f" SCOPE_AUTO_INSTRUMENT=true")
|
|
213
269
|
print(f" PYTHONPATH={env['PYTHONPATH']}")
|
|
270
|
+
print(f" {executable} resolves to: {shutil.which(executable, path=env['PATH']) or '(not found on PATH)'}")
|
|
214
271
|
if args.debug:
|
|
215
272
|
print(f" SCOPE_DEBUG=true")
|
|
216
273
|
print(f" {' '.join(final_command)}")
|