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,275 @@
1
+ """
2
+ Event schema and formatters for Scope Analytics
3
+ Defines the structure of events captured by the backend SDK
4
+ """
5
+
6
+ from datetime import datetime, timezone
7
+ from typing import Optional, Dict, Any, List
8
+ import re
9
+
10
+ from .context import ScopeContext
11
+
12
+
13
+ class EventFormatter:
14
+ """Formats and validates events before shipping to API"""
15
+
16
+ def __init__(self, config):
17
+ self.config = config
18
+
19
+ def format_llm_call(
20
+ self,
21
+ provider: str,
22
+ model: str,
23
+ messages: List[Dict[str, str]],
24
+ response: str,
25
+ tokens: Optional[Dict[str, int]] = None,
26
+ latency_ms: Optional[float] = None,
27
+ error: Optional[str] = None,
28
+ metadata: Optional[Dict[str, Any]] = None,
29
+ ) -> Dict[str, Any]:
30
+ """
31
+ Format an LLM call event
32
+
33
+ Args:
34
+ provider: LLM provider (openai, anthropic, etc.)
35
+ model: Model name (gpt-4, claude-3-opus, etc.)
36
+ messages: List of message dicts with role/content
37
+ response: LLM response text
38
+ tokens: Token usage (prompt_tokens, completion_tokens, total_tokens)
39
+ latency_ms: Request latency in milliseconds
40
+ error: Error message if call failed
41
+ metadata: Additional metadata
42
+
43
+ Returns:
44
+ Formatted event dictionary
45
+ """
46
+ session_id = ScopeContext.get_session_id()
47
+ user_id = ScopeContext.get_user_id()
48
+
49
+ # Determine if this is a user-facing LLM call
50
+ # If we have a session_id from frontend (not temp_), it's user-facing
51
+ is_user_facing = session_id and not session_id.startswith("temp_")
52
+
53
+ # Extract prompt and response for easy access
54
+ prompt = self._extract_prompt(messages)
55
+
56
+ # Redact sensitive information
57
+ prompt = self._redact_sensitive_data(prompt)
58
+ response = self._redact_sensitive_data(response)
59
+
60
+ event = {
61
+ "event_type": "llm_call",
62
+ "source": self.config.sdk_source,
63
+ "timestamp": datetime.now(timezone.utc).isoformat(),
64
+ "session_id": session_id or ScopeContext.ensure_session_id(),
65
+ "user_id": user_id,
66
+
67
+ # LLM call specific fields
68
+ "provider": provider,
69
+ "model": model,
70
+ "prompt": prompt,
71
+ "response": response,
72
+ "messages": messages, # Full message history
73
+ "tokens": tokens or {},
74
+ "latency_ms": latency_ms,
75
+ "is_user_facing": is_user_facing,
76
+
77
+ # Error handling
78
+ "error": error,
79
+ "success": error is None,
80
+
81
+ # Server metadata
82
+ "environment": self.config.environment,
83
+ "sdk_version": self.config.sdk_version,
84
+ "server_timestamp": datetime.now(timezone.utc).isoformat(),
85
+ }
86
+
87
+ # Add custom metadata
88
+ if metadata:
89
+ event["metadata"] = metadata
90
+
91
+ return event
92
+
93
+ def format_http_request(
94
+ self,
95
+ method: str,
96
+ path: str,
97
+ status_code: int,
98
+ latency_ms: float,
99
+ query_params: Optional[Dict[str, str]] = None,
100
+ client_ip: Optional[str] = None,
101
+ user_agent: Optional[str] = None,
102
+ content_type: Optional[str] = None,
103
+ content_length: Optional[int] = None,
104
+ ) -> Dict[str, Any]:
105
+ """
106
+ Format an incoming HTTP request event.
107
+
108
+ This tracks ALL incoming HTTP requests to the user's backend,
109
+ allowing AI agents to answer questions like "what patterns lead to cancellation?"
110
+ by analyzing API call sequences.
111
+
112
+ Args:
113
+ method: HTTP method (GET, POST, PUT, DELETE, etc.)
114
+ path: Request path (e.g., /api/users/123)
115
+ status_code: HTTP response status code
116
+ latency_ms: Request processing time in milliseconds
117
+ query_params: Query string parameters
118
+ client_ip: Client IP address
119
+ user_agent: User-Agent header
120
+ content_type: Content-Type header
121
+ content_length: Content-Length header
122
+
123
+ Returns:
124
+ Formatted event dictionary
125
+ """
126
+ session_id = ScopeContext.get_session_id()
127
+ user_id = ScopeContext.get_user_id()
128
+
129
+ # Determine if this is a user-facing request
130
+ # If we have a session_id from frontend (not temp_), it's user-initiated
131
+ is_user_facing = session_id and not session_id.startswith("temp_")
132
+
133
+ event = {
134
+ "event_type": "http_request",
135
+ "source": self.config.sdk_source,
136
+ "timestamp": datetime.now(timezone.utc).isoformat(),
137
+ "session_id": session_id or ScopeContext.ensure_session_id(),
138
+ "user_id": user_id,
139
+
140
+ # HTTP request specific fields
141
+ "method": method,
142
+ "path": path,
143
+ "status_code": status_code,
144
+ "latency_ms": latency_ms,
145
+ "query_params": query_params or {},
146
+ "client_ip": client_ip,
147
+ "user_agent": user_agent,
148
+ "content_type": content_type,
149
+ "content_length": content_length,
150
+
151
+ # Derived fields for AI agent analysis
152
+ "is_user_facing": is_user_facing,
153
+ "success": 200 <= status_code < 400,
154
+ "is_error": status_code >= 400,
155
+ "is_client_error": 400 <= status_code < 500,
156
+ "is_server_error": status_code >= 500,
157
+
158
+ # Server metadata
159
+ "environment": self.config.environment,
160
+ "sdk_version": self.config.sdk_version,
161
+ "server_timestamp": datetime.now(timezone.utc).isoformat(),
162
+ }
163
+
164
+ return event
165
+
166
+ def format_external_api_call(
167
+ self,
168
+ method: str,
169
+ url: str,
170
+ status_code: int,
171
+ latency_ms: float,
172
+ request_body: Optional[str] = None,
173
+ response_body: Optional[str] = None,
174
+ error: Optional[str] = None,
175
+ ) -> Dict[str, Any]:
176
+ """
177
+ Format an external API call event (Stripe, Twilio, etc.)
178
+
179
+ Args:
180
+ method: HTTP method (GET, POST, etc.)
181
+ url: API endpoint URL
182
+ status_code: HTTP status code
183
+ latency_ms: Request latency in milliseconds
184
+ request_body: Request body (optional)
185
+ response_body: Response body (optional)
186
+ error: Error message if call failed
187
+
188
+ Returns:
189
+ Formatted event dictionary
190
+ """
191
+ session_id = ScopeContext.get_session_id()
192
+ user_id = ScopeContext.get_user_id()
193
+
194
+ # Redact sensitive data from bodies
195
+ if request_body:
196
+ request_body = self._redact_sensitive_data(request_body)
197
+ if response_body:
198
+ response_body = self._redact_sensitive_data(response_body)
199
+
200
+ event = {
201
+ "event_type": "external_api_call",
202
+ "source": self.config.sdk_source,
203
+ "timestamp": datetime.now(timezone.utc).isoformat(),
204
+ "session_id": session_id or ScopeContext.ensure_session_id(),
205
+ "user_id": user_id,
206
+
207
+ # API call specific fields
208
+ "method": method,
209
+ "url": url,
210
+ "status_code": status_code,
211
+ "latency_ms": latency_ms,
212
+ "request_body": request_body,
213
+ "response_body": response_body,
214
+
215
+ # Error handling
216
+ "error": error,
217
+ "success": 200 <= status_code < 300 and error is None,
218
+
219
+ # Server metadata
220
+ "environment": self.config.environment,
221
+ "sdk_version": self.config.sdk_version,
222
+ "server_timestamp": datetime.now(timezone.utc).isoformat(),
223
+ }
224
+
225
+ return event
226
+
227
+ def _extract_prompt(self, messages: List[Dict[str, str]]) -> str:
228
+ """Extract the user prompt from messages list"""
229
+ if not messages:
230
+ return ""
231
+
232
+ # Get the last user message as the prompt
233
+ user_messages = [m for m in messages if m.get("role") == "user"]
234
+ if user_messages:
235
+ return user_messages[-1].get("content", "")
236
+
237
+ return ""
238
+
239
+ def _redact_sensitive_data(self, text: str) -> str:
240
+ """
241
+ Redact sensitive information from text using configured patterns
242
+
243
+ Args:
244
+ text: Text to redact
245
+
246
+ Returns:
247
+ Redacted text
248
+ """
249
+ if not text:
250
+ return text
251
+
252
+ redacted = text
253
+ for pattern in self.config.redact_patterns:
254
+ redacted = re.sub(pattern, r"\1***REDACTED***", redacted, flags=re.IGNORECASE)
255
+
256
+ return redacted
257
+
258
+ def validate_event(self, event: Dict[str, Any]) -> bool:
259
+ """
260
+ Validate event structure
261
+
262
+ Args:
263
+ event: Event dictionary
264
+
265
+ Returns:
266
+ True if valid, False otherwise
267
+ """
268
+ required_fields = ["event_type", "source", "timestamp", "session_id"]
269
+
270
+ for field in required_fields:
271
+ if field not in event:
272
+ self.config.log(f"Invalid event: missing field '{field}'")
273
+ return False
274
+
275
+ return True