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.
Files changed (43) hide show
  1. {scope_analytics-0.1.1/scope_analytics.egg-info → scope_analytics-0.1.2}/PKG-INFO +6 -2
  2. scope_analytics-0.1.2/scope_analytics/__init__.py +481 -0
  3. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/auto.py +7 -0
  4. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/client.py +23 -4
  5. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/config.py +42 -10
  6. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/context.py +20 -0
  7. scope_analytics-0.1.2/scope_analytics/deployment.py +193 -0
  8. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/events.py +178 -3
  9. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/middleware.py +40 -11
  10. scope_analytics-0.1.2/scope_analytics/patches/__init__.py +11 -0
  11. scope_analytics-0.1.2/scope_analytics/patches/_capture.py +815 -0
  12. scope_analytics-0.1.2/scope_analytics/patches/_streaming.py +207 -0
  13. scope_analytics-0.1.2/scope_analytics/patches/anthropic_patch.py +447 -0
  14. scope_analytics-0.1.2/scope_analytics/patches/gemini_patch.py +356 -0
  15. scope_analytics-0.1.2/scope_analytics/patches/google_genai_patch.py +212 -0
  16. scope_analytics-0.1.2/scope_analytics/patches/openai_patch.py +486 -0
  17. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/queue.py +78 -20
  18. scope_analytics-0.1.2/scope_analytics/supported_versions.py +99 -0
  19. {scope_analytics-0.1.1 → scope_analytics-0.1.2/scope_analytics.egg-info}/PKG-INFO +6 -2
  20. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics.egg-info/SOURCES.txt +14 -1
  21. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics.egg-info/requires.txt +6 -0
  22. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/setup.py +4 -2
  23. scope_analytics-0.1.2/tests/test_capture_contract.py +2178 -0
  24. scope_analytics-0.1.2/tests/test_capture_contract_google.py +769 -0
  25. scope_analytics-0.1.2/tests/test_coverage_honesty.py +105 -0
  26. scope_analytics-0.1.2/tests/test_deployment.py +208 -0
  27. scope_analytics-0.1.2/tests/test_identify.py +198 -0
  28. scope_analytics-0.1.2/tests/test_identity_bridge.py +94 -0
  29. scope_analytics-0.1.2/tests/test_patch_versions.py +605 -0
  30. scope_analytics-0.1.2/tests/test_redaction.py +56 -0
  31. scope_analytics-0.1.1/scope_analytics/__init__.py +0 -244
  32. scope_analytics-0.1.1/scope_analytics/patches/__init__.py +0 -9
  33. scope_analytics-0.1.1/scope_analytics/patches/anthropic_patch.py +0 -430
  34. scope_analytics-0.1.1/scope_analytics/patches/gemini_patch.py +0 -422
  35. scope_analytics-0.1.1/scope_analytics/patches/openai_patch.py +0 -483
  36. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/LICENSE +0 -0
  37. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/MANIFEST.in +0 -0
  38. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/README.md +0 -0
  39. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics/cli.py +0 -0
  40. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics.egg-info/dependency_links.txt +0 -0
  41. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics.egg-info/entry_points.txt +0 -0
  42. {scope_analytics-0.1.1 → scope_analytics-0.1.2}/scope_analytics.egg-info/top_level.txt +0 -0
  43. {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.1
3
+ Version: 0.1.2
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
@@ -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
- response = self.client.post(
67
- self.endpoint,
68
- json=payload
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: List of regex patterns to redact from events
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
- self.redact_patterns = redact_patterns or [
75
- r"password\s*=\s*['\"]?([^'\">\s]+)",
76
- r"api[_-]?key\s*=\s*['\"]?([^'\">\s]+)",
77
- r"token\s*=\s*['\"]?([^'\">\s]+)",
78
- r"secret\s*=\s*['\"]?([^'\">\s]+)",
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
- self.sdk_version = "0.1.0"
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)