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/__init__.py +244 -0
- scope_analytics/auto.py +335 -0
- scope_analytics/cli.py +240 -0
- scope_analytics/client.py +121 -0
- scope_analytics/config.py +101 -0
- scope_analytics/context.py +173 -0
- scope_analytics/events.py +275 -0
- scope_analytics/middleware.py +669 -0
- scope_analytics/patches/__init__.py +9 -0
- scope_analytics/patches/anthropic_patch.py +430 -0
- scope_analytics/patches/gemini_patch.py +422 -0
- scope_analytics/patches/openai_patch.py +483 -0
- scope_analytics/queue.py +158 -0
- scope_analytics-0.1.0.dist-info/METADATA +253 -0
- scope_analytics-0.1.0.dist-info/RECORD +19 -0
- scope_analytics-0.1.0.dist-info/WHEEL +5 -0
- scope_analytics-0.1.0.dist-info/entry_points.txt +2 -0
- scope_analytics-0.1.0.dist-info/licenses/LICENSE +21 -0
- scope_analytics-0.1.0.dist-info/top_level.txt +1 -0
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)
|