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.
Files changed (52) hide show
  1. {scope_analytics-0.1.4/scope_analytics.egg-info → scope_analytics-0.1.6}/PKG-INFO +17 -6
  2. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/README.md +14 -5
  3. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/__init__.py +117 -8
  4. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/cli.py +59 -2
  5. scope_analytics-0.1.6/scope_analytics/client.py +276 -0
  6. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/config.py +5 -1
  7. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/context.py +79 -4
  8. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/events.py +71 -2
  9. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/middleware.py +100 -30
  10. scope_analytics-0.1.6/scope_analytics/patches/_raw_response.py +137 -0
  11. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/anthropic_patch.py +18 -0
  12. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/openai_patch.py +18 -24
  13. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/queue.py +89 -14
  14. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/supported_versions.py +43 -11
  15. {scope_analytics-0.1.4 → scope_analytics-0.1.6/scope_analytics.egg-info}/PKG-INFO +17 -6
  16. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics.egg-info/SOURCES.txt +8 -0
  17. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics.egg-info/requires.txt +2 -0
  18. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/setup.py +5 -1
  19. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_capture_contract.py +376 -0
  20. scope_analytics-0.1.6/tests/test_cli_exec.py +250 -0
  21. scope_analytics-0.1.6/tests/test_client_ip.py +96 -0
  22. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_coverage_honesty.py +6 -0
  23. scope_analytics-0.1.6/tests/test_discard_reply.py +185 -0
  24. scope_analytics-0.1.6/tests/test_fork_safety.py +582 -0
  25. scope_analytics-0.1.6/tests/test_frameworks_langchain.py +215 -0
  26. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_identity_bridge.py +46 -0
  27. scope_analytics-0.1.6/tests/test_ids_0_1_6.py +390 -0
  28. scope_analytics-0.1.6/tests/test_manual_event_and_debug_output.py +43 -0
  29. scope_analytics-0.1.6/tests/test_query_scrub.py +122 -0
  30. scope_analytics-0.1.4/scope_analytics/client.py +0 -155
  31. scope_analytics-0.1.4/tests/test_cli_exec.py +0 -130
  32. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/LICENSE +0 -0
  33. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/MANIFEST.in +0 -0
  34. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/auto.py +0 -0
  35. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/deployment.py +0 -0
  36. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/__init__.py +0 -0
  37. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/_capture.py +0 -0
  38. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/_streaming.py +0 -0
  39. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/gemini_patch.py +0 -0
  40. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/google_genai_patch.py +0 -0
  41. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics/patches/http_patch.py +0 -0
  42. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics.egg-info/dependency_links.txt +0 -0
  43. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics.egg-info/entry_points.txt +0 -0
  44. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/scope_analytics.egg-info/top_level.txt +0 -0
  45. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/setup.cfg +0 -0
  46. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_capture_contract_google.py +0 -0
  47. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_deployment.py +0 -0
  48. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_http_patch.py +0 -0
  49. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_identify.py +0 -0
  50. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_patch_versions.py +0 -0
  51. {scope_analytics-0.1.4 → scope_analytics-0.1.6}/tests/test_redaction.py +0 -0
  52. {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.4
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 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 |
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 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 |
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
- from typing import Optional
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: str, traits: Optional[dict] = None):
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, 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)
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 not user_id:
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
- success = self.client.ship_events(events)
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
- except ImportError:
92
- pass # scope-analytics not installed, skip silently
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)}")