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