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.
- {scope_analytics-0.1.1/scope_analytics.egg-info → scope_analytics-0.1.3}/PKG-INFO +13 -2
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/README.md +4 -0
- scope_analytics-0.1.3/scope_analytics/__init__.py +547 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics/auto.py +7 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics/cli.py +18 -1
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics/client.py +41 -7
- scope_analytics-0.1.3/scope_analytics/config.py +162 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics/context.py +127 -0
- scope_analytics-0.1.3/scope_analytics/deployment.py +193 -0
- scope_analytics-0.1.3/scope_analytics/events.py +598 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics/middleware.py +40 -11
- scope_analytics-0.1.3/scope_analytics/patches/__init__.py +11 -0
- scope_analytics-0.1.3/scope_analytics/patches/_capture.py +815 -0
- scope_analytics-0.1.3/scope_analytics/patches/_streaming.py +221 -0
- scope_analytics-0.1.3/scope_analytics/patches/anthropic_patch.py +462 -0
- scope_analytics-0.1.3/scope_analytics/patches/gemini_patch.py +364 -0
- scope_analytics-0.1.3/scope_analytics/patches/google_genai_patch.py +220 -0
- scope_analytics-0.1.3/scope_analytics/patches/http_patch.py +612 -0
- scope_analytics-0.1.3/scope_analytics/patches/openai_patch.py +494 -0
- scope_analytics-0.1.3/scope_analytics/queue.py +245 -0
- scope_analytics-0.1.3/scope_analytics/supported_versions.py +106 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.3/scope_analytics.egg-info}/PKG-INFO +13 -2
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics.egg-info/SOURCES.txt +17 -1
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics.egg-info/requires.txt +9 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/setup.py +12 -2
- scope_analytics-0.1.3/tests/test_capture_contract.py +2208 -0
- scope_analytics-0.1.3/tests/test_capture_contract_google.py +769 -0
- scope_analytics-0.1.3/tests/test_cli_exec.py +130 -0
- scope_analytics-0.1.3/tests/test_coverage_honesty.py +108 -0
- scope_analytics-0.1.3/tests/test_deployment.py +208 -0
- scope_analytics-0.1.3/tests/test_http_patch.py +822 -0
- scope_analytics-0.1.3/tests/test_identify.py +198 -0
- scope_analytics-0.1.3/tests/test_identity_bridge.py +94 -0
- scope_analytics-0.1.3/tests/test_patch_versions.py +634 -0
- scope_analytics-0.1.3/tests/test_redaction.py +56 -0
- scope_analytics-0.1.1/scope_analytics/__init__.py +0 -244
- scope_analytics-0.1.1/scope_analytics/config.py +0 -101
- scope_analytics-0.1.1/scope_analytics/events.py +0 -288
- scope_analytics-0.1.1/scope_analytics/patches/__init__.py +0 -9
- scope_analytics-0.1.1/scope_analytics/patches/anthropic_patch.py +0 -430
- scope_analytics-0.1.1/scope_analytics/patches/gemini_patch.py +0 -422
- scope_analytics-0.1.1/scope_analytics/patches/openai_patch.py +0 -483
- scope_analytics-0.1.1/scope_analytics/queue.py +0 -158
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/LICENSE +0 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/MANIFEST.in +0 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics.egg-info/dependency_links.txt +0 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics.egg-info/entry_points.txt +0 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.3}/scope_analytics.egg-info/top_level.txt +0 -0
- {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.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: AI-powered analytics SDK for backend applications with automatic LLM tracking
|
|
5
|
-
Home-page: https://
|
|
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:
|