scope-analytics 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,244 @@
1
+ """
2
+ Scope Analytics - Backend SDK
3
+ AI-powered analytics with automatic LLM conversation tracking
4
+ """
5
+
6
+ import atexit
7
+ from typing import Optional
8
+
9
+ from .config import ScopeConfig
10
+ from .context import ScopeContext
11
+ from .events import EventFormatter
12
+ from .queue import EventQueue
13
+ from .client import ScopeAPIClient
14
+ from .patches.openai_patch import OpenAIPatcher
15
+ from .patches.anthropic_patch import AnthropicPatcher
16
+ from .patches.gemini_patch import GeminiPatcher
17
+ from .middleware import (
18
+ ScopeSessionMiddleware,
19
+ FlaskScopeMiddleware,
20
+ init_flask_session_tracking,
21
+ DjangoScopeMiddleware,
22
+ )
23
+
24
+ __version__ = "0.1.0"
25
+ __all__ = [
26
+ "ScopeAnalytics",
27
+ "ScopeContext",
28
+ "get_sdk_instance",
29
+ # Middleware exports
30
+ "ScopeSessionMiddleware", # For FastAPI/Starlette
31
+ "FlaskScopeMiddleware", # For Flask (WSGI wrapper)
32
+ "init_flask_session_tracking", # For Flask (hooks approach)
33
+ "DjangoScopeMiddleware", # For Django
34
+ ]
35
+
36
+ # Global SDK instance reference for middleware access
37
+ # This allows middleware to capture HTTP request events without requiring
38
+ # explicit SDK reference in user code
39
+ _global_sdk_instance: Optional["ScopeAnalytics"] = None
40
+
41
+
42
+ def get_sdk_instance() -> Optional["ScopeAnalytics"]:
43
+ """
44
+ Get the global SDK instance.
45
+
46
+ Returns:
47
+ ScopeAnalytics instance if initialized, None otherwise
48
+ """
49
+ return _global_sdk_instance
50
+
51
+
52
+ def _set_sdk_instance(instance: "ScopeAnalytics") -> None:
53
+ """
54
+ Set the global SDK instance.
55
+ Called internally when ScopeAnalytics is initialized.
56
+ """
57
+ global _global_sdk_instance
58
+ _global_sdk_instance = instance
59
+
60
+
61
+ class ScopeAnalytics:
62
+ """
63
+ Main SDK class for Scope Analytics
64
+
65
+ Usage:
66
+ scope = ScopeAnalytics(api_key="sk_live_...")
67
+
68
+ # SDK automatically patches OpenAI, Anthropic, LangChain
69
+ # All LLM calls are captured and sent to Scope AI
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ api_key: Optional[str] = None,
75
+ endpoint: Optional[str] = None,
76
+ auto_patch: bool = True,
77
+ batch_size: int = 10,
78
+ batch_timeout_seconds: int = 5,
79
+ max_queue_size: int = 1000,
80
+ debug: bool = False,
81
+ environment: Optional[str] = None,
82
+ ):
83
+ """
84
+ Initialize Scope Analytics SDK
85
+
86
+ Args:
87
+ api_key: Secret API key (sk_live_... or sk_test_...)
88
+ endpoint: API endpoint URL (default: https://api.scopeai.dev)
89
+ auto_patch: Automatically patch LLM libraries (default: True)
90
+ batch_size: Events per batch (default: 10)
91
+ batch_timeout_seconds: Max wait before sending partial batch (default: 5)
92
+ max_queue_size: Max events to queue (default: 1000)
93
+ debug: Enable debug logging (default: False)
94
+ environment: Environment name (default: production)
95
+ """
96
+ # Initialize configuration
97
+ self.config = ScopeConfig(
98
+ api_key=api_key,
99
+ endpoint=endpoint,
100
+ auto_patch=auto_patch,
101
+ batch_size=batch_size,
102
+ batch_timeout_seconds=batch_timeout_seconds,
103
+ max_queue_size=max_queue_size,
104
+ debug=debug,
105
+ environment=environment,
106
+ )
107
+
108
+ # Initialize components
109
+ self.event_formatter = EventFormatter(self.config)
110
+ self.client = ScopeAPIClient(self.config)
111
+ self.queue = EventQueue(
112
+ batch_size=self.config.batch_size,
113
+ batch_timeout_seconds=self.config.batch_timeout_seconds,
114
+ max_queue_size=self.config.max_queue_size,
115
+ flush_callback=self._flush_events,
116
+ config=self.config,
117
+ )
118
+
119
+ # Initialize patchers
120
+ self.openai_patcher = OpenAIPatcher(self)
121
+ self.anthropic_patcher = AnthropicPatcher(self)
122
+ self.gemini_patcher = GeminiPatcher(self)
123
+ self.patches = [] # List of successfully applied patches
124
+
125
+ # Start queue background thread
126
+ self.queue.start()
127
+
128
+ # Register shutdown hook
129
+ atexit.register(self.shutdown)
130
+
131
+ self.config.log("Scope Analytics SDK initialized")
132
+ self.config.log(f"Configuration: {self.config.to_dict()}")
133
+
134
+ # Register as global instance for middleware access
135
+ _set_sdk_instance(self)
136
+
137
+ # Auto-patch LLM libraries if enabled
138
+ if self.config.auto_patch:
139
+ self._apply_patches()
140
+
141
+ def _apply_patches(self):
142
+ """Apply monkey patches to LLM libraries"""
143
+ self.config.log("Auto-patching enabled - will patch LLM libraries")
144
+
145
+ # Patch OpenAI
146
+ try:
147
+ import openai
148
+ self.config.log("OpenAI library detected - patching...")
149
+ if self.openai_patcher.patch():
150
+ self.patches.append('openai')
151
+ except ImportError:
152
+ self.config.log("OpenAI library not installed - skipping patch")
153
+
154
+ # Patch Anthropic
155
+ try:
156
+ import anthropic
157
+ self.config.log("Anthropic library detected - patching...")
158
+ if self.anthropic_patcher.patch():
159
+ self.patches.append('anthropic')
160
+ except ImportError:
161
+ self.config.log("Anthropic library not installed - skipping patch")
162
+
163
+ # Patch Google Gemini
164
+ try:
165
+ import google.generativeai
166
+ self.config.log("Google Generative AI library detected - patching...")
167
+ if self.gemini_patcher.patch():
168
+ self.patches.append('gemini')
169
+ except ImportError:
170
+ self.config.log("Google Generative AI library not installed - skipping patch")
171
+
172
+ def track_event(self, event_type: str, properties: dict):
173
+ """
174
+ Manually track an event
175
+
176
+ Args:
177
+ event_type: Type of event (e.g., "llm_call", "external_api_call")
178
+ properties: Event properties
179
+ """
180
+ self.config.log(f"Tracking event: {event_type}")
181
+
182
+ # Create event with standard fields
183
+ event = {
184
+ "event_type": event_type,
185
+ "source": self.config.sdk_source,
186
+ **properties
187
+ }
188
+
189
+ # Validate and enqueue
190
+ if self.event_formatter.validate_event(event):
191
+ self.queue.enqueue(event)
192
+
193
+ def identify(self, user_id: str, traits: Optional[dict] = None):
194
+ """
195
+ Identify a user and optionally set traits
196
+
197
+ Args:
198
+ user_id: Unique user identifier
199
+ traits: Optional user traits (e.g., email, name, plan)
200
+ """
201
+ ScopeContext.set_user_id(user_id)
202
+ self.config.log(f"Identified user: {user_id}")
203
+
204
+ # TODO: Send identify event to API
205
+ if traits:
206
+ self.config.log(f"User traits: {traits}")
207
+
208
+ def _flush_events(self, events: list):
209
+ """
210
+ Callback for flushing events to API
211
+ Called by event queue when batch is ready
212
+
213
+ Args:
214
+ events: List of events to flush
215
+ """
216
+ success = self.client.ship_events(events)
217
+
218
+ if not success:
219
+ self.config.log(f"⚠️ Failed to ship {len(events)} events")
220
+
221
+ def shutdown(self):
222
+ """
223
+ Gracefully shutdown SDK
224
+ Flushes remaining events and cleans up resources
225
+ """
226
+ self.config.log("Shutting down Scope Analytics SDK...")
227
+
228
+ # Stop queue and flush remaining events
229
+ if self.queue:
230
+ self.queue.stop()
231
+
232
+ # Close HTTP client
233
+ if self.client:
234
+ self.client.close()
235
+
236
+ # Remove patches
237
+ if 'openai' in self.patches:
238
+ self.openai_patcher.unpatch()
239
+ if 'anthropic' in self.patches:
240
+ self.anthropic_patcher.unpatch()
241
+ if 'gemini' in self.patches:
242
+ self.gemini_patcher.unpatch()
243
+
244
+ self.config.log("Scope Analytics SDK shutdown complete")
@@ -0,0 +1,335 @@
1
+ """
2
+ Auto-instrumentation module for Scope Analytics
3
+
4
+ Import this module to automatically initialize Scope Analytics
5
+ with configuration from environment variables.
6
+
7
+ Usage:
8
+ import scope_analytics.auto # That's it!
9
+
10
+ Or via CLI:
11
+ scope-run python app.py
12
+
13
+ Environment Variables:
14
+ SCOPE_API_KEY: Required. Your Scope Analytics API key
15
+ SCOPE_ENDPOINT: Optional. Custom API endpoint
16
+ SCOPE_DEBUG: Optional. Set to 'true' for debug logging
17
+ SCOPE_ENVIRONMENT: Optional. Environment name (default: production)
18
+ """
19
+
20
+ import os
21
+ import sys
22
+ import warnings
23
+ import atexit
24
+
25
+ # Only initialize once
26
+ _initialized = False
27
+ _scope_instance = None
28
+
29
+
30
+ def _log(message: str) -> None:
31
+ """Log a message if debug mode is enabled"""
32
+ if os.environ.get('SCOPE_DEBUG', '').lower() == 'true':
33
+ print(f"[Scope SDK] {message}", file=sys.stderr)
34
+
35
+
36
+ def _auto_init():
37
+ """
38
+ Automatically initialize Scope Analytics from environment variables.
39
+
40
+ This function:
41
+ 1. Reads configuration from environment variables
42
+ 2. Initializes ScopeAnalytics with auto-patching enabled
43
+ 3. Sets up auto-middleware injection for known frameworks
44
+ """
45
+ global _initialized, _scope_instance
46
+
47
+ if _initialized:
48
+ return _scope_instance
49
+
50
+ _initialized = True # Set early to prevent re-entry
51
+
52
+ api_key = os.environ.get('SCOPE_API_KEY')
53
+
54
+ if not api_key:
55
+ # No API key - skip initialization with a warning
56
+ warnings.warn(
57
+ "SCOPE_API_KEY not set - Scope Analytics disabled. "
58
+ "Set SCOPE_API_KEY environment variable to enable tracking.",
59
+ UserWarning,
60
+ stacklevel=2
61
+ )
62
+ _log("No API key found - instrumentation disabled")
63
+ return None
64
+
65
+ try:
66
+ from scope_analytics import ScopeAnalytics
67
+
68
+ _log("Initializing Scope Analytics...")
69
+
70
+ _scope_instance = ScopeAnalytics(
71
+ api_key=api_key,
72
+ endpoint=os.environ.get('SCOPE_ENDPOINT'),
73
+ debug=os.environ.get('SCOPE_DEBUG', '').lower() == 'true',
74
+ environment=os.environ.get('SCOPE_ENVIRONMENT', 'production'),
75
+ auto_patch=True, # Always auto-patch in auto mode
76
+ )
77
+
78
+ _log("Scope Analytics initialized successfully")
79
+
80
+ # Auto-inject session middleware for known frameworks
81
+ _auto_inject_middleware()
82
+
83
+ return _scope_instance
84
+
85
+ except ValueError as e:
86
+ # Invalid API key format or missing required config
87
+ warnings.warn(
88
+ f"Scope Analytics initialization failed: {e}",
89
+ UserWarning,
90
+ stacklevel=2
91
+ )
92
+ _log(f"Initialization failed: {e}")
93
+ return None
94
+
95
+ except Exception as e:
96
+ # Unexpected error - don't crash the user's app
97
+ warnings.warn(
98
+ f"Scope Analytics initialization failed unexpectedly: {e}",
99
+ UserWarning,
100
+ stacklevel=2
101
+ )
102
+ _log(f"Unexpected error during initialization: {e}")
103
+ return None
104
+
105
+
106
+ def _auto_inject_middleware():
107
+ """
108
+ Automatically inject session middleware for known frameworks.
109
+ This happens at RUNTIME - no user code changes required.
110
+
111
+ Supports: FastAPI/Starlette, Flask, Django
112
+
113
+ The middleware extracts X-Scope-Session-ID headers and makes
114
+ session correlation work automatically between frontend and backend.
115
+ """
116
+ _log("Setting up auto-middleware injection...")
117
+
118
+ injected = False
119
+
120
+ # Try FastAPI/Starlette
121
+ if _try_inject_fastapi():
122
+ _log("Prepared session middleware injection for FastAPI/Starlette")
123
+ injected = True
124
+
125
+ # Try Flask
126
+ if _try_inject_flask():
127
+ _log("Prepared session hooks injection for Flask")
128
+ injected = True
129
+
130
+ # Try Django
131
+ if _try_inject_django():
132
+ _log("Prepared session middleware injection for Django")
133
+ injected = True
134
+
135
+ if not injected:
136
+ _log(
137
+ "No known framework detected - session middleware not auto-injected. "
138
+ "LLM tracking still works. For session correlation with custom frameworks, "
139
+ "see docs on manual ScopeContext.set_session_id()"
140
+ )
141
+
142
+
143
+ def _try_inject_fastapi() -> bool:
144
+ """
145
+ Inject session middleware into FastAPI/Starlette apps at runtime.
146
+
147
+ We patch the __init__ method of FastAPI and Starlette classes
148
+ so that when the user creates an app, our middleware is automatically added.
149
+ """
150
+ patched = False
151
+
152
+ # Try patching Starlette first (FastAPI extends it)
153
+ try:
154
+ from starlette.applications import Starlette
155
+
156
+ if not hasattr(Starlette.__init__, '_scope_patched'):
157
+ original_starlette_init = Starlette.__init__
158
+
159
+ def patched_starlette_init(self, *args, **kwargs):
160
+ original_starlette_init(self, *args, **kwargs)
161
+ # Add our middleware after app is initialized
162
+ try:
163
+ from scope_analytics.middleware import ScopeSessionMiddleware
164
+ self.add_middleware(ScopeSessionMiddleware)
165
+ _log(f"Auto-injected ScopeSessionMiddleware into Starlette app")
166
+ except Exception as e:
167
+ _log(f"Failed to inject middleware into Starlette: {e}")
168
+
169
+ patched_starlette_init._scope_patched = True
170
+ Starlette.__init__ = patched_starlette_init
171
+ patched = True
172
+ _log("Patched Starlette.__init__ for auto-middleware injection")
173
+
174
+ except ImportError:
175
+ pass
176
+
177
+ # Also patch FastAPI specifically (more common)
178
+ try:
179
+ from fastapi import FastAPI
180
+
181
+ if not hasattr(FastAPI.__init__, '_scope_patched'):
182
+ original_fastapi_init = FastAPI.__init__
183
+
184
+ def patched_fastapi_init(self, *args, **kwargs):
185
+ original_fastapi_init(self, *args, **kwargs)
186
+ # Add our middleware after app is initialized
187
+ try:
188
+ from scope_analytics.middleware import ScopeSessionMiddleware
189
+ # Check if middleware already added (from Starlette patch)
190
+ has_scope_middleware = any(
191
+ getattr(m, 'cls', None) == ScopeSessionMiddleware
192
+ for m in getattr(self, 'user_middleware', [])
193
+ )
194
+ if not has_scope_middleware:
195
+ self.add_middleware(ScopeSessionMiddleware)
196
+ _log(f"Auto-injected ScopeSessionMiddleware into FastAPI app")
197
+ except Exception as e:
198
+ _log(f"Failed to inject middleware into FastAPI: {e}")
199
+
200
+ patched_fastapi_init._scope_patched = True
201
+ FastAPI.__init__ = patched_fastapi_init
202
+ patched = True
203
+ _log("Patched FastAPI.__init__ for auto-middleware injection")
204
+
205
+ except ImportError:
206
+ pass
207
+
208
+ return patched
209
+
210
+
211
+ def _try_inject_flask() -> bool:
212
+ """
213
+ Inject session hooks into Flask apps at runtime.
214
+
215
+ We patch Flask's __init__ to register before/after request hooks
216
+ that handle session ID extraction.
217
+ """
218
+ try:
219
+ from flask import Flask
220
+
221
+ if hasattr(Flask.__init__, '_scope_patched'):
222
+ return True
223
+
224
+ original_flask_init = Flask.__init__
225
+
226
+ def patched_flask_init(self, *args, **kwargs):
227
+ original_flask_init(self, *args, **kwargs)
228
+ # Register session tracking hooks after app is created
229
+ try:
230
+ from scope_analytics.middleware import init_flask_session_tracking
231
+ init_flask_session_tracking(self)
232
+ _log(f"Auto-injected session hooks into Flask app")
233
+ except Exception as e:
234
+ _log(f"Failed to inject session hooks into Flask: {e}")
235
+
236
+ patched_flask_init._scope_patched = True
237
+ Flask.__init__ = patched_flask_init
238
+ _log("Patched Flask.__init__ for auto-middleware injection")
239
+ return True
240
+
241
+ except ImportError:
242
+ return False
243
+
244
+
245
+ def _try_inject_django() -> bool:
246
+ """
247
+ Inject session middleware into Django apps at runtime.
248
+
249
+ Django is trickier - middleware is configured in settings.py.
250
+ We hook into Django's setup to add our middleware.
251
+ """
252
+ try:
253
+ import django
254
+ from django.conf import settings
255
+
256
+ # Check if Django is configured
257
+ if not settings.configured:
258
+ # Django not configured yet - set up a hook for when it is
259
+ _log("Django detected but not configured - deferring middleware injection")
260
+
261
+ # We can hook into django.setup() to add middleware when called
262
+ if not hasattr(django.setup, '_scope_patched'):
263
+ original_setup = django.setup
264
+
265
+ def patched_setup(*args, **kwargs):
266
+ result = original_setup(*args, **kwargs)
267
+ _inject_django_middleware()
268
+ return result
269
+
270
+ patched_setup._scope_patched = True
271
+ django.setup = patched_setup
272
+ _log("Patched django.setup() for deferred middleware injection")
273
+
274
+ return True
275
+
276
+ # Django is already configured - inject middleware now
277
+ return _inject_django_middleware()
278
+
279
+ except ImportError:
280
+ return False
281
+ except Exception as e:
282
+ _log(f"Error setting up Django middleware injection: {e}")
283
+ return False
284
+
285
+
286
+ def _inject_django_middleware() -> bool:
287
+ """
288
+ Actually inject the middleware into Django's MIDDLEWARE setting.
289
+ """
290
+ try:
291
+ from django.conf import settings
292
+
293
+ if not settings.configured:
294
+ return False
295
+
296
+ middleware_class = 'scope_analytics.middleware.DjangoScopeMiddleware'
297
+
298
+ if hasattr(settings, 'MIDDLEWARE'):
299
+ middleware_list = list(settings.MIDDLEWARE)
300
+ if middleware_class not in middleware_list:
301
+ # Insert at the beginning for earliest access to request
302
+ middleware_list.insert(0, middleware_class)
303
+ settings.MIDDLEWARE = middleware_list
304
+ _log(f"Auto-injected {middleware_class} into Django MIDDLEWARE")
305
+ return True
306
+
307
+ return False
308
+
309
+ except Exception as e:
310
+ _log(f"Failed to inject Django middleware: {e}")
311
+ return False
312
+
313
+
314
+ def get_instance():
315
+ """
316
+ Get the auto-initialized ScopeAnalytics instance.
317
+
318
+ Returns:
319
+ ScopeAnalytics instance or None if not initialized
320
+ """
321
+ return _scope_instance
322
+
323
+
324
+ def is_initialized() -> bool:
325
+ """
326
+ Check if auto-instrumentation has been initialized.
327
+
328
+ Returns:
329
+ True if initialized, False otherwise
330
+ """
331
+ return _initialized and _scope_instance is not None
332
+
333
+
334
+ # Auto-initialize on import
335
+ _auto_init()