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,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']