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.
scope_analytics/cli.py ADDED
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ scope-run: No-code instrumentation CLI for Scope Analytics
4
+
5
+ Usage:
6
+ scope-run python app.py
7
+ scope-run uvicorn main:app --reload
8
+ scope-run gunicorn app:app -w 4
9
+ scope-run flask run --port 5000
10
+ scope-run celery -A tasks worker
11
+
12
+ This CLI wrapper automatically instruments your Python application
13
+ with Scope Analytics tracking - no code changes required.
14
+
15
+ Environment Variables:
16
+ SCOPE_API_KEY: Required. Your Scope Analytics API key
17
+ SCOPE_ENDPOINT: Optional. Custom API endpoint
18
+ SCOPE_DEBUG: Optional. Set to 'true' for debug logging
19
+ SCOPE_ENVIRONMENT: Optional. Environment name (default: production)
20
+ """
21
+
22
+ import os
23
+ import sys
24
+ import subprocess
25
+ import argparse
26
+ import tempfile
27
+ import atexit
28
+ from typing import List, Optional
29
+
30
+ # Track temp files for cleanup
31
+ _temp_files: List[str] = []
32
+
33
+
34
+ def _cleanup_temp_files():
35
+ """Clean up temporary bootstrap files on exit"""
36
+ for path in _temp_files:
37
+ try:
38
+ os.unlink(path)
39
+ except (OSError, FileNotFoundError):
40
+ pass
41
+
42
+
43
+ atexit.register(_cleanup_temp_files)
44
+
45
+
46
+ def _create_bootstrap_file() -> str:
47
+ """
48
+ Create a temporary bootstrap file that imports scope_analytics.auto
49
+
50
+ This file is used for PYTHONSTARTUP to ensure instrumentation
51
+ happens before the user's code runs.
52
+ """
53
+ bootstrap_code = '''# Scope Analytics Auto-Bootstrap
54
+ import os
55
+ if os.environ.get('SCOPE_AUTO_INSTRUMENT') == 'true':
56
+ try:
57
+ import scope_analytics.auto
58
+ except Exception as e:
59
+ import sys
60
+ print(f"[Scope SDK] Warning: Auto-instrumentation failed: {e}", file=sys.stderr)
61
+ '''
62
+
63
+ fd, path = tempfile.mkstemp(suffix='.py', prefix='scope_bootstrap_')
64
+ with os.fdopen(fd, 'w') as f:
65
+ f.write(bootstrap_code)
66
+
67
+ _temp_files.append(path)
68
+ return path
69
+
70
+
71
+ def _create_sitecustomize_dir() -> str:
72
+ """
73
+ Create a temporary directory with sitecustomize.py for auto-instrumentation.
74
+
75
+ sitecustomize.py is automatically imported by Python at startup,
76
+ making it ideal for pre-application instrumentation.
77
+ """
78
+ import tempfile
79
+
80
+ # Create temp directory
81
+ temp_dir = tempfile.mkdtemp(prefix='scope_site_')
82
+ _temp_files.append(temp_dir)
83
+
84
+ # Create sitecustomize.py
85
+ sitecustomize_path = os.path.join(temp_dir, 'sitecustomize.py')
86
+ sitecustomize_code = '''# Scope Analytics Sitecustomize
87
+ import os
88
+ if os.environ.get('SCOPE_AUTO_INSTRUMENT') == 'true':
89
+ try:
90
+ import scope_analytics.auto
91
+ except ImportError:
92
+ pass # scope-analytics not installed, skip silently
93
+ except Exception as e:
94
+ import sys
95
+ print(f"[Scope SDK] Warning: Auto-instrumentation failed: {e}", file=sys.stderr)
96
+ '''
97
+
98
+ with open(sitecustomize_path, 'w') as f:
99
+ f.write(sitecustomize_code)
100
+
101
+ return temp_dir
102
+
103
+
104
+ def _get_version() -> str:
105
+ """Get the SDK version"""
106
+ try:
107
+ from scope_analytics import __version__
108
+ return __version__
109
+ except ImportError:
110
+ return "unknown"
111
+
112
+
113
+ def main(argv: Optional[List[str]] = None) -> int:
114
+ """
115
+ Main entry point for scope-run CLI
116
+
117
+ Args:
118
+ argv: Command line arguments (defaults to sys.argv[1:])
119
+
120
+ Returns:
121
+ Exit code from the wrapped command
122
+ """
123
+ parser = argparse.ArgumentParser(
124
+ prog='scope-run',
125
+ description='Run Python application with Scope Analytics auto-instrumentation',
126
+ epilog='''
127
+ Examples:
128
+ scope-run python app.py # Run a Python script
129
+ scope-run uvicorn main:app --reload # Run uvicorn server
130
+ scope-run gunicorn app:app -w 4 # Run gunicorn server
131
+ scope-run flask run --port 5000 # Run Flask dev server
132
+ scope-run python -m mypackage # Run a module
133
+
134
+ Environment Variables:
135
+ SCOPE_API_KEY Your Scope Analytics API key (required)
136
+ SCOPE_ENDPOINT Custom API endpoint (optional)
137
+ SCOPE_DEBUG Set to 'true' for debug logging
138
+ SCOPE_ENVIRONMENT Environment name (default: production)
139
+ ''',
140
+ formatter_class=argparse.RawDescriptionHelpFormatter
141
+ )
142
+
143
+ parser.add_argument(
144
+ '--version', '-v',
145
+ action='store_true',
146
+ help='Show version and exit'
147
+ )
148
+
149
+ parser.add_argument(
150
+ '--debug', '-d',
151
+ action='store_true',
152
+ help='Enable debug logging'
153
+ )
154
+
155
+ parser.add_argument(
156
+ '--dry-run',
157
+ action='store_true',
158
+ help='Show what would be executed without running'
159
+ )
160
+
161
+ parser.add_argument(
162
+ 'command',
163
+ nargs=argparse.REMAINDER,
164
+ help='Command to run (e.g., python app.py, uvicorn main:app)'
165
+ )
166
+
167
+ args = parser.parse_args(argv)
168
+
169
+ # Handle --version
170
+ if args.version:
171
+ print(f"scope-analytics {_get_version()}")
172
+ return 0
173
+
174
+ # Require a command
175
+ if not args.command:
176
+ parser.print_help()
177
+ print("\nError: No command specified. Usage: scope-run python app.py", file=sys.stderr)
178
+ return 1
179
+
180
+ # Check for API key
181
+ api_key = os.environ.get('SCOPE_API_KEY')
182
+ if not api_key:
183
+ print("[Scope SDK] Warning: SCOPE_API_KEY not set. Instrumentation will be disabled.", file=sys.stderr)
184
+ print("[Scope SDK] Set your API key: export SCOPE_API_KEY='sk_live_...'", file=sys.stderr)
185
+
186
+ # Set up environment for auto-instrumentation
187
+ env = os.environ.copy()
188
+ env['SCOPE_AUTO_INSTRUMENT'] = 'true'
189
+
190
+ if args.debug:
191
+ env['SCOPE_DEBUG'] = 'true'
192
+
193
+ # Determine the best injection strategy based on the command
194
+ command = args.command
195
+ executable = command[0] if command else ''
196
+
197
+ # Use sitecustomize approach - works with all Python programs
198
+ site_dir = _create_sitecustomize_dir()
199
+
200
+ # Prepend to PYTHONPATH so sitecustomize.py is found
201
+ existing_path = env.get('PYTHONPATH', '')
202
+ if existing_path:
203
+ env['PYTHONPATH'] = f"{site_dir}{os.pathsep}{existing_path}"
204
+ else:
205
+ env['PYTHONPATH'] = site_dir
206
+
207
+ # Build the final command
208
+ final_command = command
209
+
210
+ if args.dry_run:
211
+ print("[Scope SDK] Dry run - would execute:")
212
+ print(f" SCOPE_AUTO_INSTRUMENT=true")
213
+ print(f" PYTHONPATH={env['PYTHONPATH']}")
214
+ if args.debug:
215
+ print(f" SCOPE_DEBUG=true")
216
+ print(f" {' '.join(final_command)}")
217
+ return 0
218
+
219
+ # Print startup message if debug enabled
220
+ if args.debug or env.get('SCOPE_DEBUG', '').lower() == 'true':
221
+ print(f"[Scope SDK] Starting with auto-instrumentation...")
222
+ print(f"[Scope SDK] Command: {' '.join(final_command)}")
223
+
224
+ # Execute the wrapped command
225
+ try:
226
+ result = subprocess.run(final_command, env=env)
227
+ return result.returncode
228
+ except FileNotFoundError:
229
+ print(f"[Scope SDK] Error: Command not found: {executable}", file=sys.stderr)
230
+ return 127
231
+ except KeyboardInterrupt:
232
+ print("\n[Scope SDK] Interrupted")
233
+ return 130
234
+ except Exception as e:
235
+ print(f"[Scope SDK] Error executing command: {e}", file=sys.stderr)
236
+ return 1
237
+
238
+
239
+ if __name__ == '__main__':
240
+ sys.exit(main())
@@ -0,0 +1,121 @@
1
+ """
2
+ HTTP client for shipping events to Scope Analytics API
3
+ Handles async communication with the backend API
4
+ """
5
+
6
+ import httpx
7
+ from typing import List, Dict, Any
8
+
9
+
10
+ class ScopeAPIClient:
11
+ """
12
+ Async HTTP client for Scope Analytics API
13
+
14
+ Handles:
15
+ - Event shipping to /api/events endpoint
16
+ - Authentication with secret API key
17
+ - Retry logic with exponential backoff
18
+ - Error handling
19
+ """
20
+
21
+ def __init__(self, config):
22
+ """
23
+ Initialize API client
24
+
25
+ Args:
26
+ config: SDK configuration
27
+ """
28
+ self.config = config
29
+ self.endpoint = f"{config.endpoint}/api/events"
30
+
31
+ # Create HTTP client
32
+ self.client = httpx.Client(
33
+ timeout=30.0,
34
+ headers={
35
+ "Authorization": f"Bearer {config.api_key}",
36
+ "Content-Type": "application/json",
37
+ "User-Agent": f"scope-analytics-python/{config.sdk_version}",
38
+ }
39
+ )
40
+
41
+ self.config.log(f"API client initialized: {self.endpoint}")
42
+
43
+ def ship_events(self, events: List[Dict[str, Any]]) -> bool:
44
+ """
45
+ Ship batch of events to API
46
+
47
+ Args:
48
+ events: List of event dictionaries
49
+
50
+ Returns:
51
+ True if successful, False otherwise
52
+ """
53
+ if not events:
54
+ return True
55
+
56
+ try:
57
+ self.config.log(f"Shipping {len(events)} events to {self.endpoint}")
58
+
59
+ # Prepare payload
60
+ payload = {
61
+ "events": events,
62
+ "source": self.config.sdk_source,
63
+ }
64
+
65
+ # Send POST request
66
+ response = self.client.post(
67
+ self.endpoint,
68
+ json=payload
69
+ )
70
+
71
+ # Check response
72
+ if response.status_code == 200:
73
+ self.config.log(f"✅ Successfully shipped {len(events)} events")
74
+ return True
75
+ else:
76
+ self.config.log(
77
+ f"❌ Failed to ship events: HTTP {response.status_code} - {response.text}"
78
+ )
79
+ return False
80
+
81
+ except httpx.TimeoutException:
82
+ self.config.log("❌ Request timeout while shipping events")
83
+ return False
84
+
85
+ except httpx.HTTPError as e:
86
+ self.config.log(f"❌ HTTP error while shipping events: {e}")
87
+ return False
88
+
89
+ except Exception as e:
90
+ self.config.log(f"❌ Unexpected error while shipping events: {e}")
91
+ return False
92
+
93
+ def test_connection(self) -> bool:
94
+ """
95
+ Test connection to API
96
+
97
+ Returns:
98
+ True if connection successful, False otherwise
99
+ """
100
+ try:
101
+ self.config.log("Testing API connection...")
102
+
103
+ # Simple health check (could be a ping endpoint)
104
+ # For now, just verify we can reach the endpoint
105
+ response = self.client.get(f"{self.config.endpoint}/health")
106
+
107
+ if response.status_code == 200:
108
+ self.config.log("✅ API connection successful")
109
+ return True
110
+ else:
111
+ self.config.log(f"⚠️ API returned status {response.status_code}")
112
+ return False
113
+
114
+ except Exception as e:
115
+ self.config.log(f"⚠️ Could not connect to API: {e}")
116
+ return False
117
+
118
+ def close(self):
119
+ """Close HTTP client"""
120
+ self.client.close()
121
+ self.config.log("API client closed")
@@ -0,0 +1,101 @@
1
+ """
2
+ Configuration management for Scope Analytics SDK
3
+ """
4
+
5
+ import os
6
+ from typing import Optional, Dict, Any
7
+ from dotenv import load_dotenv
8
+
9
+ # Load environment variables
10
+ load_dotenv()
11
+
12
+
13
+ class ScopeConfig:
14
+ """Configuration for Scope Analytics SDK"""
15
+
16
+ def __init__(
17
+ self,
18
+ api_key: Optional[str] = None,
19
+ endpoint: Optional[str] = None,
20
+ auto_patch: bool = True,
21
+ batch_size: int = 10,
22
+ batch_timeout_seconds: int = 5,
23
+ max_queue_size: int = 1000,
24
+ debug: bool = False,
25
+ environment: Optional[str] = None,
26
+ redact_patterns: Optional[list] = None,
27
+ ):
28
+ """
29
+ Initialize Scope SDK configuration
30
+
31
+ Args:
32
+ api_key: Secret API key (sk_live_... or sk_test_...)
33
+ endpoint: API endpoint URL
34
+ auto_patch: Whether to automatically patch LLM libraries
35
+ batch_size: Number of events to batch before sending
36
+ batch_timeout_seconds: Maximum time to wait before sending partial batch
37
+ max_queue_size: Maximum events to queue (oldest dropped if exceeded)
38
+ debug: Enable debug logging
39
+ environment: Environment name (production, staging, development)
40
+ redact_patterns: List of regex patterns to redact from events
41
+ """
42
+ # API Key - required
43
+ self.api_key = api_key or os.getenv("SCOPE_API_KEY")
44
+ if not self.api_key:
45
+ raise ValueError(
46
+ "API key is required. Pass api_key parameter or set SCOPE_API_KEY environment variable."
47
+ )
48
+
49
+ # Validate API key format
50
+ if not (self.api_key.startswith("sk_live_") or self.api_key.startswith("sk_test_")):
51
+ raise ValueError(
52
+ "Invalid API key format. Backend SDK requires secret keys starting with 'sk_live_' or 'sk_test_'."
53
+ )
54
+
55
+ # Endpoint
56
+ self.endpoint = endpoint or os.getenv(
57
+ "SCOPE_ENDPOINT",
58
+ "https://api.scopeai.dev"
59
+ )
60
+
61
+ # Patching
62
+ self.auto_patch = auto_patch
63
+
64
+ # Batching
65
+ self.batch_size = batch_size
66
+ self.batch_timeout_seconds = batch_timeout_seconds
67
+ self.max_queue_size = max_queue_size
68
+
69
+ # Environment
70
+ self.debug = debug or os.getenv("SCOPE_DEBUG", "").lower() == "true"
71
+ self.environment = environment or os.getenv("SCOPE_ENVIRONMENT", "production")
72
+
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
+ ]
80
+
81
+ # SDK metadata
82
+ self.sdk_version = "0.1.0"
83
+ self.sdk_source = "backend_sdk"
84
+
85
+ def to_dict(self) -> Dict[str, Any]:
86
+ """Convert config to dictionary"""
87
+ return {
88
+ "endpoint": self.endpoint,
89
+ "auto_patch": self.auto_patch,
90
+ "batch_size": self.batch_size,
91
+ "batch_timeout_seconds": self.batch_timeout_seconds,
92
+ "max_queue_size": self.max_queue_size,
93
+ "debug": self.debug,
94
+ "environment": self.environment,
95
+ "sdk_version": self.sdk_version,
96
+ }
97
+
98
+ def log(self, message: str):
99
+ """Log debug message if debug mode enabled"""
100
+ if self.debug:
101
+ print(f"[Scope SDK] {message}")
@@ -0,0 +1,173 @@
1
+ """
2
+ Context management for Scope Analytics SDK using contextvars
3
+ Handles session ID propagation across async boundaries
4
+ """
5
+
6
+ import contextvars
7
+ from typing import Optional
8
+ from contextlib import contextmanager
9
+ import uuid
10
+
11
+ # Context variables for tracking current request context
12
+ session_id_context: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
13
+ 'scope_session_id', default=None
14
+ )
15
+
16
+ user_id_context: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
17
+ 'scope_user_id', default=None
18
+ )
19
+
20
+ # Recursion guard - prevents infinite loops when Scope tracks its own LLM calls
21
+ # This is critical when Scope AI backend uses Scope SDK to track itself
22
+ _in_scope_context: contextvars.ContextVar[bool] = contextvars.ContextVar(
23
+ 'scope_in_scope_context', default=False
24
+ )
25
+
26
+
27
+ class ScopeContext:
28
+ """
29
+ Manages request-scoped context using Python's contextvars.
30
+
31
+ This ensures session IDs are properly isolated across concurrent requests
32
+ and propagated through async operations.
33
+ """
34
+
35
+ @staticmethod
36
+ def set_session_id(session_id: str) -> None:
37
+ """
38
+ Set the session ID for the current context
39
+
40
+ Args:
41
+ session_id: Session ID from X-Scope-Session-ID header
42
+ """
43
+ session_id_context.set(session_id)
44
+
45
+ @staticmethod
46
+ def get_session_id() -> Optional[str]:
47
+ """
48
+ Get the session ID from current context
49
+
50
+ Returns:
51
+ Session ID if set, None otherwise
52
+ """
53
+ return session_id_context.get()
54
+
55
+ @staticmethod
56
+ def set_user_id(user_id: str) -> None:
57
+ """
58
+ Set the user ID for the current context
59
+
60
+ Args:
61
+ user_id: User ID from backend application
62
+ """
63
+ user_id_context.set(user_id)
64
+
65
+ @staticmethod
66
+ def get_user_id() -> Optional[str]:
67
+ """
68
+ Get the user ID from current context
69
+
70
+ Returns:
71
+ User ID if set, None otherwise
72
+ """
73
+ return user_id_context.get()
74
+
75
+ @staticmethod
76
+ def clear() -> None:
77
+ """
78
+ Clear all context variables
79
+ Should be called after request completes to prevent leaks
80
+ """
81
+ session_id_context.set(None)
82
+ user_id_context.set(None)
83
+
84
+ @staticmethod
85
+ def generate_temp_session_id() -> str:
86
+ """
87
+ Generate a temporary session ID for requests without X-Scope-Session-ID header
88
+
89
+ Returns:
90
+ Temporary session ID with 'temp_' prefix
91
+ """
92
+ return f"temp_{uuid.uuid4().hex[:16]}"
93
+
94
+ @staticmethod
95
+ def ensure_session_id() -> str:
96
+ """
97
+ Get session ID from context, or generate temporary one if missing
98
+
99
+ Returns:
100
+ Session ID (either from context or temporary)
101
+ """
102
+ session_id = session_id_context.get()
103
+ if not session_id:
104
+ session_id = ScopeContext.generate_temp_session_id()
105
+ session_id_context.set(session_id)
106
+ return session_id
107
+
108
+ @staticmethod
109
+ def get_context_dict() -> dict:
110
+ """
111
+ Get all context variables as dictionary
112
+
113
+ Returns:
114
+ Dictionary with current context values
115
+ """
116
+ return {
117
+ "session_id": session_id_context.get(),
118
+ "user_id": user_id_context.get(),
119
+ }
120
+
121
+ # =========================================================================
122
+ # Recursion Guard - Prevents infinite loops when Scope tracks itself
123
+ # =========================================================================
124
+
125
+ @staticmethod
126
+ def is_in_scope_context() -> bool:
127
+ """
128
+ Check if we're currently inside Scope SDK code.
129
+
130
+ This prevents infinite recursion when:
131
+ 1. Scope backend uses OpenAI for embeddings
132
+ 2. Scope backend has Scope SDK installed
133
+ 3. Without this guard, embedding calls would be captured → sent to API → trigger more embeddings
134
+
135
+ Returns:
136
+ True if inside Scope context, False otherwise
137
+ """
138
+ return _in_scope_context.get()
139
+
140
+ @staticmethod
141
+ def enter_scope_context() -> None:
142
+ """
143
+ Mark that we're entering Scope SDK internal code.
144
+ LLM calls made while in this context will NOT be captured.
145
+ """
146
+ _in_scope_context.set(True)
147
+
148
+ @staticmethod
149
+ def exit_scope_context() -> None:
150
+ """
151
+ Mark that we're exiting Scope SDK internal code.
152
+ """
153
+ _in_scope_context.set(False)
154
+
155
+ @staticmethod
156
+ @contextmanager
157
+ def scope_internal():
158
+ """
159
+ Context manager for Scope internal operations.
160
+
161
+ Usage:
162
+ with ScopeContext.scope_internal():
163
+ # LLM calls here will NOT be tracked
164
+ embedding = openai.embeddings.create(...)
165
+
166
+ This is used by Scope's own backend to prevent recursion.
167
+ """
168
+ previous = _in_scope_context.get()
169
+ _in_scope_context.set(True)
170
+ try:
171
+ yield
172
+ finally:
173
+ _in_scope_context.set(previous)