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
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP Middleware for Scope Analytics SDK
|
|
3
|
+
|
|
4
|
+
Automatically extracts session ID from X-Scope-Session-ID header
|
|
5
|
+
and sets it in the context for all LLM calls to be linked.
|
|
6
|
+
|
|
7
|
+
Also tracks ALL incoming HTTP requests to enable analysis of
|
|
8
|
+
API call patterns (e.g., "what did users do before cancelling?").
|
|
9
|
+
|
|
10
|
+
Auto-JWT Extraction (Identity Stitching):
|
|
11
|
+
- Automatically extracts user_id from JWT tokens in Authorization header
|
|
12
|
+
- Enables identity stitching WITHOUT any user code changes
|
|
13
|
+
- Works with standard JWT claims: sub, user_id, uid, id
|
|
14
|
+
- Manual ScopeContext.set_user_id() takes priority if set
|
|
15
|
+
|
|
16
|
+
Supports:
|
|
17
|
+
- FastAPI (ASGI middleware)
|
|
18
|
+
- Flask (WSGI middleware)
|
|
19
|
+
- Django middleware
|
|
20
|
+
- Generic ASGI/WSGI applications
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import time
|
|
25
|
+
import base64
|
|
26
|
+
import json
|
|
27
|
+
from typing import Optional, Callable, Any
|
|
28
|
+
from .context import ScopeContext
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("scope_analytics.middleware")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_sdk():
|
|
34
|
+
"""
|
|
35
|
+
Get the global SDK instance for HTTP request tracking.
|
|
36
|
+
Imported lazily to avoid circular imports.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
from . import get_sdk_instance
|
|
40
|
+
return get_sdk_instance()
|
|
41
|
+
except ImportError:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _track_http_request(
|
|
46
|
+
method: str,
|
|
47
|
+
path: str,
|
|
48
|
+
status_code: int,
|
|
49
|
+
latency_ms: float,
|
|
50
|
+
query_params: Optional[dict] = None,
|
|
51
|
+
client_ip: Optional[str] = None,
|
|
52
|
+
user_agent: Optional[str] = None,
|
|
53
|
+
content_type: Optional[str] = None,
|
|
54
|
+
content_length: Optional[int] = None,
|
|
55
|
+
):
|
|
56
|
+
"""
|
|
57
|
+
Track an HTTP request event via the global SDK instance.
|
|
58
|
+
"""
|
|
59
|
+
sdk = _get_sdk()
|
|
60
|
+
if sdk is None:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
event = sdk.event_formatter.format_http_request(
|
|
65
|
+
method=method,
|
|
66
|
+
path=path,
|
|
67
|
+
status_code=status_code,
|
|
68
|
+
latency_ms=latency_ms,
|
|
69
|
+
query_params=query_params,
|
|
70
|
+
client_ip=client_ip,
|
|
71
|
+
user_agent=user_agent,
|
|
72
|
+
content_type=content_type,
|
|
73
|
+
content_length=content_length,
|
|
74
|
+
)
|
|
75
|
+
sdk.queue.enqueue(event)
|
|
76
|
+
sdk.config.log(f"Captured HTTP request: {method} {path} -> {status_code}")
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.warning(f"Failed to track HTTP request: {e}")
|
|
79
|
+
|
|
80
|
+
# Header name for session ID (case-insensitive in HTTP)
|
|
81
|
+
SESSION_HEADER = "X-Scope-Session-ID"
|
|
82
|
+
SESSION_HEADER_LOWER = "x-scope-session-id"
|
|
83
|
+
|
|
84
|
+
# Standard JWT claims that may contain user ID
|
|
85
|
+
JWT_USER_ID_CLAIMS = ["sub", "user_id", "uid", "id", "userId", "user"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _extract_user_id_from_jwt(auth_header: Optional[str]) -> Optional[str]:
|
|
89
|
+
"""
|
|
90
|
+
Extract user_id from JWT token in Authorization header.
|
|
91
|
+
|
|
92
|
+
This enables automatic identity stitching without any user code changes.
|
|
93
|
+
Works with standard JWT claims: sub, user_id, uid, id, userId, user
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
auth_header: Authorization header value (e.g., "Bearer eyJhbG...")
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
User ID if found in JWT, None otherwise
|
|
100
|
+
"""
|
|
101
|
+
if not auth_header:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
# Check for Bearer token
|
|
105
|
+
if not auth_header.startswith("Bearer "):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
token = auth_header[7:] # Remove "Bearer " prefix
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
# JWT format: header.payload.signature
|
|
112
|
+
parts = token.split(".")
|
|
113
|
+
if len(parts) != 3:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
# Decode payload (middle part)
|
|
117
|
+
# Add padding if needed (JWT base64url doesn't include padding)
|
|
118
|
+
payload_b64 = parts[1]
|
|
119
|
+
padding = 4 - len(payload_b64) % 4
|
|
120
|
+
if padding != 4:
|
|
121
|
+
payload_b64 += "=" * padding
|
|
122
|
+
|
|
123
|
+
# Use base64url decoding (replace - with + and _ with /)
|
|
124
|
+
payload_b64 = payload_b64.replace("-", "+").replace("_", "/")
|
|
125
|
+
payload_bytes = base64.b64decode(payload_b64)
|
|
126
|
+
payload = json.loads(payload_bytes.decode("utf-8"))
|
|
127
|
+
|
|
128
|
+
# Look for user ID in standard claims
|
|
129
|
+
for claim in JWT_USER_ID_CLAIMS:
|
|
130
|
+
if claim in payload:
|
|
131
|
+
user_id = payload[claim]
|
|
132
|
+
# Convert to string if needed
|
|
133
|
+
if user_id is not None:
|
|
134
|
+
return str(user_id)
|
|
135
|
+
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
except Exception as e:
|
|
139
|
+
# Not a valid JWT or couldn't decode - silently skip
|
|
140
|
+
# This is expected for non-JWT auth or malformed tokens
|
|
141
|
+
logger.debug(f"Could not extract user_id from JWT: {e}")
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _auto_set_user_id_from_headers(headers: dict) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Automatically extract and set user_id from JWT in headers.
|
|
148
|
+
|
|
149
|
+
Only sets user_id if:
|
|
150
|
+
1. User hasn't manually set it via ScopeContext.set_user_id()
|
|
151
|
+
2. Authorization header contains a valid JWT with user ID claim
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
headers: Dictionary of HTTP headers (case-insensitive keys)
|
|
155
|
+
"""
|
|
156
|
+
# Skip if user_id already set manually
|
|
157
|
+
if ScopeContext.get_user_id() is not None:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
# Try to find Authorization header (case-insensitive)
|
|
161
|
+
auth_header = None
|
|
162
|
+
for key, value in headers.items():
|
|
163
|
+
if key.lower() == "authorization":
|
|
164
|
+
auth_header = value
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
if auth_header:
|
|
168
|
+
user_id = _extract_user_id_from_jwt(auth_header)
|
|
169
|
+
if user_id:
|
|
170
|
+
ScopeContext.set_user_id(user_id)
|
|
171
|
+
logger.debug(f"Auto-extracted user_id from JWT: {user_id}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def extract_session_from_headers(headers: dict) -> Optional[str]:
|
|
175
|
+
"""
|
|
176
|
+
Extract session ID from HTTP headers (case-insensitive)
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
headers: Dictionary of HTTP headers
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Session ID if found, None otherwise
|
|
183
|
+
"""
|
|
184
|
+
# Try exact case first
|
|
185
|
+
session_id = headers.get(SESSION_HEADER)
|
|
186
|
+
if session_id:
|
|
187
|
+
return session_id
|
|
188
|
+
|
|
189
|
+
# Try lowercase
|
|
190
|
+
session_id = headers.get(SESSION_HEADER_LOWER)
|
|
191
|
+
if session_id:
|
|
192
|
+
return session_id
|
|
193
|
+
|
|
194
|
+
# Try case-insensitive search
|
|
195
|
+
for key, value in headers.items():
|
|
196
|
+
if key.lower() == SESSION_HEADER_LOWER:
|
|
197
|
+
return value
|
|
198
|
+
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# =============================================================================
|
|
203
|
+
# FastAPI / Starlette Middleware (ASGI)
|
|
204
|
+
# =============================================================================
|
|
205
|
+
|
|
206
|
+
class ScopeSessionMiddleware:
|
|
207
|
+
"""
|
|
208
|
+
ASGI Middleware for FastAPI/Starlette applications
|
|
209
|
+
|
|
210
|
+
Extracts X-Scope-Session-ID header and sets it in ScopeContext
|
|
211
|
+
for the duration of the request.
|
|
212
|
+
|
|
213
|
+
Usage:
|
|
214
|
+
from fastapi import FastAPI
|
|
215
|
+
from scope_analytics.middleware import ScopeSessionMiddleware
|
|
216
|
+
|
|
217
|
+
app = FastAPI()
|
|
218
|
+
app.add_middleware(ScopeSessionMiddleware)
|
|
219
|
+
|
|
220
|
+
# Or with options:
|
|
221
|
+
app.add_middleware(
|
|
222
|
+
ScopeSessionMiddleware,
|
|
223
|
+
generate_temp_session=True, # Generate temp session if no header
|
|
224
|
+
log_missing_session=True, # Log warning when no session header
|
|
225
|
+
)
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
def __init__(
|
|
229
|
+
self,
|
|
230
|
+
app,
|
|
231
|
+
generate_temp_session: bool = True,
|
|
232
|
+
log_missing_session: bool = False,
|
|
233
|
+
):
|
|
234
|
+
"""
|
|
235
|
+
Initialize middleware
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
app: ASGI application
|
|
239
|
+
generate_temp_session: Generate temp session ID if header missing (default: True)
|
|
240
|
+
log_missing_session: Log warning when no session header (default: False)
|
|
241
|
+
"""
|
|
242
|
+
self.app = app
|
|
243
|
+
self.generate_temp_session = generate_temp_session
|
|
244
|
+
self.log_missing_session = log_missing_session
|
|
245
|
+
|
|
246
|
+
async def __call__(self, scope, receive, send):
|
|
247
|
+
if scope["type"] != "http":
|
|
248
|
+
# Pass through non-HTTP requests (websocket, lifespan, etc.)
|
|
249
|
+
await self.app(scope, receive, send)
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
# Start timing
|
|
253
|
+
start_time = time.time()
|
|
254
|
+
|
|
255
|
+
# Extract headers from ASGI scope
|
|
256
|
+
headers = {}
|
|
257
|
+
for key, value in scope.get("headers", []):
|
|
258
|
+
# ASGI headers are bytes
|
|
259
|
+
headers[key.decode("latin-1")] = value.decode("latin-1")
|
|
260
|
+
|
|
261
|
+
# Extract session ID from headers
|
|
262
|
+
session_id = extract_session_from_headers(headers)
|
|
263
|
+
|
|
264
|
+
if session_id:
|
|
265
|
+
ScopeContext.set_session_id(session_id)
|
|
266
|
+
elif self.generate_temp_session:
|
|
267
|
+
# Generate temporary session for backend-only requests
|
|
268
|
+
session_id = ScopeContext.generate_temp_session_id()
|
|
269
|
+
ScopeContext.set_session_id(session_id)
|
|
270
|
+
if self.log_missing_session:
|
|
271
|
+
logger.debug(f"No X-Scope-Session-ID header, generated temp: {session_id}")
|
|
272
|
+
elif self.log_missing_session:
|
|
273
|
+
logger.warning("No X-Scope-Session-ID header and temp generation disabled")
|
|
274
|
+
|
|
275
|
+
# Auto-extract user_id from JWT (for identity stitching)
|
|
276
|
+
_auto_set_user_id_from_headers(headers)
|
|
277
|
+
|
|
278
|
+
# Extract request details for tracking
|
|
279
|
+
method = scope.get("method", "GET")
|
|
280
|
+
path = scope.get("path", "/")
|
|
281
|
+
query_string = scope.get("query_string", b"").decode("utf-8", errors="ignore")
|
|
282
|
+
query_params = dict(pair.split("=", 1) for pair in query_string.split("&") if "=" in pair) if query_string else {}
|
|
283
|
+
|
|
284
|
+
# Extract useful headers
|
|
285
|
+
client_ip = None
|
|
286
|
+
if scope.get("client"):
|
|
287
|
+
client_ip = scope["client"][0]
|
|
288
|
+
user_agent = headers.get("user-agent")
|
|
289
|
+
content_type = headers.get("content-type")
|
|
290
|
+
content_length = None
|
|
291
|
+
if headers.get("content-length"):
|
|
292
|
+
try:
|
|
293
|
+
content_length = int(headers["content-length"])
|
|
294
|
+
except ValueError:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
# Capture response status code
|
|
298
|
+
response_status = [200] # Default, will be updated by send wrapper
|
|
299
|
+
|
|
300
|
+
original_send = send
|
|
301
|
+
|
|
302
|
+
async def send_wrapper(message):
|
|
303
|
+
if message["type"] == "http.response.start":
|
|
304
|
+
response_status[0] = message.get("status", 200)
|
|
305
|
+
await original_send(message)
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
await self.app(scope, receive, send_wrapper)
|
|
309
|
+
finally:
|
|
310
|
+
# Calculate latency
|
|
311
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
312
|
+
|
|
313
|
+
# Track HTTP request
|
|
314
|
+
_track_http_request(
|
|
315
|
+
method=method,
|
|
316
|
+
path=path,
|
|
317
|
+
status_code=response_status[0],
|
|
318
|
+
latency_ms=latency_ms,
|
|
319
|
+
query_params=query_params,
|
|
320
|
+
client_ip=client_ip,
|
|
321
|
+
user_agent=user_agent,
|
|
322
|
+
content_type=content_type,
|
|
323
|
+
content_length=content_length,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Always clear context after request to prevent leaks
|
|
327
|
+
ScopeContext.clear()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# =============================================================================
|
|
331
|
+
# Flask Middleware (WSGI)
|
|
332
|
+
# =============================================================================
|
|
333
|
+
|
|
334
|
+
class FlaskScopeMiddleware:
|
|
335
|
+
"""
|
|
336
|
+
WSGI Middleware for Flask applications
|
|
337
|
+
|
|
338
|
+
Extracts X-Scope-Session-ID header and sets it in ScopeContext
|
|
339
|
+
for the duration of the request.
|
|
340
|
+
|
|
341
|
+
Usage:
|
|
342
|
+
from flask import Flask
|
|
343
|
+
from scope_analytics.middleware import FlaskScopeMiddleware
|
|
344
|
+
|
|
345
|
+
app = Flask(__name__)
|
|
346
|
+
app.wsgi_app = FlaskScopeMiddleware(app.wsgi_app)
|
|
347
|
+
|
|
348
|
+
# Or with options:
|
|
349
|
+
app.wsgi_app = FlaskScopeMiddleware(
|
|
350
|
+
app.wsgi_app,
|
|
351
|
+
generate_temp_session=True,
|
|
352
|
+
log_missing_session=True,
|
|
353
|
+
)
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
def __init__(
|
|
357
|
+
self,
|
|
358
|
+
app,
|
|
359
|
+
generate_temp_session: bool = True,
|
|
360
|
+
log_missing_session: bool = False,
|
|
361
|
+
):
|
|
362
|
+
"""
|
|
363
|
+
Initialize middleware
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
app: WSGI application
|
|
367
|
+
generate_temp_session: Generate temp session ID if header missing (default: True)
|
|
368
|
+
log_missing_session: Log warning when no session header (default: False)
|
|
369
|
+
"""
|
|
370
|
+
self.app = app
|
|
371
|
+
self.generate_temp_session = generate_temp_session
|
|
372
|
+
self.log_missing_session = log_missing_session
|
|
373
|
+
|
|
374
|
+
def __call__(self, environ, start_response):
|
|
375
|
+
# Start timing
|
|
376
|
+
start_time = time.time()
|
|
377
|
+
|
|
378
|
+
# Extract headers from WSGI environ
|
|
379
|
+
# WSGI headers are in format HTTP_X_SCOPE_SESSION_ID
|
|
380
|
+
headers = {}
|
|
381
|
+
for key, value in environ.items():
|
|
382
|
+
if key.startswith("HTTP_"):
|
|
383
|
+
# Convert HTTP_X_SCOPE_SESSION_ID to X-Scope-Session-Id
|
|
384
|
+
header_name = key[5:].replace("_", "-").title()
|
|
385
|
+
headers[header_name] = value
|
|
386
|
+
|
|
387
|
+
# Also check for direct header (some servers)
|
|
388
|
+
if SESSION_HEADER in environ:
|
|
389
|
+
headers[SESSION_HEADER] = environ[SESSION_HEADER]
|
|
390
|
+
|
|
391
|
+
# Extract session ID from headers
|
|
392
|
+
session_id = extract_session_from_headers(headers)
|
|
393
|
+
|
|
394
|
+
if session_id:
|
|
395
|
+
ScopeContext.set_session_id(session_id)
|
|
396
|
+
elif self.generate_temp_session:
|
|
397
|
+
session_id = ScopeContext.generate_temp_session_id()
|
|
398
|
+
ScopeContext.set_session_id(session_id)
|
|
399
|
+
if self.log_missing_session:
|
|
400
|
+
logger.debug(f"No X-Scope-Session-ID header, generated temp: {session_id}")
|
|
401
|
+
elif self.log_missing_session:
|
|
402
|
+
logger.warning("No X-Scope-Session-ID header and temp generation disabled")
|
|
403
|
+
|
|
404
|
+
# Auto-extract user_id from JWT (for identity stitching)
|
|
405
|
+
_auto_set_user_id_from_headers(headers)
|
|
406
|
+
|
|
407
|
+
# Extract request details for tracking
|
|
408
|
+
method = environ.get("REQUEST_METHOD", "GET")
|
|
409
|
+
path = environ.get("PATH_INFO", "/")
|
|
410
|
+
query_string = environ.get("QUERY_STRING", "")
|
|
411
|
+
query_params = dict(pair.split("=", 1) for pair in query_string.split("&") if "=" in pair) if query_string else {}
|
|
412
|
+
|
|
413
|
+
# Extract useful headers
|
|
414
|
+
client_ip = environ.get("REMOTE_ADDR")
|
|
415
|
+
user_agent = environ.get("HTTP_USER_AGENT")
|
|
416
|
+
content_type = environ.get("CONTENT_TYPE")
|
|
417
|
+
content_length = None
|
|
418
|
+
if environ.get("CONTENT_LENGTH"):
|
|
419
|
+
try:
|
|
420
|
+
content_length = int(environ["CONTENT_LENGTH"])
|
|
421
|
+
except ValueError:
|
|
422
|
+
pass
|
|
423
|
+
|
|
424
|
+
# Capture response status code
|
|
425
|
+
response_status = [200]
|
|
426
|
+
|
|
427
|
+
def start_response_wrapper(status, response_headers, exc_info=None):
|
|
428
|
+
# Parse status code from "200 OK" format
|
|
429
|
+
try:
|
|
430
|
+
response_status[0] = int(status.split()[0])
|
|
431
|
+
except (ValueError, IndexError):
|
|
432
|
+
pass
|
|
433
|
+
return start_response(status, response_headers, exc_info)
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
return self.app(environ, start_response_wrapper)
|
|
437
|
+
finally:
|
|
438
|
+
# Calculate latency
|
|
439
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
440
|
+
|
|
441
|
+
# Track HTTP request
|
|
442
|
+
_track_http_request(
|
|
443
|
+
method=method,
|
|
444
|
+
path=path,
|
|
445
|
+
status_code=response_status[0],
|
|
446
|
+
latency_ms=latency_ms,
|
|
447
|
+
query_params=query_params,
|
|
448
|
+
client_ip=client_ip,
|
|
449
|
+
user_agent=user_agent,
|
|
450
|
+
content_type=content_type,
|
|
451
|
+
content_length=content_length,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Always clear context after request to prevent leaks
|
|
455
|
+
ScopeContext.clear()
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# =============================================================================
|
|
459
|
+
# Flask Blueprint/Before Request Hook (Alternative)
|
|
460
|
+
# =============================================================================
|
|
461
|
+
|
|
462
|
+
def init_flask_session_tracking(app, generate_temp_session: bool = True):
|
|
463
|
+
"""
|
|
464
|
+
Initialize session tracking for Flask using before/after request hooks
|
|
465
|
+
|
|
466
|
+
Alternative to middleware approach - uses Flask's request lifecycle.
|
|
467
|
+
Also tracks HTTP requests for analytics.
|
|
468
|
+
|
|
469
|
+
Usage:
|
|
470
|
+
from flask import Flask
|
|
471
|
+
from scope_analytics.middleware import init_flask_session_tracking
|
|
472
|
+
|
|
473
|
+
app = Flask(__name__)
|
|
474
|
+
init_flask_session_tracking(app)
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
app: Flask application instance
|
|
478
|
+
generate_temp_session: Generate temp session if header missing
|
|
479
|
+
"""
|
|
480
|
+
from flask import request, g
|
|
481
|
+
|
|
482
|
+
@app.before_request
|
|
483
|
+
def extract_scope_session():
|
|
484
|
+
# Start timing
|
|
485
|
+
g.scope_start_time = time.time()
|
|
486
|
+
|
|
487
|
+
session_id = request.headers.get(SESSION_HEADER)
|
|
488
|
+
if session_id:
|
|
489
|
+
ScopeContext.set_session_id(session_id)
|
|
490
|
+
g.scope_session_id = session_id
|
|
491
|
+
elif generate_temp_session:
|
|
492
|
+
session_id = ScopeContext.generate_temp_session_id()
|
|
493
|
+
ScopeContext.set_session_id(session_id)
|
|
494
|
+
g.scope_session_id = session_id
|
|
495
|
+
|
|
496
|
+
# Auto-extract user_id from JWT (for identity stitching)
|
|
497
|
+
flask_headers = dict(request.headers)
|
|
498
|
+
_auto_set_user_id_from_headers(flask_headers)
|
|
499
|
+
|
|
500
|
+
@app.after_request
|
|
501
|
+
def track_http_request_and_clear_context(response):
|
|
502
|
+
# Track HTTP request
|
|
503
|
+
if hasattr(g, 'scope_start_time'):
|
|
504
|
+
latency_ms = (time.time() - g.scope_start_time) * 1000
|
|
505
|
+
|
|
506
|
+
# Extract query params
|
|
507
|
+
query_params = dict(request.args.items()) if request.args else {}
|
|
508
|
+
|
|
509
|
+
# Extract content length
|
|
510
|
+
content_length = None
|
|
511
|
+
if request.content_length:
|
|
512
|
+
content_length = request.content_length
|
|
513
|
+
|
|
514
|
+
_track_http_request(
|
|
515
|
+
method=request.method,
|
|
516
|
+
path=request.path,
|
|
517
|
+
status_code=response.status_code,
|
|
518
|
+
latency_ms=latency_ms,
|
|
519
|
+
query_params=query_params,
|
|
520
|
+
client_ip=request.remote_addr,
|
|
521
|
+
user_agent=request.user_agent.string if request.user_agent else None,
|
|
522
|
+
content_type=request.content_type,
|
|
523
|
+
content_length=content_length,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
ScopeContext.clear()
|
|
527
|
+
return response
|
|
528
|
+
|
|
529
|
+
@app.teardown_request
|
|
530
|
+
def teardown_scope_context(exception=None):
|
|
531
|
+
# Ensure cleanup even if after_request doesn't run
|
|
532
|
+
ScopeContext.clear()
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# =============================================================================
|
|
536
|
+
# FastAPI Dependency (Alternative)
|
|
537
|
+
# =============================================================================
|
|
538
|
+
|
|
539
|
+
def get_scope_session():
|
|
540
|
+
"""
|
|
541
|
+
FastAPI dependency for extracting session ID
|
|
542
|
+
|
|
543
|
+
Usage:
|
|
544
|
+
from fastapi import FastAPI, Depends, Request
|
|
545
|
+
from scope_analytics.middleware import get_scope_session
|
|
546
|
+
|
|
547
|
+
app = FastAPI()
|
|
548
|
+
|
|
549
|
+
@app.get("/api/chat")
|
|
550
|
+
async def chat(request: Request, session_id: str = Depends(get_scope_session)):
|
|
551
|
+
# session_id is automatically extracted and set in context
|
|
552
|
+
return {"session_id": session_id}
|
|
553
|
+
"""
|
|
554
|
+
from fastapi import Request
|
|
555
|
+
|
|
556
|
+
async def dependency(request: Request) -> str:
|
|
557
|
+
session_id = request.headers.get(SESSION_HEADER)
|
|
558
|
+
if session_id:
|
|
559
|
+
ScopeContext.set_session_id(session_id)
|
|
560
|
+
else:
|
|
561
|
+
session_id = ScopeContext.ensure_session_id()
|
|
562
|
+
return session_id
|
|
563
|
+
|
|
564
|
+
return dependency
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# =============================================================================
|
|
568
|
+
# Django Middleware
|
|
569
|
+
# =============================================================================
|
|
570
|
+
|
|
571
|
+
class DjangoScopeMiddleware:
|
|
572
|
+
"""
|
|
573
|
+
Django middleware for session tracking
|
|
574
|
+
|
|
575
|
+
Extracts X-Scope-Session-ID header and sets it in ScopeContext
|
|
576
|
+
for the duration of the request.
|
|
577
|
+
|
|
578
|
+
Usage:
|
|
579
|
+
# settings.py
|
|
580
|
+
MIDDLEWARE = [
|
|
581
|
+
'scope_analytics.middleware.DjangoScopeMiddleware',
|
|
582
|
+
# ... other middleware
|
|
583
|
+
]
|
|
584
|
+
|
|
585
|
+
Or for auto-injection (no code changes):
|
|
586
|
+
scope-run python manage.py runserver
|
|
587
|
+
"""
|
|
588
|
+
|
|
589
|
+
def __init__(self, get_response):
|
|
590
|
+
"""
|
|
591
|
+
Initialize middleware
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
get_response: The next middleware or view in the chain
|
|
595
|
+
"""
|
|
596
|
+
self.get_response = get_response
|
|
597
|
+
self.generate_temp_session = True
|
|
598
|
+
self.log_missing_session = False
|
|
599
|
+
|
|
600
|
+
def __call__(self, request):
|
|
601
|
+
# Start timing
|
|
602
|
+
start_time = time.time()
|
|
603
|
+
|
|
604
|
+
# Extract session ID from headers
|
|
605
|
+
session_id = request.META.get('HTTP_X_SCOPE_SESSION_ID')
|
|
606
|
+
|
|
607
|
+
# Also check for direct header (some configurations)
|
|
608
|
+
if not session_id:
|
|
609
|
+
session_id = request.headers.get(SESSION_HEADER)
|
|
610
|
+
|
|
611
|
+
if session_id:
|
|
612
|
+
ScopeContext.set_session_id(session_id)
|
|
613
|
+
elif self.generate_temp_session:
|
|
614
|
+
session_id = ScopeContext.generate_temp_session_id()
|
|
615
|
+
ScopeContext.set_session_id(session_id)
|
|
616
|
+
if self.log_missing_session:
|
|
617
|
+
logger.debug(f"No X-Scope-Session-ID header, generated temp: {session_id}")
|
|
618
|
+
elif self.log_missing_session:
|
|
619
|
+
logger.warning("No X-Scope-Session-ID header and temp generation disabled")
|
|
620
|
+
|
|
621
|
+
# Auto-extract user_id from JWT (for identity stitching)
|
|
622
|
+
# Django stores headers in META with HTTP_ prefix
|
|
623
|
+
django_headers = {}
|
|
624
|
+
for key, value in request.META.items():
|
|
625
|
+
if key.startswith("HTTP_"):
|
|
626
|
+
# Convert HTTP_AUTHORIZATION to authorization
|
|
627
|
+
header_name = key[5:].replace("_", "-").lower()
|
|
628
|
+
django_headers[header_name] = value
|
|
629
|
+
_auto_set_user_id_from_headers(django_headers)
|
|
630
|
+
|
|
631
|
+
# Extract request details for tracking
|
|
632
|
+
method = request.method
|
|
633
|
+
path = request.path
|
|
634
|
+
query_params = dict(request.GET.items()) if hasattr(request, 'GET') else {}
|
|
635
|
+
|
|
636
|
+
# Extract useful headers
|
|
637
|
+
client_ip = request.META.get('REMOTE_ADDR')
|
|
638
|
+
user_agent = request.META.get('HTTP_USER_AGENT')
|
|
639
|
+
content_type = request.content_type if hasattr(request, 'content_type') else None
|
|
640
|
+
content_length = None
|
|
641
|
+
if request.META.get('CONTENT_LENGTH'):
|
|
642
|
+
try:
|
|
643
|
+
content_length = int(request.META['CONTENT_LENGTH'])
|
|
644
|
+
except ValueError:
|
|
645
|
+
pass
|
|
646
|
+
|
|
647
|
+
try:
|
|
648
|
+
response = self.get_response(request)
|
|
649
|
+
|
|
650
|
+
# Calculate latency
|
|
651
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
652
|
+
|
|
653
|
+
# Track HTTP request
|
|
654
|
+
_track_http_request(
|
|
655
|
+
method=method,
|
|
656
|
+
path=path,
|
|
657
|
+
status_code=response.status_code,
|
|
658
|
+
latency_ms=latency_ms,
|
|
659
|
+
query_params=query_params,
|
|
660
|
+
client_ip=client_ip,
|
|
661
|
+
user_agent=user_agent,
|
|
662
|
+
content_type=content_type,
|
|
663
|
+
content_length=content_length,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
return response
|
|
667
|
+
finally:
|
|
668
|
+
# Always clear context after request to prevent leaks
|
|
669
|
+
ScopeContext.clear()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Monkey patches for LLM libraries (OpenAI, Anthropic, Gemini)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .openai_patch import OpenAIPatcher
|
|
6
|
+
from .anthropic_patch import AnthropicPatcher
|
|
7
|
+
from .gemini_patch import GeminiPatcher
|
|
8
|
+
|
|
9
|
+
__all__ = ['OpenAIPatcher', 'AnthropicPatcher', 'GeminiPatcher']
|