scope-analytics 0.1.1__tar.gz → 0.1.3__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 (49) hide show
  1. {scope_analytics-0.1.1/scope_analytics.egg-info → scope_analytics-0.1.3}/PKG-INFO +13 -2
  2. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/README.md +4 -0
  3. scope_analytics-0.1.3/scope_analytics/__init__.py +547 -0
  4. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics/auto.py +7 -0
  5. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics/cli.py +18 -1
  6. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics/client.py +41 -7
  7. scope_analytics-0.1.3/scope_analytics/config.py +162 -0
  8. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics/context.py +127 -0
  9. scope_analytics-0.1.3/scope_analytics/deployment.py +193 -0
  10. scope_analytics-0.1.3/scope_analytics/events.py +598 -0
  11. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics/middleware.py +40 -11
  12. scope_analytics-0.1.3/scope_analytics/patches/__init__.py +11 -0
  13. scope_analytics-0.1.3/scope_analytics/patches/_capture.py +815 -0
  14. scope_analytics-0.1.3/scope_analytics/patches/_streaming.py +221 -0
  15. scope_analytics-0.1.3/scope_analytics/patches/anthropic_patch.py +462 -0
  16. scope_analytics-0.1.3/scope_analytics/patches/gemini_patch.py +364 -0
  17. scope_analytics-0.1.3/scope_analytics/patches/google_genai_patch.py +220 -0
  18. scope_analytics-0.1.3/scope_analytics/patches/http_patch.py +612 -0
  19. scope_analytics-0.1.3/scope_analytics/patches/openai_patch.py +494 -0
  20. scope_analytics-0.1.3/scope_analytics/queue.py +245 -0
  21. scope_analytics-0.1.3/scope_analytics/supported_versions.py +106 -0
  22. {scope_analytics-0.1.1 → scope_analytics-0.1.3/scope_analytics.egg-info}/PKG-INFO +13 -2
  23. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics.egg-info/SOURCES.txt +17 -1
  24. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics.egg-info/requires.txt +9 -0
  25. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/setup.py +12 -2
  26. scope_analytics-0.1.3/tests/test_capture_contract.py +2208 -0
  27. scope_analytics-0.1.3/tests/test_capture_contract_google.py +769 -0
  28. scope_analytics-0.1.3/tests/test_cli_exec.py +130 -0
  29. scope_analytics-0.1.3/tests/test_coverage_honesty.py +108 -0
  30. scope_analytics-0.1.3/tests/test_deployment.py +208 -0
  31. scope_analytics-0.1.3/tests/test_http_patch.py +822 -0
  32. scope_analytics-0.1.3/tests/test_identify.py +198 -0
  33. scope_analytics-0.1.3/tests/test_identity_bridge.py +94 -0
  34. scope_analytics-0.1.3/tests/test_patch_versions.py +634 -0
  35. scope_analytics-0.1.3/tests/test_redaction.py +56 -0
  36. scope_analytics-0.1.1/scope_analytics/__init__.py +0 -244
  37. scope_analytics-0.1.1/scope_analytics/config.py +0 -101
  38. scope_analytics-0.1.1/scope_analytics/events.py +0 -288
  39. scope_analytics-0.1.1/scope_analytics/patches/__init__.py +0 -9
  40. scope_analytics-0.1.1/scope_analytics/patches/anthropic_patch.py +0 -430
  41. scope_analytics-0.1.1/scope_analytics/patches/gemini_patch.py +0 -422
  42. scope_analytics-0.1.1/scope_analytics/patches/openai_patch.py +0 -483
  43. scope_analytics-0.1.1/scope_analytics/queue.py +0 -158
  44. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/LICENSE +0 -0
  45. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/MANIFEST.in +0 -0
  46. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics.egg-info/dependency_links.txt +0 -0
  47. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics.egg-info/entry_points.txt +0 -0
  48. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics.egg-info/top_level.txt +0 -0
  49. {scope_analytics-0.1.1 → scope_analytics-0.1.3}/setup.cfg +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scope-analytics
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: AI-powered analytics SDK for backend applications with automatic LLM tracking
5
- Home-page: https://github.com/scopeai/scope-analytics-python
5
+ Home-page: https://scopeai.dev
6
6
  Author: Scope AI
7
7
  Author-email: support@scopeai.dev
8
8
  Classifier: Development Status :: 3 - Alpha
@@ -24,12 +24,19 @@ Provides-Extra: dev
24
24
  Requires-Dist: pytest>=7.0.0; extra == "dev"
25
25
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
26
26
  Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
27
+ Requires-Dist: requests>=2.25.0; extra == "dev"
28
+ Requires-Dist: aiohttp>=3.8.0; extra == "dev"
29
+ Requires-Dist: httpx2>=2.0.0; extra == "dev"
27
30
  Requires-Dist: black>=23.0.0; extra == "dev"
28
31
  Requires-Dist: flake8>=6.0.0; extra == "dev"
29
32
  Provides-Extra: openai
30
33
  Requires-Dist: openai>=1.0.0; extra == "openai"
31
34
  Provides-Extra: anthropic
32
35
  Requires-Dist: anthropic>=0.18.0; extra == "anthropic"
36
+ Provides-Extra: gemini
37
+ Requires-Dist: google-generativeai>=0.3.0; extra == "gemini"
38
+ Provides-Extra: google-genai
39
+ Requires-Dist: google-genai>=1.0.0; extra == "google-genai"
33
40
  Provides-Extra: langchain
34
41
  Requires-Dist: langchain>=0.1.0; extra == "langchain"
35
42
  Dynamic: author
@@ -51,6 +58,7 @@ AI-powered analytics for backend applications with **zero-code LLM conversation
51
58
  ## Features
52
59
 
53
60
  - **Automatic LLM Tracking**: Captures OpenAI, Anthropic, and Gemini calls automatically
61
+ - **Outbound Call Tracking**: Records the calls your app makes to other services (httpx, httpx2, requests, aiohttp, urllib) — destination, status, latency and failures. Never the request or response bodies.
54
62
  - **Session Correlation**: Links backend events to frontend user sessions via `X-Scope-Session-ID` header
55
63
  - **Conversation Intelligence**: Classifies LLM calls as user-facing vs background jobs
56
64
  - **Zero Code Changes**: Drop-in integration with automatic monkey-patching
@@ -167,6 +175,7 @@ scope-run --dry-run uvicorn main:app
167
175
  | `SCOPE_ENDPOINT` | No | Custom API endpoint |
168
176
  | `SCOPE_DEBUG` | No | Set to 'true' for debug logging |
169
177
  | `SCOPE_ENVIRONMENT` | No | Environment name (default: production) |
178
+ | `SCOPE_CAPTURE_HTTP` | No | Capture outbound calls to other services (default: on; `false` turns it off) |
170
179
 
171
180
  ## Configuration (Code-Based)
172
181
 
@@ -177,6 +186,8 @@ scope = ScopeAnalytics(
177
186
  auto_patch=True, # Optional: Auto-patch LLM libraries (default: True)
178
187
  batch_size=10, # Optional: Events per batch (default: 10)
179
188
  batch_timeout_seconds=5, # Optional: Max wait time (default: 5)
189
+ capture_http=True, # Optional: Capture outbound calls (default: True,
190
+ # independent of auto_patch)
180
191
  debug=False, # Optional: Enable debug logging
181
192
  environment="production", # Optional: Environment name
182
193
  )
@@ -5,6 +5,7 @@ AI-powered analytics for backend applications with **zero-code LLM conversation
5
5
  ## Features
6
6
 
7
7
  - **Automatic LLM Tracking**: Captures OpenAI, Anthropic, and Gemini calls automatically
8
+ - **Outbound Call Tracking**: Records the calls your app makes to other services (httpx, httpx2, requests, aiohttp, urllib) — destination, status, latency and failures. Never the request or response bodies.
8
9
  - **Session Correlation**: Links backend events to frontend user sessions via `X-Scope-Session-ID` header
9
10
  - **Conversation Intelligence**: Classifies LLM calls as user-facing vs background jobs
10
11
  - **Zero Code Changes**: Drop-in integration with automatic monkey-patching
@@ -121,6 +122,7 @@ scope-run --dry-run uvicorn main:app
121
122
  | `SCOPE_ENDPOINT` | No | Custom API endpoint |
122
123
  | `SCOPE_DEBUG` | No | Set to 'true' for debug logging |
123
124
  | `SCOPE_ENVIRONMENT` | No | Environment name (default: production) |
125
+ | `SCOPE_CAPTURE_HTTP` | No | Capture outbound calls to other services (default: on; `false` turns it off) |
124
126
 
125
127
  ## Configuration (Code-Based)
126
128
 
@@ -131,6 +133,8 @@ scope = ScopeAnalytics(
131
133
  auto_patch=True, # Optional: Auto-patch LLM libraries (default: True)
132
134
  batch_size=10, # Optional: Events per batch (default: 10)
133
135
  batch_timeout_seconds=5, # Optional: Max wait time (default: 5)
136
+ capture_http=True, # Optional: Capture outbound calls (default: True,
137
+ # independent of auto_patch)
134
138
  debug=False, # Optional: Enable debug logging
135
139
  environment="production", # Optional: Environment name
136
140
  )
@@ -0,0 +1,547 @@
1
+ """
2
+ Scope Analytics - Backend SDK
3
+ AI-powered analytics with automatic LLM conversation tracking
4
+ """
5
+
6
+ import atexit
7
+ import logging
8
+ from typing import Optional
9
+
10
+ from .config import ScopeConfig, DEFAULT_REDACT_PATTERNS
11
+ from .context import ScopeContext
12
+ from .deployment import DeploymentContext
13
+ from .events import EventFormatter
14
+ from .queue import EventQueue
15
+ from .client import ScopeAPIClient
16
+ from .patches.openai_patch import OpenAIPatcher
17
+ from .patches.anthropic_patch import AnthropicPatcher
18
+ from .patches.gemini_patch import GeminiPatcher
19
+ from .patches.google_genai_patch import GoogleGenaiPatcher
20
+ from .patches.http_patch import HTTPPatcher
21
+ from .middleware import (
22
+ ScopeSessionMiddleware,
23
+ FlaskScopeMiddleware,
24
+ init_flask_session_tracking,
25
+ DjangoScopeMiddleware,
26
+ )
27
+
28
+ def _resolve_version() -> str:
29
+ """Single source of truth: the installed distribution's own metadata.
30
+
31
+ Hardcoding this drifted once already — 0.1.1 shipped stamping every event as
32
+ "0.1.0", which made it impossible to tell from our own ingested data which SDK a
33
+ customer was running (exactly what you need to triage an SDK bug). Deriving it
34
+ means setup.py is the only place a version lives.
35
+ """
36
+ try:
37
+ from importlib.metadata import version, PackageNotFoundError
38
+ try:
39
+ return version("scope-analytics")
40
+ except PackageNotFoundError:
41
+ return "0.0.0+unknown" # running from a source tree, not installed
42
+ except Exception: # noqa: BLE001 - never let version lookup break an import
43
+ return "0.0.0+unknown"
44
+
45
+
46
+ __version__ = _resolve_version()
47
+ __all__ = [
48
+ "ScopeAnalytics",
49
+ "ScopeContext",
50
+ "ScopeConfig",
51
+ "DEFAULT_REDACT_PATTERNS", # default credential-scrub patterns (on by default; [] to opt out)
52
+ "get_sdk_instance",
53
+ # Middleware exports
54
+ "ScopeSessionMiddleware", # For FastAPI/Starlette
55
+ "FlaskScopeMiddleware", # For Flask (WSGI wrapper)
56
+ "init_flask_session_tracking", # For Flask (hooks approach)
57
+ "DjangoScopeMiddleware", # For Django
58
+ ]
59
+
60
+ # Global SDK instance reference for middleware access
61
+ # This allows middleware to capture HTTP request events without requiring
62
+ # explicit SDK reference in user code
63
+ _global_sdk_instance: Optional["ScopeAnalytics"] = None
64
+
65
+
66
+ def get_sdk_instance() -> Optional["ScopeAnalytics"]:
67
+ """
68
+ Get the global SDK instance.
69
+
70
+ Returns:
71
+ ScopeAnalytics instance if initialized, None otherwise
72
+ """
73
+ return _global_sdk_instance
74
+
75
+
76
+ def _set_sdk_instance(instance: "ScopeAnalytics") -> None:
77
+ """
78
+ Set the global SDK instance.
79
+ Called internally when ScopeAnalytics is initialized.
80
+ """
81
+ global _global_sdk_instance
82
+ _global_sdk_instance = instance
83
+
84
+
85
+ class ScopeAnalytics:
86
+ """
87
+ Main SDK class for Scope Analytics
88
+
89
+ Usage:
90
+ scope = ScopeAnalytics(api_key="sk_live_...")
91
+
92
+ # SDK automatically patches OpenAI, Anthropic, LangChain
93
+ # All LLM calls are captured and sent to Scope AI
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ api_key: Optional[str] = None,
99
+ endpoint: Optional[str] = None,
100
+ auto_patch: bool = True,
101
+ batch_size: int = 10,
102
+ batch_timeout_seconds: int = 5,
103
+ max_queue_size: int = 1000,
104
+ debug: bool = False,
105
+ environment: Optional[str] = None,
106
+ capture_http: Optional[bool] = None,
107
+ ):
108
+ """
109
+ Initialize Scope Analytics SDK
110
+
111
+ A missing or malformed API key does NOT raise: the SDK emits one
112
+ always-visible warning and constructs itself disabled — no patching, no
113
+ event capture, every public method a no-op — so a config typo can never
114
+ take the host application down (PRODUCT.md §2, Robustness: analytics must never break
115
+ the host app; fail loudly, not silently).
116
+
117
+ Args:
118
+ api_key: Secret API key (sk_live_... or sk_test_...)
119
+ endpoint: API endpoint URL (default: https://api.scopeai.dev)
120
+ auto_patch: Automatically patch LLM libraries (default: True)
121
+ batch_size: Events per batch (default: 10)
122
+ batch_timeout_seconds: Max wait before sending partial batch (default: 5)
123
+ max_queue_size: Max events to queue (default: 1000)
124
+ debug: Enable debug logging (default: False)
125
+ environment: Environment name (default: production)
126
+ capture_http: Capture the outbound calls this app makes to other services
127
+ (default: on; SCOPE_CAPTURE_HTTP=false turns it off). Destination, status,
128
+ latency and failures only — never request or response bodies.
129
+ """
130
+ # Defined before anything can fail so every instance — including a
131
+ # disabled shell — carries it, and the no-op guards below never AttributeError.
132
+ self.enabled = True
133
+
134
+ # Initialize configuration. Only ValueError is caught: it is the specific,
135
+ # known config-validation failure (missing key, wrong prefix). Any other
136
+ # exception is an SDK bug and must still surface in development. The
137
+ # zero-code path (auto.py) already degrades this way; the manual path has
138
+ # to behave identically or a typo'd key crashes the app at startup.
139
+ try:
140
+ self.config = ScopeConfig(
141
+ api_key=api_key,
142
+ endpoint=endpoint,
143
+ auto_patch=auto_patch,
144
+ batch_size=batch_size,
145
+ batch_timeout_seconds=batch_timeout_seconds,
146
+ max_queue_size=max_queue_size,
147
+ debug=debug,
148
+ environment=environment,
149
+ capture_http=capture_http,
150
+ )
151
+ except ValueError as e:
152
+ # self.config doesn't exist yet, so go straight to the same
153
+ # always-visible logger channel ScopeConfig.warn() uses.
154
+ logging.getLogger("scope_analytics").warning(
155
+ "[Scope SDK] %s Scope Analytics is DISABLED — no events will be "
156
+ "captured and no LLM libraries will be patched.",
157
+ e,
158
+ )
159
+ self.enabled = False
160
+ # Heavy components (formatter, client, deployment, queue, patchers)
161
+ # stay unconstructed: no background thread, no atexit hook, and no
162
+ # global registration — a disabled instance must never become the
163
+ # middleware's SDK.
164
+ self.patches = []
165
+ self.uncovered_llm_libraries = []
166
+ self.outbound_http_libraries = []
167
+ self._warned_ship_failure = False
168
+ self._llm_sdks_detected = []
169
+ return
170
+
171
+ # Initialize components
172
+ self.event_formatter = EventFormatter(self.config)
173
+ self.client = ScopeAPIClient(self.config)
174
+
175
+ # Capture deployment + commit identity once at startup (PRODUCT.md §4.2).
176
+ # Its .stamp is wired as the queue enricher so git_sha/deployment_id land
177
+ # on every event through a single chokepoint.
178
+ self.deployment = DeploymentContext(self.config)
179
+
180
+ self.queue = EventQueue(
181
+ batch_size=self.config.batch_size,
182
+ batch_timeout_seconds=self.config.batch_timeout_seconds,
183
+ max_queue_size=self.config.max_queue_size,
184
+ flush_callback=self._flush_events,
185
+ config=self.config,
186
+ event_enricher=self.deployment.stamp,
187
+ )
188
+
189
+ # Initialize patchers
190
+ self.openai_patcher = OpenAIPatcher(self)
191
+ self.anthropic_patcher = AnthropicPatcher(self)
192
+ self.gemini_patcher = GeminiPatcher(self) # legacy google-generativeai (EOL)
193
+ self.google_genai_patcher = GoogleGenaiPatcher(self) # unified google-genai (modern)
194
+ self.http_patcher = HTTPPatcher(self) # outbound calls to other services
195
+ self._warned_ship_failure = False # the delivery-failure warning fires once
196
+ self.patches = [] # List of successfully applied LLM patches (LLM ONLY — see below)
197
+ self.uncovered_llm_libraries = [] # Known-uncovered LLM libs detected at startup
198
+ # Outbound HTTP coverage is tracked separately and deliberately NOT in self.patches:
199
+ # that list is reported to the backend as `patched_llm_libraries`, and an HTTP client
200
+ # in it would claim LLM coverage this SDK does not have.
201
+ self.outbound_http_libraries = []
202
+ self._llm_sdks_detected = [] # Recognized SDKs whose import succeeded (≠ patched)
203
+
204
+ # Start queue background thread
205
+ self.queue.start()
206
+
207
+ # Register shutdown hook
208
+ atexit.register(self.shutdown)
209
+
210
+ self.config.log("Scope Analytics SDK initialized")
211
+ self.config.log(f"Configuration: {self.config.to_dict()}")
212
+
213
+ # Register as global instance for middleware access
214
+ _set_sdk_instance(self)
215
+
216
+ # Auto-patch LLM libraries if enabled
217
+ if self.config.auto_patch:
218
+ self._apply_patches()
219
+
220
+ # Outbound capture runs on its own switch (see _apply_http_capture).
221
+ self._apply_http_capture()
222
+
223
+ # Emit a single deployment_detected event when this boot's commit differs
224
+ # from the previous boot's (PRODUCT.md §4.2). No-op when no SHA is known.
225
+ self._emit_deployment_detected()
226
+
227
+ def _emit_deployment_detected(self):
228
+ """Emit one deployment_detected event per new commit (best-effort)."""
229
+ try:
230
+ if self.deployment.should_emit_deployment_detected():
231
+ # Only claim a coverage snapshot when patching actually ran —
232
+ # auto_patch=False means "unknown", not "none". Package names
233
+ # throughout ('gemini' is the internal patcher key).
234
+ coverage = None
235
+ if self.config.auto_patch:
236
+ name_map = {"gemini": "google-generativeai"}
237
+ coverage = {
238
+ "patched_llm_libraries": [name_map.get(p, p) for p in self.patches],
239
+ "uncovered_llm_libraries": list(self.uncovered_llm_libraries),
240
+ # Which HTTP clients this deploy captures outbound calls through.
241
+ # Empty when capture is switched off — the record that distinguishes
242
+ # "this app makes no outbound calls" from "we were not looking".
243
+ # Stored on the deploy event; the coverage report reads live
244
+ # presence today and could read this receipt as well.
245
+ "outbound_http_libraries": list(self.outbound_http_libraries),
246
+ }
247
+ event = self.deployment.build_deployment_event(coverage=coverage)
248
+ if self.event_formatter.validate_event(event):
249
+ self.queue.enqueue(event)
250
+ self.config.log(
251
+ f"Deployment detected: {self.deployment.deployment_id} "
252
+ f"(git_sha={self.deployment.git_sha})"
253
+ )
254
+ except Exception as e:
255
+ # Never let deployment detection break SDK startup.
256
+ self.config.log(f"Failed to emit deployment_detected: {e}")
257
+
258
+ def _apply_patches(self):
259
+ """Apply monkey patches to LLM libraries"""
260
+ self.config.log("Auto-patching enabled - will patch LLM libraries")
261
+
262
+ # Patch OpenAI
263
+ try:
264
+ import openai
265
+ self._llm_sdks_detected.append('openai')
266
+ self.config.log("OpenAI library detected - patching...")
267
+ if self.openai_patcher.patch():
268
+ self.patches.append('openai')
269
+ else:
270
+ # The library is present but we failed to instrument it: the app will run
271
+ # fine and capture NOTHING. Never let that pass silently.
272
+ self.config.warn(
273
+ "openai is installed but Scope could not instrument it — "
274
+ "LLM calls will NOT be captured. Set SCOPE_DEBUG=true for details."
275
+ )
276
+ except ImportError:
277
+ self.config.log("OpenAI library not installed - skipping patch")
278
+
279
+ # Patch Anthropic
280
+ try:
281
+ import anthropic
282
+ self._llm_sdks_detected.append('anthropic')
283
+ self.config.log("Anthropic library detected - patching...")
284
+ if self.anthropic_patcher.patch():
285
+ self.patches.append('anthropic')
286
+ else:
287
+ self.config.warn(
288
+ "anthropic is installed but Scope could not instrument it — "
289
+ "LLM calls will NOT be captured. Set SCOPE_DEBUG=true for details."
290
+ )
291
+ except ImportError:
292
+ self.config.log("Anthropic library not installed - skipping patch")
293
+
294
+ # Patch Google Gemini — legacy google-generativeai SDK (deprecated/EOL 2025-11-30) ...
295
+ try:
296
+ import google.generativeai
297
+ self._llm_sdks_detected.append('google-generativeai')
298
+ self.config.log("Google Generative AI (legacy) library detected - patching...")
299
+ if self.gemini_patcher.patch():
300
+ self.patches.append('gemini')
301
+ else:
302
+ self.config.warn(
303
+ "google-generativeai is installed but Scope could not instrument it — "
304
+ "LLM calls will NOT be captured. Set SCOPE_DEBUG=true for details."
305
+ )
306
+ except ImportError:
307
+ self.config.log("Google Generative AI (legacy) library not installed - skipping patch")
308
+
309
+ # ... and the modern unified google-genai SDK (the recommended replacement). Both are patched
310
+ # additively so legacy and new Gemini users are covered.
311
+ try:
312
+ import google.genai # noqa: F401
313
+ self._llm_sdks_detected.append('google-genai')
314
+ self.config.log("google-genai (unified) library detected - patching...")
315
+ if self.google_genai_patcher.patch():
316
+ self.patches.append('google-genai')
317
+ else:
318
+ self.config.warn(
319
+ "google-genai is installed but Scope could not instrument it — "
320
+ "LLM calls will NOT be captured. Set SCOPE_DEBUG=true for details."
321
+ )
322
+ except ImportError:
323
+ self.config.log("google-genai (unified) library not installed - skipping patch")
324
+
325
+ self._report_coverage_gaps()
326
+
327
+ def _apply_http_capture(self):
328
+ """Capture the calls this app makes TO other services.
329
+
330
+ Its own switch, deliberately not `auto_patch`: that one is about instrumenting LLM
331
+ libraries, and an app that turns it off (custom provider handling, an unsupported
332
+ SDK) still wants to know which services it calls and how they are behaving.
333
+ SCOPE_CAPTURE_HTTP=false is the knob for this one.
334
+
335
+ Coverage is tracked apart from self.patches, which is reported to the backend as
336
+ `patched_llm_libraries`: a patched HTTP client says nothing about whether this
337
+ app's LLM calls are covered, and letting it silence the "no LLM SDK detected"
338
+ warning would hide a real gap.
339
+ """
340
+ if not self.config.capture_http:
341
+ self.config.log("Outbound HTTP capture disabled (SCOPE_CAPTURE_HTTP=false)")
342
+ return
343
+ if self.http_patcher.patch():
344
+ self.outbound_http_libraries = list(self.http_patcher.patched_libraries)
345
+ else:
346
+ # httpx ships with this SDK, so reaching here means the shape we instrument
347
+ # has moved — outbound calls silently stop being captured. Say so.
348
+ self.config.warn(
349
+ "Scope could not instrument any HTTP client — the outbound calls this app "
350
+ "makes to other services will NOT be captured. Set SCOPE_DEBUG=true for details."
351
+ )
352
+
353
+ def _report_coverage_gaps(self):
354
+ """Coverage honesty at startup (rule b): a known gap must never look like
355
+ working coverage.
356
+
357
+ Two silences this kills: (1) a KNOWN-uncovered LLM library (litellm, cohere)
358
+ installed alongside — or instead of — the SDKs we patch; (2) no recognized LLM
359
+ SDK at all, where zero output is indistinguishable from zero traffic. Detection
360
+ is metadata-only (find_spec never imports the target) and totally fail-safe.
361
+ """
362
+ try:
363
+ import importlib.util
364
+ from .supported_versions import KNOWN_UNCOVERED
365
+ for lib, why in KNOWN_UNCOVERED.items():
366
+ try:
367
+ if importlib.util.find_spec(lib) is not None:
368
+ self.uncovered_llm_libraries.append(lib)
369
+ self.config.warn(f"{lib} detected but NOT captured by Scope: {why}")
370
+ except Exception:
371
+ continue # one undetectable lib must not silence the others
372
+ if not self._llm_sdks_detected and not self.uncovered_llm_libraries:
373
+ self.config.warn(
374
+ "No supported LLM SDK detected (openai / anthropic / google-genai / "
375
+ "google-generativeai) — no LLM calls will be captured. Frontend and "
376
+ "backend events still flow. If this app calls an LLM through another "
377
+ "library, capture it manually with scope.track_event(...)."
378
+ )
379
+ except Exception as e:
380
+ # Coverage reporting must never break startup.
381
+ self.config.log(f"Coverage-gap check failed: {e}")
382
+
383
+ def track_event(self, event_type: str, properties: dict):
384
+ """
385
+ Manually track an event
386
+
387
+ Args:
388
+ event_type: Type of event (e.g., "llm_call", "external_api_call")
389
+ properties: Event properties
390
+ """
391
+ # Disabled construction has no formatter/queue (and possibly no config) —
392
+ # the one loud warning already fired at init, so just drop the event.
393
+ if not self.enabled:
394
+ return
395
+
396
+ self.config.log(f"Tracking event: {event_type}")
397
+
398
+ # Create event with standard fields
399
+ event = {
400
+ "event_type": event_type,
401
+ "source": self.config.sdk_source,
402
+ **properties
403
+ }
404
+
405
+ # Manual callers shouldn't need boilerplate: default the fields
406
+ # validate_event requires (never overriding caller-provided values).
407
+ # Without these defaults a bare track_event(...) call — exactly what the
408
+ # docs and the MCP coverage report tell users to write for uncovered
409
+ # libraries — was silently dropped at validation.
410
+ from datetime import datetime, timezone
411
+ event.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
412
+ if "session_id" not in event:
413
+ # generate_temp (NOT ensure_session_id): ensure_ would PIN the temp id
414
+ # into the contextvar as a side effect — in a long-lived worker with no
415
+ # per-request reset, that stitches every later event (manual AND
416
+ # auto-captured) into one bogus session. Same decision as events.py's
417
+ # http_request path. The lazy `if` also avoids evaluating any of this
418
+ # when the caller supplied a session_id.
419
+ event["session_id"] = (ScopeContext.get_session_id()
420
+ or ScopeContext.generate_temp_session_id())
421
+
422
+ # Validate and enqueue
423
+ if self.event_formatter.validate_event(event):
424
+ self.queue.enqueue(event)
425
+ else:
426
+ # A rejected manual event must not vanish silently.
427
+ self.config.warn(
428
+ f"Manual event '{event_type}' failed validation and was dropped."
429
+ )
430
+
431
+ def identify(self, user_id: str, traits: Optional[dict] = None):
432
+ """
433
+ Identify a user and stitch their prior anonymous activity to them.
434
+
435
+ Emits an ``identify`` event that the backend uses to merge the user's
436
+ anonymous session into this identified user — matched by the shared
437
+ ``session_id`` (the FE↔BE fingerprint) and/or the prior ``anonymous_id``
438
+ — and to attach the given traits. Also binds the identified user to the
439
+ current request context so subsequent events are attributed to them.
440
+
441
+ Args:
442
+ user_id: Unique user identifier (whatever your app uses — email, sub, etc.)
443
+ traits: Optional user traits (e.g., email, name, plan)
444
+ """
445
+ # Disabled construction has no formatter/queue (and possibly no config) —
446
+ # the one loud warning already fired at init, so just drop the identify.
447
+ if not self.enabled:
448
+ return
449
+
450
+ if not user_id:
451
+ self.config.log("identify() called without a user_id - ignoring")
452
+ return
453
+
454
+ # Capture the prior (anonymous) identity BEFORE overwriting the context,
455
+ # so the stitcher can link pre-identification events to this user. Prefer
456
+ # the bridged frontend anonymous id (the durable cross-visit visitor id)
457
+ # over the request's user_id, so the merge works even when this request
458
+ # already carries an authenticated user_id (e.g. a JWT issued at signup).
459
+ anonymous_id = ScopeContext.get_anonymous_id() or ScopeContext.get_user_id()
460
+ session_id = ScopeContext.get_session_id()
461
+
462
+ # Bind the identified user so later events in this request use it.
463
+ ScopeContext.set_user_id(user_id)
464
+
465
+ event = self.event_formatter.format_identify(
466
+ identified_user_id=user_id,
467
+ anonymous_id=anonymous_id,
468
+ session_id=session_id,
469
+ traits=traits,
470
+ )
471
+
472
+ if self.event_formatter.validate_event(event):
473
+ self.queue.enqueue(event)
474
+ self.config.log(
475
+ f"Identified user: {user_id} "
476
+ f"(anonymous_id={event.get('anonymous_id')}, session={event.get('session_id')})"
477
+ )
478
+ if traits:
479
+ self.config.log(f"User traits: {traits}")
480
+
481
+ def _flush_events(self, events: list):
482
+ """
483
+ Callback for flushing events to API
484
+ Called by event queue when batch is ready
485
+
486
+ Args:
487
+ events: List of events to flush
488
+ """
489
+ success = self.client.ship_events(events)
490
+
491
+ if not success:
492
+ self.config.log(f"⚠️ Failed to ship {len(events)} events")
493
+ if not self._warned_ship_failure:
494
+ # Event LOSS: a batch the backend refused (rate limit, auth, outage) is
495
+ # dropped, and until now that was visible only in debug mode. It matters
496
+ # more since outbound capture: more events means the per-IP ingest limit
497
+ # is reachable, and a dropped batch takes llm_call events with it.
498
+ self._warned_ship_failure = True
499
+ self.config.warn(
500
+ f"Could not deliver {len(events)} events to Scope — they were dropped. "
501
+ f"Check the API key and the endpoint; if this is volume, "
502
+ f"SCOPE_CAPTURE_HTTP=false turns off outbound HTTP capture. "
503
+ f"This warning is shown once."
504
+ )
505
+
506
+ def shutdown(self):
507
+ """
508
+ Gracefully shutdown SDK
509
+ Flushes remaining events and cleans up resources
510
+ """
511
+ # Disabled construction never started a queue, opened a client, or applied
512
+ # patches — nothing to tear down, so shutdown is a silent no-op.
513
+ if not self.enabled:
514
+ return
515
+
516
+ self.config.log("Shutting down Scope Analytics SDK...")
517
+
518
+ # Bound how long telemetry may delay process exit: the final synchronous
519
+ # flush ships with a short timeout (vs the 30s steady-state client timeout)
520
+ # so a hung/cold backend can't hold a customer's CLI or cron job hostage.
521
+ if self.client:
522
+ self.client.ship_timeout = 5.0
523
+
524
+ # Remove the outbound-HTTP wrappers FIRST: anything captured after the queue
525
+ # stops is appended to a queue that will never ship it, and the customer's own
526
+ # atexit handlers can still be making calls at this point.
527
+ self.http_patcher.unpatch()
528
+
529
+ # Stop queue and flush remaining events
530
+ if self.queue:
531
+ self.queue.stop()
532
+
533
+ # Close HTTP client
534
+ if self.client:
535
+ self.client.close()
536
+
537
+ # Remove patches
538
+ if 'openai' in self.patches:
539
+ self.openai_patcher.unpatch()
540
+ if 'anthropic' in self.patches:
541
+ self.anthropic_patcher.unpatch()
542
+ if 'gemini' in self.patches:
543
+ self.gemini_patcher.unpatch()
544
+ if 'google-genai' in self.patches:
545
+ self.google_genai_patcher.unpatch()
546
+
547
+ self.config.log("Scope Analytics SDK shutdown complete")
@@ -75,6 +75,13 @@ def _auto_init():
75
75
  auto_patch=True, # Always auto-patch in auto mode
76
76
  )
77
77
 
78
+ if not getattr(_scope_instance, 'enabled', True):
79
+ # ScopeAnalytics self-disabled (bad key) and already warned loudly.
80
+ # Don't hold the disabled instance: is_initialized() must report the
81
+ # truth — nothing is being captured.
82
+ _scope_instance = None
83
+ return None
84
+
78
85
  _log("Scope Analytics initialized successfully")
79
86
 
80
87
  # Auto-inject session middleware for known frameworks
@@ -221,8 +221,25 @@ Environment Variables:
221
221
  print(f"[Scope SDK] Starting with auto-instrumentation...")
222
222
  print(f"[Scope SDK] Command: {' '.join(final_command)}")
223
223
 
224
- # Execute the wrapped command
224
+ # Execute the wrapped command.
225
+ #
226
+ # POSIX: replace this process (exec) instead of spawning a child. scope-run's whole job is
227
+ # done once the environment is prepared, and staying resident as a wrapper breaks graceful
228
+ # shutdown in containers: as PID 1 it neither installs handlers nor forwards signals, so
229
+ # `docker stop`/Cloud Run/K8s SIGTERM is swallowed, the runtime escalates to SIGKILL, and
230
+ # the SDK's synchronous shutdown flush never runs — silently losing the final event batch
231
+ # on every instance stop. After exec the app itself is PID 1 and owns its signals.
232
+ #
233
+ # The sitecustomize temp dir outlives this process by design — reload/fork respawns
234
+ # (e.g. `uvicorn --reload`) re-import it from PYTHONPATH — so no cleanup is attempted
235
+ # (atexit couldn't run in a replaced process anyway; the OS tmp reaper collects it).
236
+ #
237
+ # Windows: exec* detaches from the waiting console/parent, so keep the subprocess wrapper.
225
238
  try:
239
+ if os.name == 'posix':
240
+ sys.stdout.flush()
241
+ sys.stderr.flush()
242
+ os.execvpe(final_command[0], final_command, env)
226
243
  result = subprocess.run(final_command, env=env)
227
244
  return result.returncode
228
245
  except FileNotFoundError: