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,483 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI library patching for Scope Analytics
|
|
3
|
+
Supports multiple OpenAI versions and usage patterns
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
import functools
|
|
8
|
+
from typing import Optional, Any, Iterator
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from ..context import ScopeContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenAIPatcher:
|
|
15
|
+
"""
|
|
16
|
+
Patches OpenAI library to automatically capture LLM calls
|
|
17
|
+
|
|
18
|
+
Supports:
|
|
19
|
+
- OpenAI v0.x (old API: openai.ChatCompletion.create)
|
|
20
|
+
- OpenAI v1.0+ (new API: client.chat.completions.create)
|
|
21
|
+
- Sync and async calls
|
|
22
|
+
- Streaming and non-streaming responses
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, scope_sdk):
|
|
26
|
+
"""
|
|
27
|
+
Initialize OpenAI patcher
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
scope_sdk: Reference to main ScopeAnalytics instance
|
|
31
|
+
"""
|
|
32
|
+
self.scope_sdk = scope_sdk
|
|
33
|
+
self.config = scope_sdk.config
|
|
34
|
+
self.event_formatter = scope_sdk.event_formatter
|
|
35
|
+
self.queue = scope_sdk.queue
|
|
36
|
+
|
|
37
|
+
self.original_methods = {}
|
|
38
|
+
self.is_patched = False
|
|
39
|
+
self.openai_version = None
|
|
40
|
+
|
|
41
|
+
def patch(self) -> bool:
|
|
42
|
+
"""
|
|
43
|
+
Apply OpenAI patches
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if patching succeeded, False otherwise
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
import openai
|
|
50
|
+
|
|
51
|
+
# Detect OpenAI version
|
|
52
|
+
self.openai_version = self._detect_version(openai)
|
|
53
|
+
self.config.log(f"Detected OpenAI version: {self.openai_version}")
|
|
54
|
+
|
|
55
|
+
if self.openai_version.startswith("1."):
|
|
56
|
+
# Patch new API (v1.0+)
|
|
57
|
+
self._patch_v1(openai)
|
|
58
|
+
else:
|
|
59
|
+
# Patch old API (v0.x)
|
|
60
|
+
self._patch_v0(openai)
|
|
61
|
+
|
|
62
|
+
self.is_patched = True
|
|
63
|
+
self.config.log("✅ OpenAI patching successful")
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
except ImportError:
|
|
67
|
+
self.config.log("OpenAI library not installed - skipping patch")
|
|
68
|
+
return False
|
|
69
|
+
except Exception as e:
|
|
70
|
+
self.config.log(f"⚠️ Failed to patch OpenAI: {e}")
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
def unpatch(self):
|
|
74
|
+
"""Remove OpenAI patches"""
|
|
75
|
+
if not self.is_patched:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
import openai
|
|
80
|
+
|
|
81
|
+
# Restore original methods
|
|
82
|
+
for key, original in self.original_methods.items():
|
|
83
|
+
parts = key.split('.')
|
|
84
|
+
obj = openai
|
|
85
|
+
for part in parts[:-1]:
|
|
86
|
+
obj = getattr(obj, part)
|
|
87
|
+
setattr(obj, parts[-1], original)
|
|
88
|
+
|
|
89
|
+
self.is_patched = False
|
|
90
|
+
self.config.log("OpenAI patches removed")
|
|
91
|
+
|
|
92
|
+
except Exception as e:
|
|
93
|
+
self.config.log(f"⚠️ Failed to unpatch OpenAI: {e}")
|
|
94
|
+
|
|
95
|
+
def _detect_version(self, openai) -> str:
|
|
96
|
+
"""Detect OpenAI library version"""
|
|
97
|
+
if hasattr(openai, '__version__'):
|
|
98
|
+
return openai.__version__
|
|
99
|
+
elif hasattr(openai, 'version'):
|
|
100
|
+
return openai.version.VERSION
|
|
101
|
+
else:
|
|
102
|
+
# Default to v1 if can't detect
|
|
103
|
+
return "1.0.0"
|
|
104
|
+
|
|
105
|
+
def _patch_v1(self, openai):
|
|
106
|
+
"""Patch OpenAI v1.0+ API"""
|
|
107
|
+
self.config.log("Patching OpenAI v1.0+ API...")
|
|
108
|
+
|
|
109
|
+
# Patch sync client
|
|
110
|
+
if hasattr(openai, 'OpenAI'):
|
|
111
|
+
self._patch_client_class(openai.OpenAI, 'OpenAI')
|
|
112
|
+
|
|
113
|
+
# Patch async client
|
|
114
|
+
if hasattr(openai, 'AsyncOpenAI'):
|
|
115
|
+
self._patch_async_client_class(openai.AsyncOpenAI, 'AsyncOpenAI')
|
|
116
|
+
|
|
117
|
+
def _patch_v0(self, openai):
|
|
118
|
+
"""Patch OpenAI v0.x API"""
|
|
119
|
+
self.config.log("Patching OpenAI v0.x API...")
|
|
120
|
+
|
|
121
|
+
# Patch ChatCompletion.create
|
|
122
|
+
if hasattr(openai, 'ChatCompletion'):
|
|
123
|
+
original = openai.ChatCompletion.create
|
|
124
|
+
self.original_methods['ChatCompletion.create'] = original
|
|
125
|
+
openai.ChatCompletion.create = self._wrap_v0_create(original)
|
|
126
|
+
|
|
127
|
+
# Patch ChatCompletion.acreate (async)
|
|
128
|
+
if hasattr(openai, 'ChatCompletion') and hasattr(openai.ChatCompletion, 'acreate'):
|
|
129
|
+
original = openai.ChatCompletion.acreate
|
|
130
|
+
self.original_methods['ChatCompletion.acreate'] = original
|
|
131
|
+
openai.ChatCompletion.acreate = self._wrap_v0_async_create(original)
|
|
132
|
+
|
|
133
|
+
def _patch_client_class(self, client_class, class_name: str):
|
|
134
|
+
"""Patch OpenAI v1+ client class"""
|
|
135
|
+
# We need to patch the instance method, not the class method
|
|
136
|
+
# This is tricky - we'll patch at the resources level
|
|
137
|
+
|
|
138
|
+
# Store original __init__ and wrap it to patch instance methods
|
|
139
|
+
original_init = client_class.__init__
|
|
140
|
+
|
|
141
|
+
def wrapped_init(instance, *args, **kwargs):
|
|
142
|
+
# Call original init
|
|
143
|
+
original_init(instance, *args, **kwargs)
|
|
144
|
+
|
|
145
|
+
# Patch chat completions on this instance
|
|
146
|
+
if hasattr(instance, 'chat') and hasattr(instance.chat, 'completions'):
|
|
147
|
+
original_create = instance.chat.completions.create
|
|
148
|
+
instance.chat.completions.create = self._wrap_v1_create(original_create)
|
|
149
|
+
|
|
150
|
+
client_class.__init__ = wrapped_init
|
|
151
|
+
self.original_methods[f'{class_name}.__init__'] = original_init
|
|
152
|
+
|
|
153
|
+
def _patch_async_client_class(self, client_class, class_name: str):
|
|
154
|
+
"""Patch OpenAI v1+ async client class"""
|
|
155
|
+
original_init = client_class.__init__
|
|
156
|
+
|
|
157
|
+
def wrapped_init(instance, *args, **kwargs):
|
|
158
|
+
# Call original init
|
|
159
|
+
original_init(instance, *args, **kwargs)
|
|
160
|
+
|
|
161
|
+
# Patch async chat completions
|
|
162
|
+
if hasattr(instance, 'chat') and hasattr(instance.chat, 'completions'):
|
|
163
|
+
original_create = instance.chat.completions.create
|
|
164
|
+
instance.chat.completions.create = self._wrap_v1_async_create(original_create)
|
|
165
|
+
|
|
166
|
+
client_class.__init__ = wrapped_init
|
|
167
|
+
self.original_methods[f'{class_name}.__init__'] = original_init
|
|
168
|
+
|
|
169
|
+
def _wrap_v1_create(self, original_func):
|
|
170
|
+
"""Wrap OpenAI v1+ sync create method"""
|
|
171
|
+
@functools.wraps(original_func)
|
|
172
|
+
def wrapper(*args, **kwargs):
|
|
173
|
+
# RECURSION GUARD: Skip capture if inside Scope internal code
|
|
174
|
+
# This prevents infinite loops when Scope backend tracks itself
|
|
175
|
+
if ScopeContext.is_in_scope_context():
|
|
176
|
+
return original_func(*args, **kwargs)
|
|
177
|
+
|
|
178
|
+
start_time = time.time()
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
# Call original function
|
|
182
|
+
response = original_func(*args, **kwargs)
|
|
183
|
+
|
|
184
|
+
# Handle streaming vs non-streaming
|
|
185
|
+
if kwargs.get('stream', False):
|
|
186
|
+
# Return streaming wrapper
|
|
187
|
+
return self._wrap_streaming_response(response, start_time, kwargs)
|
|
188
|
+
else:
|
|
189
|
+
# Capture non-streaming response
|
|
190
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
191
|
+
self._capture_v1_response(response, kwargs, latency_ms)
|
|
192
|
+
return response
|
|
193
|
+
|
|
194
|
+
except Exception as e:
|
|
195
|
+
# Capture error
|
|
196
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
197
|
+
self._capture_error(kwargs, str(e), latency_ms)
|
|
198
|
+
raise
|
|
199
|
+
|
|
200
|
+
return wrapper
|
|
201
|
+
|
|
202
|
+
def _wrap_v1_async_create(self, original_func):
|
|
203
|
+
"""Wrap OpenAI v1+ async create method"""
|
|
204
|
+
@functools.wraps(original_func)
|
|
205
|
+
async def wrapper(*args, **kwargs):
|
|
206
|
+
# RECURSION GUARD: Skip capture if inside Scope internal code
|
|
207
|
+
if ScopeContext.is_in_scope_context():
|
|
208
|
+
return await original_func(*args, **kwargs)
|
|
209
|
+
|
|
210
|
+
start_time = time.time()
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
# Call original async function
|
|
214
|
+
response = await original_func(*args, **kwargs)
|
|
215
|
+
|
|
216
|
+
# Handle streaming vs non-streaming
|
|
217
|
+
if kwargs.get('stream', False):
|
|
218
|
+
# Return async streaming wrapper
|
|
219
|
+
return self._wrap_async_streaming_response(response, start_time, kwargs)
|
|
220
|
+
else:
|
|
221
|
+
# Capture non-streaming response
|
|
222
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
223
|
+
self._capture_v1_response(response, kwargs, latency_ms)
|
|
224
|
+
return response
|
|
225
|
+
|
|
226
|
+
except Exception as e:
|
|
227
|
+
# Capture error
|
|
228
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
229
|
+
self._capture_error(kwargs, str(e), latency_ms)
|
|
230
|
+
raise
|
|
231
|
+
|
|
232
|
+
return wrapper
|
|
233
|
+
|
|
234
|
+
def _wrap_v0_create(self, original_func):
|
|
235
|
+
"""Wrap OpenAI v0.x sync create method"""
|
|
236
|
+
@functools.wraps(original_func)
|
|
237
|
+
def wrapper(*args, **kwargs):
|
|
238
|
+
# RECURSION GUARD: Skip capture if inside Scope internal code
|
|
239
|
+
if ScopeContext.is_in_scope_context():
|
|
240
|
+
return original_func(*args, **kwargs)
|
|
241
|
+
|
|
242
|
+
start_time = time.time()
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
response = original_func(*args, **kwargs)
|
|
246
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
247
|
+
|
|
248
|
+
# Handle streaming
|
|
249
|
+
if kwargs.get('stream', False):
|
|
250
|
+
return self._wrap_v0_streaming_response(response, start_time, kwargs)
|
|
251
|
+
else:
|
|
252
|
+
self._capture_v0_response(response, kwargs, latency_ms)
|
|
253
|
+
return response
|
|
254
|
+
|
|
255
|
+
except Exception as e:
|
|
256
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
257
|
+
self._capture_error(kwargs, str(e), latency_ms)
|
|
258
|
+
raise
|
|
259
|
+
|
|
260
|
+
return wrapper
|
|
261
|
+
|
|
262
|
+
def _wrap_v0_async_create(self, original_func):
|
|
263
|
+
"""Wrap OpenAI v0.x async create method"""
|
|
264
|
+
@functools.wraps(original_func)
|
|
265
|
+
async def wrapper(*args, **kwargs):
|
|
266
|
+
# RECURSION GUARD: Skip capture if inside Scope internal code
|
|
267
|
+
if ScopeContext.is_in_scope_context():
|
|
268
|
+
return await original_func(*args, **kwargs)
|
|
269
|
+
|
|
270
|
+
start_time = time.time()
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
response = await original_func(*args, **kwargs)
|
|
274
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
275
|
+
self._capture_v0_response(response, kwargs, latency_ms)
|
|
276
|
+
return response
|
|
277
|
+
|
|
278
|
+
except Exception as e:
|
|
279
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
280
|
+
self._capture_error(kwargs, str(e), latency_ms)
|
|
281
|
+
raise
|
|
282
|
+
|
|
283
|
+
return wrapper
|
|
284
|
+
|
|
285
|
+
def _wrap_streaming_response(self, response, start_time, kwargs):
|
|
286
|
+
"""Wrap v1+ streaming response to capture chunks"""
|
|
287
|
+
chunks = []
|
|
288
|
+
|
|
289
|
+
for chunk in response:
|
|
290
|
+
chunks.append(chunk)
|
|
291
|
+
yield chunk
|
|
292
|
+
|
|
293
|
+
# After streaming completes, capture event
|
|
294
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
295
|
+
self._capture_v1_streaming_response(chunks, kwargs, latency_ms)
|
|
296
|
+
|
|
297
|
+
async def _wrap_async_streaming_response(self, response, start_time, kwargs):
|
|
298
|
+
"""Wrap v1+ async streaming response"""
|
|
299
|
+
chunks = []
|
|
300
|
+
|
|
301
|
+
async for chunk in response:
|
|
302
|
+
chunks.append(chunk)
|
|
303
|
+
yield chunk
|
|
304
|
+
|
|
305
|
+
# After streaming completes, capture event
|
|
306
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
307
|
+
self._capture_v1_streaming_response(chunks, kwargs, latency_ms)
|
|
308
|
+
|
|
309
|
+
def _wrap_v0_streaming_response(self, response, start_time, kwargs):
|
|
310
|
+
"""Wrap v0.x streaming response"""
|
|
311
|
+
chunks = []
|
|
312
|
+
|
|
313
|
+
for chunk in response:
|
|
314
|
+
chunks.append(chunk)
|
|
315
|
+
yield chunk
|
|
316
|
+
|
|
317
|
+
# After streaming completes, capture event
|
|
318
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
319
|
+
self._capture_v0_streaming_response(chunks, kwargs, latency_ms)
|
|
320
|
+
|
|
321
|
+
def _capture_v1_response(self, response, kwargs, latency_ms):
|
|
322
|
+
"""Capture OpenAI v1+ non-streaming response"""
|
|
323
|
+
try:
|
|
324
|
+
# Extract data from response
|
|
325
|
+
model = kwargs.get('model', response.model if hasattr(response, 'model') else 'unknown')
|
|
326
|
+
messages = kwargs.get('messages', [])
|
|
327
|
+
|
|
328
|
+
# Extract response content
|
|
329
|
+
response_text = ""
|
|
330
|
+
if hasattr(response, 'choices') and len(response.choices) > 0:
|
|
331
|
+
choice = response.choices[0]
|
|
332
|
+
if hasattr(choice, 'message') and hasattr(choice.message, 'content'):
|
|
333
|
+
response_text = choice.message.content or ""
|
|
334
|
+
|
|
335
|
+
# Extract token usage
|
|
336
|
+
tokens = {}
|
|
337
|
+
if hasattr(response, 'usage'):
|
|
338
|
+
tokens = {
|
|
339
|
+
'prompt_tokens': response.usage.prompt_tokens,
|
|
340
|
+
'completion_tokens': response.usage.completion_tokens,
|
|
341
|
+
'total_tokens': response.usage.total_tokens,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
# Format and queue event
|
|
345
|
+
event = self.event_formatter.format_llm_call(
|
|
346
|
+
provider='openai',
|
|
347
|
+
model=model,
|
|
348
|
+
messages=messages,
|
|
349
|
+
response=response_text,
|
|
350
|
+
tokens=tokens,
|
|
351
|
+
latency_ms=latency_ms,
|
|
352
|
+
error=None,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
self.queue.enqueue(event)
|
|
356
|
+
self.config.log(f"Captured OpenAI call: {model}")
|
|
357
|
+
|
|
358
|
+
except Exception as e:
|
|
359
|
+
self.config.log(f"⚠️ Failed to capture OpenAI response: {e}")
|
|
360
|
+
|
|
361
|
+
def _capture_v1_streaming_response(self, chunks, kwargs, latency_ms):
|
|
362
|
+
"""Capture OpenAI v1+ streaming response"""
|
|
363
|
+
try:
|
|
364
|
+
# Reconstruct response from chunks
|
|
365
|
+
model = kwargs.get('model', 'unknown')
|
|
366
|
+
messages = kwargs.get('messages', [])
|
|
367
|
+
|
|
368
|
+
# Combine streaming chunks
|
|
369
|
+
response_text = ""
|
|
370
|
+
for chunk in chunks:
|
|
371
|
+
if hasattr(chunk, 'choices') and len(chunk.choices) > 0:
|
|
372
|
+
delta = chunk.choices[0].delta
|
|
373
|
+
if hasattr(delta, 'content') and delta.content:
|
|
374
|
+
response_text += delta.content
|
|
375
|
+
|
|
376
|
+
# Format and queue event
|
|
377
|
+
event = self.event_formatter.format_llm_call(
|
|
378
|
+
provider='openai',
|
|
379
|
+
model=model,
|
|
380
|
+
messages=messages,
|
|
381
|
+
response=response_text,
|
|
382
|
+
tokens={}, # Streaming usually doesn't include tokens
|
|
383
|
+
latency_ms=latency_ms,
|
|
384
|
+
error=None,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
self.queue.enqueue(event)
|
|
388
|
+
self.config.log(f"Captured OpenAI streaming call: {model}")
|
|
389
|
+
|
|
390
|
+
except Exception as e:
|
|
391
|
+
self.config.log(f"⚠️ Failed to capture OpenAI streaming response: {e}")
|
|
392
|
+
|
|
393
|
+
def _capture_v0_response(self, response, kwargs, latency_ms):
|
|
394
|
+
"""Capture OpenAI v0.x response"""
|
|
395
|
+
try:
|
|
396
|
+
model = kwargs.get('model', response.get('model', 'unknown'))
|
|
397
|
+
messages = kwargs.get('messages', [])
|
|
398
|
+
|
|
399
|
+
# Extract response
|
|
400
|
+
response_text = ""
|
|
401
|
+
if 'choices' in response and len(response['choices']) > 0:
|
|
402
|
+
choice = response['choices'][0]
|
|
403
|
+
if 'message' in choice and 'content' in choice['message']:
|
|
404
|
+
response_text = choice['message']['content'] or ""
|
|
405
|
+
|
|
406
|
+
# Extract tokens
|
|
407
|
+
tokens = {}
|
|
408
|
+
if 'usage' in response:
|
|
409
|
+
tokens = {
|
|
410
|
+
'prompt_tokens': response['usage'].get('prompt_tokens', 0),
|
|
411
|
+
'completion_tokens': response['usage'].get('completion_tokens', 0),
|
|
412
|
+
'total_tokens': response['usage'].get('total_tokens', 0),
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
# Format and queue event
|
|
416
|
+
event = self.event_formatter.format_llm_call(
|
|
417
|
+
provider='openai',
|
|
418
|
+
model=model,
|
|
419
|
+
messages=messages,
|
|
420
|
+
response=response_text,
|
|
421
|
+
tokens=tokens,
|
|
422
|
+
latency_ms=latency_ms,
|
|
423
|
+
error=None,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
self.queue.enqueue(event)
|
|
427
|
+
self.config.log(f"Captured OpenAI call: {model}")
|
|
428
|
+
|
|
429
|
+
except Exception as e:
|
|
430
|
+
self.config.log(f"⚠️ Failed to capture OpenAI v0 response: {e}")
|
|
431
|
+
|
|
432
|
+
def _capture_v0_streaming_response(self, chunks, kwargs, latency_ms):
|
|
433
|
+
"""Capture OpenAI v0.x streaming response"""
|
|
434
|
+
try:
|
|
435
|
+
model = kwargs.get('model', 'unknown')
|
|
436
|
+
messages = kwargs.get('messages', [])
|
|
437
|
+
|
|
438
|
+
# Combine streaming chunks
|
|
439
|
+
response_text = ""
|
|
440
|
+
for chunk in chunks:
|
|
441
|
+
if 'choices' in chunk and len(chunk['choices']) > 0:
|
|
442
|
+
delta = chunk['choices'][0].get('delta', {})
|
|
443
|
+
if 'content' in delta:
|
|
444
|
+
response_text += delta['content']
|
|
445
|
+
|
|
446
|
+
# Format and queue event
|
|
447
|
+
event = self.event_formatter.format_llm_call(
|
|
448
|
+
provider='openai',
|
|
449
|
+
model=model,
|
|
450
|
+
messages=messages,
|
|
451
|
+
response=response_text,
|
|
452
|
+
tokens={},
|
|
453
|
+
latency_ms=latency_ms,
|
|
454
|
+
error=None,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
self.queue.enqueue(event)
|
|
458
|
+
self.config.log(f"Captured OpenAI v0 streaming call: {model}")
|
|
459
|
+
|
|
460
|
+
except Exception as e:
|
|
461
|
+
self.config.log(f"⚠️ Failed to capture OpenAI v0 streaming response: {e}")
|
|
462
|
+
|
|
463
|
+
def _capture_error(self, kwargs, error_message, latency_ms):
|
|
464
|
+
"""Capture failed LLM call"""
|
|
465
|
+
try:
|
|
466
|
+
model = kwargs.get('model', 'unknown')
|
|
467
|
+
messages = kwargs.get('messages', [])
|
|
468
|
+
|
|
469
|
+
event = self.event_formatter.format_llm_call(
|
|
470
|
+
provider='openai',
|
|
471
|
+
model=model,
|
|
472
|
+
messages=messages,
|
|
473
|
+
response="",
|
|
474
|
+
tokens={},
|
|
475
|
+
latency_ms=latency_ms,
|
|
476
|
+
error=error_message,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
self.queue.enqueue(event)
|
|
480
|
+
self.config.log(f"Captured OpenAI error: {error_message}")
|
|
481
|
+
|
|
482
|
+
except Exception as e:
|
|
483
|
+
self.config.log(f"⚠️ Failed to capture OpenAI error: {e}")
|
scope_analytics/queue.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event queue with batch flushing for Scope Analytics
|
|
3
|
+
Manages in-memory event storage and batch processing
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
from typing import Dict, Any, Callable, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventQueue:
|
|
13
|
+
"""
|
|
14
|
+
Thread-safe event queue with automatic batch flushing
|
|
15
|
+
|
|
16
|
+
Features:
|
|
17
|
+
- Batches events by size (batch_size)
|
|
18
|
+
- Flushes automatically after timeout (batch_timeout_seconds)
|
|
19
|
+
- Drops oldest events if max_queue_size exceeded
|
|
20
|
+
- Thread-safe for concurrent access
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
batch_size: int,
|
|
26
|
+
batch_timeout_seconds: int,
|
|
27
|
+
max_queue_size: int,
|
|
28
|
+
flush_callback: Callable[[list], None],
|
|
29
|
+
config,
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
Initialize event queue
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
batch_size: Number of events to batch before flushing
|
|
36
|
+
batch_timeout_seconds: Max seconds to wait before flushing partial batch
|
|
37
|
+
max_queue_size: Maximum events to queue (oldest dropped if exceeded)
|
|
38
|
+
flush_callback: Function to call when flushing batch
|
|
39
|
+
config: SDK configuration for logging
|
|
40
|
+
"""
|
|
41
|
+
self.batch_size = batch_size
|
|
42
|
+
self.batch_timeout_seconds = batch_timeout_seconds
|
|
43
|
+
self.max_queue_size = max_queue_size
|
|
44
|
+
self.flush_callback = flush_callback
|
|
45
|
+
self.config = config
|
|
46
|
+
|
|
47
|
+
self.queue = deque(maxlen=max_queue_size)
|
|
48
|
+
self.lock = threading.Lock()
|
|
49
|
+
self.last_flush_time = time.time()
|
|
50
|
+
|
|
51
|
+
# Background thread for periodic flushing
|
|
52
|
+
self.running = False
|
|
53
|
+
self.flush_thread: Optional[threading.Thread] = None
|
|
54
|
+
|
|
55
|
+
def start(self):
|
|
56
|
+
"""Start background flush thread"""
|
|
57
|
+
if self.running:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
self.running = True
|
|
61
|
+
self.flush_thread = threading.Thread(
|
|
62
|
+
target=self._flush_loop,
|
|
63
|
+
daemon=True,
|
|
64
|
+
name="ScopeAnalytics-FlushThread"
|
|
65
|
+
)
|
|
66
|
+
self.flush_thread.start()
|
|
67
|
+
self.config.log("Event queue started")
|
|
68
|
+
|
|
69
|
+
def stop(self):
|
|
70
|
+
"""Stop background thread and flush remaining events"""
|
|
71
|
+
self.config.log("Stopping event queue...")
|
|
72
|
+
self.running = False
|
|
73
|
+
|
|
74
|
+
if self.flush_thread:
|
|
75
|
+
self.flush_thread.join(timeout=5)
|
|
76
|
+
|
|
77
|
+
# Flush any remaining events
|
|
78
|
+
self.flush()
|
|
79
|
+
self.config.log("Event queue stopped")
|
|
80
|
+
|
|
81
|
+
def enqueue(self, event: Dict[str, Any]):
|
|
82
|
+
"""
|
|
83
|
+
Add event to queue
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
event: Event dictionary to queue
|
|
87
|
+
"""
|
|
88
|
+
with self.lock:
|
|
89
|
+
# Check if queue is full
|
|
90
|
+
if len(self.queue) >= self.max_queue_size:
|
|
91
|
+
self.config.log(
|
|
92
|
+
f"Queue full ({self.max_queue_size}), dropping oldest event"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self.queue.append(event)
|
|
96
|
+
|
|
97
|
+
# Check if we should flush due to batch size
|
|
98
|
+
if len(self.queue) >= self.batch_size:
|
|
99
|
+
self.config.log(
|
|
100
|
+
f"Batch size ({self.batch_size}) reached, flushing..."
|
|
101
|
+
)
|
|
102
|
+
self._flush_batch()
|
|
103
|
+
|
|
104
|
+
def flush(self):
|
|
105
|
+
"""Manually flush all queued events"""
|
|
106
|
+
with self.lock:
|
|
107
|
+
if len(self.queue) > 0:
|
|
108
|
+
self.config.log(f"Manual flush: {len(self.queue)} events")
|
|
109
|
+
self._flush_batch()
|
|
110
|
+
|
|
111
|
+
def _flush_loop(self):
|
|
112
|
+
"""Background thread that periodically flushes events"""
|
|
113
|
+
while self.running:
|
|
114
|
+
time.sleep(1) # Check every second
|
|
115
|
+
|
|
116
|
+
with self.lock:
|
|
117
|
+
time_since_flush = time.time() - self.last_flush_time
|
|
118
|
+
|
|
119
|
+
# Flush if timeout reached and we have events
|
|
120
|
+
if time_since_flush >= self.batch_timeout_seconds and len(self.queue) > 0:
|
|
121
|
+
self.config.log(
|
|
122
|
+
f"Timeout ({self.batch_timeout_seconds}s) reached, flushing {len(self.queue)} events"
|
|
123
|
+
)
|
|
124
|
+
self._flush_batch()
|
|
125
|
+
|
|
126
|
+
def _flush_batch(self):
|
|
127
|
+
"""
|
|
128
|
+
Flush current batch of events
|
|
129
|
+
NOTE: Must be called with lock held
|
|
130
|
+
"""
|
|
131
|
+
if len(self.queue) == 0:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Copy batch and clear queue while holding lock
|
|
135
|
+
batch = list(self.queue)
|
|
136
|
+
self.queue.clear()
|
|
137
|
+
self.last_flush_time = time.time()
|
|
138
|
+
|
|
139
|
+
# We'll flush outside the lock in a separate thread
|
|
140
|
+
# This prevents blocking the queue during I/O
|
|
141
|
+
import threading
|
|
142
|
+
def flush_in_background():
|
|
143
|
+
try:
|
|
144
|
+
self.flush_callback(batch)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
self.config.log(f"Error flushing batch: {e}")
|
|
147
|
+
# Re-queue events on failure
|
|
148
|
+
with self.lock:
|
|
149
|
+
self.queue.extendleft(reversed(batch))
|
|
150
|
+
|
|
151
|
+
# Start flush in background
|
|
152
|
+
thread = threading.Thread(target=flush_in_background, daemon=True)
|
|
153
|
+
thread.start()
|
|
154
|
+
|
|
155
|
+
def size(self) -> int:
|
|
156
|
+
"""Get current queue size"""
|
|
157
|
+
with self.lock:
|
|
158
|
+
return len(self.queue)
|