scope-analytics 0.1.1__tar.gz → 0.1.2__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.2}/PKG-INFO +6 -2
- scope_analytics-0.1.2/scope_analytics/__init__.py +481 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/auto.py +7 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/client.py +23 -4
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/config.py +42 -10
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/context.py +20 -0
- scope_analytics-0.1.2/scope_analytics/deployment.py +193 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/events.py +178 -3
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/middleware.py +40 -11
- scope_analytics-0.1.2/scope_analytics/patches/__init__.py +11 -0
- scope_analytics-0.1.2/scope_analytics/patches/_capture.py +815 -0
- scope_analytics-0.1.2/scope_analytics/patches/_streaming.py +207 -0
- scope_analytics-0.1.2/scope_analytics/patches/anthropic_patch.py +447 -0
- scope_analytics-0.1.2/scope_analytics/patches/gemini_patch.py +356 -0
- scope_analytics-0.1.2/scope_analytics/patches/google_genai_patch.py +212 -0
- scope_analytics-0.1.2/scope_analytics/patches/openai_patch.py +486 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/queue.py +78 -20
- scope_analytics-0.1.2/scope_analytics/supported_versions.py +99 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2/scope_analytics.egg-info}/PKG-INFO +6 -2
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics.egg-info/SOURCES.txt +14 -1
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics.egg-info/requires.txt +6 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/setup.py +4 -2
- scope_analytics-0.1.2/tests/test_capture_contract.py +2178 -0
- scope_analytics-0.1.2/tests/test_capture_contract_google.py +769 -0
- scope_analytics-0.1.2/tests/test_coverage_honesty.py +105 -0
- scope_analytics-0.1.2/tests/test_deployment.py +208 -0
- scope_analytics-0.1.2/tests/test_identify.py +198 -0
- scope_analytics-0.1.2/tests/test_identity_bridge.py +94 -0
- scope_analytics-0.1.2/tests/test_patch_versions.py +605 -0
- scope_analytics-0.1.2/tests/test_redaction.py +56 -0
- scope_analytics-0.1.1/scope_analytics/__init__.py +0 -244
- 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-0.1.2}/LICENSE +0 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/MANIFEST.in +0 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/README.md +0 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/cli.py +0 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics.egg-info/dependency_links.txt +0 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics.egg-info/entry_points.txt +0 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics.egg-info/top_level.txt +0 -0
- {scope_analytics-0.1.1 → scope_analytics-0.1.2}/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.2
|
|
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
|
|
@@ -30,6 +30,10 @@ Provides-Extra: openai
|
|
|
30
30
|
Requires-Dist: openai>=1.0.0; extra == "openai"
|
|
31
31
|
Provides-Extra: anthropic
|
|
32
32
|
Requires-Dist: anthropic>=0.18.0; extra == "anthropic"
|
|
33
|
+
Provides-Extra: gemini
|
|
34
|
+
Requires-Dist: google-generativeai>=0.3.0; extra == "gemini"
|
|
35
|
+
Provides-Extra: google-genai
|
|
36
|
+
Requires-Dist: google-genai>=1.0.0; extra == "google-genai"
|
|
33
37
|
Provides-Extra: langchain
|
|
34
38
|
Requires-Dist: langchain>=0.1.0; extra == "langchain"
|
|
35
39
|
Dynamic: author
|
|
@@ -0,0 +1,481 @@
|
|
|
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 .middleware import (
|
|
21
|
+
ScopeSessionMiddleware,
|
|
22
|
+
FlaskScopeMiddleware,
|
|
23
|
+
init_flask_session_tracking,
|
|
24
|
+
DjangoScopeMiddleware,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def _resolve_version() -> str:
|
|
28
|
+
"""Single source of truth: the installed distribution's own metadata.
|
|
29
|
+
|
|
30
|
+
Hardcoding this drifted once already — 0.1.1 shipped stamping every event as
|
|
31
|
+
"0.1.0", which made it impossible to tell from our own ingested data which SDK a
|
|
32
|
+
customer was running (exactly what you need to triage an SDK bug). Deriving it
|
|
33
|
+
means setup.py is the only place a version lives.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
37
|
+
try:
|
|
38
|
+
return version("scope-analytics")
|
|
39
|
+
except PackageNotFoundError:
|
|
40
|
+
return "0.0.0+unknown" # running from a source tree, not installed
|
|
41
|
+
except Exception: # noqa: BLE001 - never let version lookup break an import
|
|
42
|
+
return "0.0.0+unknown"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__version__ = _resolve_version()
|
|
46
|
+
__all__ = [
|
|
47
|
+
"ScopeAnalytics",
|
|
48
|
+
"ScopeContext",
|
|
49
|
+
"ScopeConfig",
|
|
50
|
+
"DEFAULT_REDACT_PATTERNS", # default credential-scrub patterns (on by default; [] to opt out)
|
|
51
|
+
"get_sdk_instance",
|
|
52
|
+
# Middleware exports
|
|
53
|
+
"ScopeSessionMiddleware", # For FastAPI/Starlette
|
|
54
|
+
"FlaskScopeMiddleware", # For Flask (WSGI wrapper)
|
|
55
|
+
"init_flask_session_tracking", # For Flask (hooks approach)
|
|
56
|
+
"DjangoScopeMiddleware", # For Django
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# Global SDK instance reference for middleware access
|
|
60
|
+
# This allows middleware to capture HTTP request events without requiring
|
|
61
|
+
# explicit SDK reference in user code
|
|
62
|
+
_global_sdk_instance: Optional["ScopeAnalytics"] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_sdk_instance() -> Optional["ScopeAnalytics"]:
|
|
66
|
+
"""
|
|
67
|
+
Get the global SDK instance.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
ScopeAnalytics instance if initialized, None otherwise
|
|
71
|
+
"""
|
|
72
|
+
return _global_sdk_instance
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _set_sdk_instance(instance: "ScopeAnalytics") -> None:
|
|
76
|
+
"""
|
|
77
|
+
Set the global SDK instance.
|
|
78
|
+
Called internally when ScopeAnalytics is initialized.
|
|
79
|
+
"""
|
|
80
|
+
global _global_sdk_instance
|
|
81
|
+
_global_sdk_instance = instance
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ScopeAnalytics:
|
|
85
|
+
"""
|
|
86
|
+
Main SDK class for Scope Analytics
|
|
87
|
+
|
|
88
|
+
Usage:
|
|
89
|
+
scope = ScopeAnalytics(api_key="sk_live_...")
|
|
90
|
+
|
|
91
|
+
# SDK automatically patches OpenAI, Anthropic, LangChain
|
|
92
|
+
# All LLM calls are captured and sent to Scope AI
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
api_key: Optional[str] = None,
|
|
98
|
+
endpoint: Optional[str] = None,
|
|
99
|
+
auto_patch: bool = True,
|
|
100
|
+
batch_size: int = 10,
|
|
101
|
+
batch_timeout_seconds: int = 5,
|
|
102
|
+
max_queue_size: int = 1000,
|
|
103
|
+
debug: bool = False,
|
|
104
|
+
environment: Optional[str] = None,
|
|
105
|
+
):
|
|
106
|
+
"""
|
|
107
|
+
Initialize Scope Analytics SDK
|
|
108
|
+
|
|
109
|
+
A missing or malformed API key does NOT raise: the SDK emits one
|
|
110
|
+
always-visible warning and constructs itself disabled — no patching, no
|
|
111
|
+
event capture, every public method a no-op — so a config typo can never
|
|
112
|
+
take the host application down (PRODUCT.md §2, Robustness: analytics must never break
|
|
113
|
+
the host app; fail loudly, not silently).
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
api_key: Secret API key (sk_live_... or sk_test_...)
|
|
117
|
+
endpoint: API endpoint URL (default: https://api.scopeai.dev)
|
|
118
|
+
auto_patch: Automatically patch LLM libraries (default: True)
|
|
119
|
+
batch_size: Events per batch (default: 10)
|
|
120
|
+
batch_timeout_seconds: Max wait before sending partial batch (default: 5)
|
|
121
|
+
max_queue_size: Max events to queue (default: 1000)
|
|
122
|
+
debug: Enable debug logging (default: False)
|
|
123
|
+
environment: Environment name (default: production)
|
|
124
|
+
"""
|
|
125
|
+
# Defined before anything can fail so every instance — including a
|
|
126
|
+
# disabled shell — carries it, and the no-op guards below never AttributeError.
|
|
127
|
+
self.enabled = True
|
|
128
|
+
|
|
129
|
+
# Initialize configuration. Only ValueError is caught: it is the specific,
|
|
130
|
+
# known config-validation failure (missing key, wrong prefix). Any other
|
|
131
|
+
# exception is an SDK bug and must still surface in development. The
|
|
132
|
+
# zero-code path (auto.py) already degrades this way; the manual path has
|
|
133
|
+
# to behave identically or a typo'd key crashes the app at startup.
|
|
134
|
+
try:
|
|
135
|
+
self.config = ScopeConfig(
|
|
136
|
+
api_key=api_key,
|
|
137
|
+
endpoint=endpoint,
|
|
138
|
+
auto_patch=auto_patch,
|
|
139
|
+
batch_size=batch_size,
|
|
140
|
+
batch_timeout_seconds=batch_timeout_seconds,
|
|
141
|
+
max_queue_size=max_queue_size,
|
|
142
|
+
debug=debug,
|
|
143
|
+
environment=environment,
|
|
144
|
+
)
|
|
145
|
+
except ValueError as e:
|
|
146
|
+
# self.config doesn't exist yet, so go straight to the same
|
|
147
|
+
# always-visible logger channel ScopeConfig.warn() uses.
|
|
148
|
+
logging.getLogger("scope_analytics").warning(
|
|
149
|
+
"[Scope SDK] %s Scope Analytics is DISABLED — no events will be "
|
|
150
|
+
"captured and no LLM libraries will be patched.",
|
|
151
|
+
e,
|
|
152
|
+
)
|
|
153
|
+
self.enabled = False
|
|
154
|
+
# Heavy components (formatter, client, deployment, queue, patchers)
|
|
155
|
+
# stay unconstructed: no background thread, no atexit hook, and no
|
|
156
|
+
# global registration — a disabled instance must never become the
|
|
157
|
+
# middleware's SDK.
|
|
158
|
+
self.patches = []
|
|
159
|
+
self.uncovered_llm_libraries = []
|
|
160
|
+
self._llm_sdks_detected = []
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# Initialize components
|
|
164
|
+
self.event_formatter = EventFormatter(self.config)
|
|
165
|
+
self.client = ScopeAPIClient(self.config)
|
|
166
|
+
|
|
167
|
+
# Capture deployment + commit identity once at startup (PRODUCT.md §4.2).
|
|
168
|
+
# Its .stamp is wired as the queue enricher so git_sha/deployment_id land
|
|
169
|
+
# on every event through a single chokepoint.
|
|
170
|
+
self.deployment = DeploymentContext(self.config)
|
|
171
|
+
|
|
172
|
+
self.queue = EventQueue(
|
|
173
|
+
batch_size=self.config.batch_size,
|
|
174
|
+
batch_timeout_seconds=self.config.batch_timeout_seconds,
|
|
175
|
+
max_queue_size=self.config.max_queue_size,
|
|
176
|
+
flush_callback=self._flush_events,
|
|
177
|
+
config=self.config,
|
|
178
|
+
event_enricher=self.deployment.stamp,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Initialize patchers
|
|
182
|
+
self.openai_patcher = OpenAIPatcher(self)
|
|
183
|
+
self.anthropic_patcher = AnthropicPatcher(self)
|
|
184
|
+
self.gemini_patcher = GeminiPatcher(self) # legacy google-generativeai (EOL)
|
|
185
|
+
self.google_genai_patcher = GoogleGenaiPatcher(self) # unified google-genai (modern)
|
|
186
|
+
self.patches = [] # List of successfully applied patches
|
|
187
|
+
self.uncovered_llm_libraries = [] # Known-uncovered LLM libs detected at startup
|
|
188
|
+
self._llm_sdks_detected = [] # Recognized SDKs whose import succeeded (≠ patched)
|
|
189
|
+
|
|
190
|
+
# Start queue background thread
|
|
191
|
+
self.queue.start()
|
|
192
|
+
|
|
193
|
+
# Register shutdown hook
|
|
194
|
+
atexit.register(self.shutdown)
|
|
195
|
+
|
|
196
|
+
self.config.log("Scope Analytics SDK initialized")
|
|
197
|
+
self.config.log(f"Configuration: {self.config.to_dict()}")
|
|
198
|
+
|
|
199
|
+
# Register as global instance for middleware access
|
|
200
|
+
_set_sdk_instance(self)
|
|
201
|
+
|
|
202
|
+
# Auto-patch LLM libraries if enabled
|
|
203
|
+
if self.config.auto_patch:
|
|
204
|
+
self._apply_patches()
|
|
205
|
+
|
|
206
|
+
# Emit a single deployment_detected event when this boot's commit differs
|
|
207
|
+
# from the previous boot's (PRODUCT.md §4.2). No-op when no SHA is known.
|
|
208
|
+
self._emit_deployment_detected()
|
|
209
|
+
|
|
210
|
+
def _emit_deployment_detected(self):
|
|
211
|
+
"""Emit one deployment_detected event per new commit (best-effort)."""
|
|
212
|
+
try:
|
|
213
|
+
if self.deployment.should_emit_deployment_detected():
|
|
214
|
+
# Only claim a coverage snapshot when patching actually ran —
|
|
215
|
+
# auto_patch=False means "unknown", not "none". Package names
|
|
216
|
+
# throughout ('gemini' is the internal patcher key).
|
|
217
|
+
coverage = None
|
|
218
|
+
if self.config.auto_patch:
|
|
219
|
+
name_map = {"gemini": "google-generativeai"}
|
|
220
|
+
coverage = {
|
|
221
|
+
"patched_llm_libraries": [name_map.get(p, p) for p in self.patches],
|
|
222
|
+
"uncovered_llm_libraries": list(self.uncovered_llm_libraries),
|
|
223
|
+
}
|
|
224
|
+
event = self.deployment.build_deployment_event(coverage=coverage)
|
|
225
|
+
if self.event_formatter.validate_event(event):
|
|
226
|
+
self.queue.enqueue(event)
|
|
227
|
+
self.config.log(
|
|
228
|
+
f"Deployment detected: {self.deployment.deployment_id} "
|
|
229
|
+
f"(git_sha={self.deployment.git_sha})"
|
|
230
|
+
)
|
|
231
|
+
except Exception as e:
|
|
232
|
+
# Never let deployment detection break SDK startup.
|
|
233
|
+
self.config.log(f"Failed to emit deployment_detected: {e}")
|
|
234
|
+
|
|
235
|
+
def _apply_patches(self):
|
|
236
|
+
"""Apply monkey patches to LLM libraries"""
|
|
237
|
+
self.config.log("Auto-patching enabled - will patch LLM libraries")
|
|
238
|
+
|
|
239
|
+
# Patch OpenAI
|
|
240
|
+
try:
|
|
241
|
+
import openai
|
|
242
|
+
self._llm_sdks_detected.append('openai')
|
|
243
|
+
self.config.log("OpenAI library detected - patching...")
|
|
244
|
+
if self.openai_patcher.patch():
|
|
245
|
+
self.patches.append('openai')
|
|
246
|
+
else:
|
|
247
|
+
# The library is present but we failed to instrument it: the app will run
|
|
248
|
+
# fine and capture NOTHING. Never let that pass silently.
|
|
249
|
+
self.config.warn(
|
|
250
|
+
"openai is installed but Scope could not instrument it — "
|
|
251
|
+
"LLM calls will NOT be captured. Set SCOPE_DEBUG=true for details."
|
|
252
|
+
)
|
|
253
|
+
except ImportError:
|
|
254
|
+
self.config.log("OpenAI library not installed - skipping patch")
|
|
255
|
+
|
|
256
|
+
# Patch Anthropic
|
|
257
|
+
try:
|
|
258
|
+
import anthropic
|
|
259
|
+
self._llm_sdks_detected.append('anthropic')
|
|
260
|
+
self.config.log("Anthropic library detected - patching...")
|
|
261
|
+
if self.anthropic_patcher.patch():
|
|
262
|
+
self.patches.append('anthropic')
|
|
263
|
+
else:
|
|
264
|
+
self.config.warn(
|
|
265
|
+
"anthropic is installed but Scope could not instrument it — "
|
|
266
|
+
"LLM calls will NOT be captured. Set SCOPE_DEBUG=true for details."
|
|
267
|
+
)
|
|
268
|
+
except ImportError:
|
|
269
|
+
self.config.log("Anthropic library not installed - skipping patch")
|
|
270
|
+
|
|
271
|
+
# Patch Google Gemini — legacy google-generativeai SDK (deprecated/EOL 2025-11-30) ...
|
|
272
|
+
try:
|
|
273
|
+
import google.generativeai
|
|
274
|
+
self._llm_sdks_detected.append('google-generativeai')
|
|
275
|
+
self.config.log("Google Generative AI (legacy) library detected - patching...")
|
|
276
|
+
if self.gemini_patcher.patch():
|
|
277
|
+
self.patches.append('gemini')
|
|
278
|
+
else:
|
|
279
|
+
self.config.warn(
|
|
280
|
+
"google-generativeai is installed but Scope could not instrument it — "
|
|
281
|
+
"LLM calls will NOT be captured. Set SCOPE_DEBUG=true for details."
|
|
282
|
+
)
|
|
283
|
+
except ImportError:
|
|
284
|
+
self.config.log("Google Generative AI (legacy) library not installed - skipping patch")
|
|
285
|
+
|
|
286
|
+
# ... and the modern unified google-genai SDK (the recommended replacement). Both are patched
|
|
287
|
+
# additively so legacy and new Gemini users are covered.
|
|
288
|
+
try:
|
|
289
|
+
import google.genai # noqa: F401
|
|
290
|
+
self._llm_sdks_detected.append('google-genai')
|
|
291
|
+
self.config.log("google-genai (unified) library detected - patching...")
|
|
292
|
+
if self.google_genai_patcher.patch():
|
|
293
|
+
self.patches.append('google-genai')
|
|
294
|
+
else:
|
|
295
|
+
self.config.warn(
|
|
296
|
+
"google-genai is installed but Scope could not instrument it — "
|
|
297
|
+
"LLM calls will NOT be captured. Set SCOPE_DEBUG=true for details."
|
|
298
|
+
)
|
|
299
|
+
except ImportError:
|
|
300
|
+
self.config.log("google-genai (unified) library not installed - skipping patch")
|
|
301
|
+
|
|
302
|
+
self._report_coverage_gaps()
|
|
303
|
+
|
|
304
|
+
def _report_coverage_gaps(self):
|
|
305
|
+
"""Coverage honesty at startup (rule b): a known gap must never look like
|
|
306
|
+
working coverage.
|
|
307
|
+
|
|
308
|
+
Two silences this kills: (1) a KNOWN-uncovered LLM library (litellm, cohere)
|
|
309
|
+
installed alongside — or instead of — the SDKs we patch; (2) no recognized LLM
|
|
310
|
+
SDK at all, where zero output is indistinguishable from zero traffic. Detection
|
|
311
|
+
is metadata-only (find_spec never imports the target) and totally fail-safe.
|
|
312
|
+
"""
|
|
313
|
+
try:
|
|
314
|
+
import importlib.util
|
|
315
|
+
from .supported_versions import KNOWN_UNCOVERED
|
|
316
|
+
for lib, why in KNOWN_UNCOVERED.items():
|
|
317
|
+
try:
|
|
318
|
+
if importlib.util.find_spec(lib) is not None:
|
|
319
|
+
self.uncovered_llm_libraries.append(lib)
|
|
320
|
+
self.config.warn(f"{lib} detected but NOT captured by Scope: {why}")
|
|
321
|
+
except Exception:
|
|
322
|
+
continue # one undetectable lib must not silence the others
|
|
323
|
+
if not self._llm_sdks_detected and not self.uncovered_llm_libraries:
|
|
324
|
+
self.config.warn(
|
|
325
|
+
"No supported LLM SDK detected (openai / anthropic / google-genai / "
|
|
326
|
+
"google-generativeai) — no LLM calls will be captured. Frontend and "
|
|
327
|
+
"backend events still flow. If this app calls an LLM through another "
|
|
328
|
+
"library, capture it manually with scope.track_event(...)."
|
|
329
|
+
)
|
|
330
|
+
except Exception as e:
|
|
331
|
+
# Coverage reporting must never break startup.
|
|
332
|
+
self.config.log(f"Coverage-gap check failed: {e}")
|
|
333
|
+
|
|
334
|
+
def track_event(self, event_type: str, properties: dict):
|
|
335
|
+
"""
|
|
336
|
+
Manually track an event
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
event_type: Type of event (e.g., "llm_call", "external_api_call")
|
|
340
|
+
properties: Event properties
|
|
341
|
+
"""
|
|
342
|
+
# Disabled construction has no formatter/queue (and possibly no config) —
|
|
343
|
+
# the one loud warning already fired at init, so just drop the event.
|
|
344
|
+
if not self.enabled:
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
self.config.log(f"Tracking event: {event_type}")
|
|
348
|
+
|
|
349
|
+
# Create event with standard fields
|
|
350
|
+
event = {
|
|
351
|
+
"event_type": event_type,
|
|
352
|
+
"source": self.config.sdk_source,
|
|
353
|
+
**properties
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# Manual callers shouldn't need boilerplate: default the fields
|
|
357
|
+
# validate_event requires (never overriding caller-provided values).
|
|
358
|
+
# Without these defaults a bare track_event(...) call — exactly what the
|
|
359
|
+
# docs and the MCP coverage report tell users to write for uncovered
|
|
360
|
+
# libraries — was silently dropped at validation.
|
|
361
|
+
from datetime import datetime, timezone
|
|
362
|
+
event.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
|
|
363
|
+
if "session_id" not in event:
|
|
364
|
+
# generate_temp (NOT ensure_session_id): ensure_ would PIN the temp id
|
|
365
|
+
# into the contextvar as a side effect — in a long-lived worker with no
|
|
366
|
+
# per-request reset, that stitches every later event (manual AND
|
|
367
|
+
# auto-captured) into one bogus session. Same decision as events.py's
|
|
368
|
+
# http_request path. The lazy `if` also avoids evaluating any of this
|
|
369
|
+
# when the caller supplied a session_id.
|
|
370
|
+
event["session_id"] = (ScopeContext.get_session_id()
|
|
371
|
+
or ScopeContext.generate_temp_session_id())
|
|
372
|
+
|
|
373
|
+
# Validate and enqueue
|
|
374
|
+
if self.event_formatter.validate_event(event):
|
|
375
|
+
self.queue.enqueue(event)
|
|
376
|
+
else:
|
|
377
|
+
# A rejected manual event must not vanish silently.
|
|
378
|
+
self.config.warn(
|
|
379
|
+
f"Manual event '{event_type}' failed validation and was dropped."
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def identify(self, user_id: str, traits: Optional[dict] = None):
|
|
383
|
+
"""
|
|
384
|
+
Identify a user and stitch their prior anonymous activity to them.
|
|
385
|
+
|
|
386
|
+
Emits an ``identify`` event that the backend uses to merge the user's
|
|
387
|
+
anonymous session into this identified user — matched by the shared
|
|
388
|
+
``session_id`` (the FE↔BE fingerprint) and/or the prior ``anonymous_id``
|
|
389
|
+
— and to attach the given traits. Also binds the identified user to the
|
|
390
|
+
current request context so subsequent events are attributed to them.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
user_id: Unique user identifier (whatever your app uses — email, sub, etc.)
|
|
394
|
+
traits: Optional user traits (e.g., email, name, plan)
|
|
395
|
+
"""
|
|
396
|
+
# Disabled construction has no formatter/queue (and possibly no config) —
|
|
397
|
+
# the one loud warning already fired at init, so just drop the identify.
|
|
398
|
+
if not self.enabled:
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
if not user_id:
|
|
402
|
+
self.config.log("identify() called without a user_id - ignoring")
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
# Capture the prior (anonymous) identity BEFORE overwriting the context,
|
|
406
|
+
# so the stitcher can link pre-identification events to this user. Prefer
|
|
407
|
+
# the bridged frontend anonymous id (the durable cross-visit visitor id)
|
|
408
|
+
# over the request's user_id, so the merge works even when this request
|
|
409
|
+
# already carries an authenticated user_id (e.g. a JWT issued at signup).
|
|
410
|
+
anonymous_id = ScopeContext.get_anonymous_id() or ScopeContext.get_user_id()
|
|
411
|
+
session_id = ScopeContext.get_session_id()
|
|
412
|
+
|
|
413
|
+
# Bind the identified user so later events in this request use it.
|
|
414
|
+
ScopeContext.set_user_id(user_id)
|
|
415
|
+
|
|
416
|
+
event = self.event_formatter.format_identify(
|
|
417
|
+
identified_user_id=user_id,
|
|
418
|
+
anonymous_id=anonymous_id,
|
|
419
|
+
session_id=session_id,
|
|
420
|
+
traits=traits,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if self.event_formatter.validate_event(event):
|
|
424
|
+
self.queue.enqueue(event)
|
|
425
|
+
self.config.log(
|
|
426
|
+
f"Identified user: {user_id} "
|
|
427
|
+
f"(anonymous_id={event.get('anonymous_id')}, session={event.get('session_id')})"
|
|
428
|
+
)
|
|
429
|
+
if traits:
|
|
430
|
+
self.config.log(f"User traits: {traits}")
|
|
431
|
+
|
|
432
|
+
def _flush_events(self, events: list):
|
|
433
|
+
"""
|
|
434
|
+
Callback for flushing events to API
|
|
435
|
+
Called by event queue when batch is ready
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
events: List of events to flush
|
|
439
|
+
"""
|
|
440
|
+
success = self.client.ship_events(events)
|
|
441
|
+
|
|
442
|
+
if not success:
|
|
443
|
+
self.config.log(f"⚠️ Failed to ship {len(events)} events")
|
|
444
|
+
|
|
445
|
+
def shutdown(self):
|
|
446
|
+
"""
|
|
447
|
+
Gracefully shutdown SDK
|
|
448
|
+
Flushes remaining events and cleans up resources
|
|
449
|
+
"""
|
|
450
|
+
# Disabled construction never started a queue, opened a client, or applied
|
|
451
|
+
# patches — nothing to tear down, so shutdown is a silent no-op.
|
|
452
|
+
if not self.enabled:
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
self.config.log("Shutting down Scope Analytics SDK...")
|
|
456
|
+
|
|
457
|
+
# Bound how long telemetry may delay process exit: the final synchronous
|
|
458
|
+
# flush ships with a short timeout (vs the 30s steady-state client timeout)
|
|
459
|
+
# so a hung/cold backend can't hold a customer's CLI or cron job hostage.
|
|
460
|
+
if self.client:
|
|
461
|
+
self.client.ship_timeout = 5.0
|
|
462
|
+
|
|
463
|
+
# Stop queue and flush remaining events
|
|
464
|
+
if self.queue:
|
|
465
|
+
self.queue.stop()
|
|
466
|
+
|
|
467
|
+
# Close HTTP client
|
|
468
|
+
if self.client:
|
|
469
|
+
self.client.close()
|
|
470
|
+
|
|
471
|
+
# Remove patches
|
|
472
|
+
if 'openai' in self.patches:
|
|
473
|
+
self.openai_patcher.unpatch()
|
|
474
|
+
if 'anthropic' in self.patches:
|
|
475
|
+
self.anthropic_patcher.unpatch()
|
|
476
|
+
if 'gemini' in self.patches:
|
|
477
|
+
self.gemini_patcher.unpatch()
|
|
478
|
+
if 'google-genai' in self.patches:
|
|
479
|
+
self.google_genai_patcher.unpatch()
|
|
480
|
+
|
|
481
|
+
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
|
|
@@ -3,6 +3,8 @@ HTTP client for shipping events to Scope Analytics API
|
|
|
3
3
|
Handles async communication with the backend API
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import json
|
|
7
|
+
|
|
6
8
|
import httpx
|
|
7
9
|
from typing import List, Dict, Any
|
|
8
10
|
|
|
@@ -28,6 +30,10 @@ class ScopeAPIClient:
|
|
|
28
30
|
self.config = config
|
|
29
31
|
self.endpoint = f"{config.endpoint}/api/events"
|
|
30
32
|
|
|
33
|
+
# Per-request timeout override; shutdown sets this to a small value so the
|
|
34
|
+
# final synchronous flush can't hold process exit for the full 30s.
|
|
35
|
+
self.ship_timeout = None
|
|
36
|
+
|
|
31
37
|
# Create HTTP client
|
|
32
38
|
self.client = httpx.Client(
|
|
33
39
|
timeout=30.0,
|
|
@@ -62,11 +68,24 @@ class ScopeAPIClient:
|
|
|
62
68
|
"source": self.config.sdk_source,
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
# Strict-first serialization with a LOUD lossy fallback: events are
|
|
72
|
+
# sanitized at build time, so the strict path should always win — but a
|
|
73
|
+
# single stray non-JSON value must degrade to its string form (with a
|
|
74
|
+
# warning), never lose the whole batch.
|
|
75
|
+
try:
|
|
76
|
+
body = json.dumps(payload)
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
body = json.dumps(payload, default=str)
|
|
79
|
+
self.config.warn(
|
|
80
|
+
"Non-JSON value in event batch — coerced via str(). "
|
|
81
|
+
"This indicates an event-sanitization gap; please report it."
|
|
82
|
+
)
|
|
83
|
+
|
|
65
84
|
# Send POST request
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
)
|
|
85
|
+
kwargs = {"content": body}
|
|
86
|
+
if self.ship_timeout is not None:
|
|
87
|
+
kwargs["timeout"] = self.ship_timeout
|
|
88
|
+
response = self.client.post(self.endpoint, **kwargs)
|
|
70
89
|
|
|
71
90
|
# Check response
|
|
72
91
|
if response.status_code == 200:
|
|
@@ -3,6 +3,7 @@ Configuration management for Scope Analytics SDK
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
+
import logging
|
|
6
7
|
from typing import Optional, Dict, Any
|
|
7
8
|
from dotenv import load_dotenv
|
|
8
9
|
|
|
@@ -10,6 +11,22 @@ from dotenv import load_dotenv
|
|
|
10
11
|
load_dotenv()
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
# Default credential-redaction patterns — applied BY DEFAULT (see ScopeConfig.redact_patterns; pass
|
|
15
|
+
# redact_patterns=[] to opt out and store raw). Each captures the KEY as group 1 and matches the
|
|
16
|
+
# secret VALUE *outside* the group, so the substitution r"\1***REDACTED***" keeps the key and DROPS
|
|
17
|
+
# the value: "password=hunter2" -> "password=***REDACTED***". Credential strings have no analytical
|
|
18
|
+
# value, so scrubbing them by default is a safe net for accidental secrets. Best-effort over free
|
|
19
|
+
# text (prompt + response + message contents) — NOT a structural guarantee: it won't catch secrets in
|
|
20
|
+
# non-"key=value" shapes (e.g. JSON '"api_key":"..."'), so it's a safety net, not a privacy guarantee.
|
|
21
|
+
# Broader PII / content redaction stays opt-in (supply your own patterns).
|
|
22
|
+
DEFAULT_REDACT_PATTERNS = [
|
|
23
|
+
r"(password\s*=\s*['\"]?)[^'\"\s]+",
|
|
24
|
+
r"(api[_-]?key\s*=\s*['\"]?)[^'\"\s]+",
|
|
25
|
+
r"(token\s*=\s*['\"]?)[^'\"\s]+",
|
|
26
|
+
r"(secret\s*=\s*['\"]?)[^'\"\s]+",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
13
30
|
class ScopeConfig:
|
|
14
31
|
"""Configuration for Scope Analytics SDK"""
|
|
15
32
|
|
|
@@ -37,7 +54,10 @@ class ScopeConfig:
|
|
|
37
54
|
max_queue_size: Maximum events to queue (oldest dropped if exceeded)
|
|
38
55
|
debug: Enable debug logging
|
|
39
56
|
environment: Environment name (production, staging, development)
|
|
40
|
-
redact_patterns:
|
|
57
|
+
redact_patterns: Regex patterns to scrub from prompt/response/bodies. Default None =
|
|
58
|
+
credential scrub ON (DEFAULT_REDACT_PATTERNS — password/api_key/token/secret in
|
|
59
|
+
key=value form). Pass [] to opt out and store raw at full fidelity, or your own
|
|
60
|
+
patterns to scrub more (each must capture the key as group 1).
|
|
41
61
|
"""
|
|
42
62
|
# API Key - required
|
|
43
63
|
self.api_key = api_key or os.getenv("SCOPE_API_KEY")
|
|
@@ -70,16 +90,18 @@ class ScopeConfig:
|
|
|
70
90
|
self.debug = debug or os.getenv("SCOPE_DEBUG", "").lower() == "true"
|
|
71
91
|
self.environment = environment or os.getenv("SCOPE_ENVIRONMENT", "production")
|
|
72
92
|
|
|
73
|
-
# Privacy
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
93
|
+
# Privacy — a credential scrub (password/api_key/token/secret in key=value form) is applied
|
|
94
|
+
# BY DEFAULT, since those strings carry no analytical value. Pass redact_patterns=[] to opt
|
|
95
|
+
# out and store raw, or your own patterns to scrub more. None (unset) = the default scrub.
|
|
96
|
+
self.redact_patterns = (
|
|
97
|
+
list(DEFAULT_REDACT_PATTERNS) # copy so the shared module default can't be mutated
|
|
98
|
+
if redact_patterns is None
|
|
99
|
+
else redact_patterns
|
|
100
|
+
)
|
|
80
101
|
|
|
81
|
-
# SDK metadata
|
|
82
|
-
|
|
102
|
+
# SDK metadata. Derived, never hardcoded — see scope_analytics.__version__.
|
|
103
|
+
from . import __version__ as _sdk_version
|
|
104
|
+
self.sdk_version = _sdk_version
|
|
83
105
|
self.sdk_source = "backend_sdk"
|
|
84
106
|
|
|
85
107
|
def to_dict(self) -> Dict[str, Any]:
|
|
@@ -99,3 +121,13 @@ class ScopeConfig:
|
|
|
99
121
|
"""Log debug message if debug mode enabled"""
|
|
100
122
|
if self.debug:
|
|
101
123
|
print(f"[Scope SDK] {message}")
|
|
124
|
+
|
|
125
|
+
def warn(self, message: str):
|
|
126
|
+
"""Always-visible warning. Deliberately NOT gated on ``debug``.
|
|
127
|
+
|
|
128
|
+
An observability SDK that fails to instrument and says nothing is
|
|
129
|
+
indistinguishable from one that is working — the single worst failure mode we
|
|
130
|
+
can have, and precisely how a "captures zero OpenAI calls on openai>=2" bug
|
|
131
|
+
survived a full release. PRODUCT.md §2 (Robustness): "fail loudly, not silently."
|
|
132
|
+
"""
|
|
133
|
+
logging.getLogger("scope_analytics").warning("[Scope SDK] %s", message)
|