raindrop-ai 0.0.19__tar.gz
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.
- raindrop_ai-0.0.19/PKG-INFO +28 -0
- raindrop_ai-0.0.19/README.md +3 -0
- raindrop_ai-0.0.19/raindrop/__init__.py +0 -0
- raindrop_ai-0.0.19/raindrop/analytics.py +283 -0
- raindrop_ai-0.0.19/raindrop/version.py +1 -0
- raindrop_ai-0.0.19/raindrop_ai.egg-info/PKG-INFO +28 -0
- raindrop_ai-0.0.19/raindrop_ai.egg-info/SOURCES.txt +11 -0
- raindrop_ai-0.0.19/raindrop_ai.egg-info/dependency_links.txt +1 -0
- raindrop_ai-0.0.19/raindrop_ai.egg-info/requires.txt +1 -0
- raindrop_ai-0.0.19/raindrop_ai.egg-info/top_level.txt +1 -0
- raindrop_ai-0.0.19/setup.cfg +4 -0
- raindrop_ai-0.0.19/setup.py +34 -0
- raindrop_ai-0.0.19/tests/test_analytics.py +254 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: raindrop-ai
|
|
3
|
+
Version: 0.0.19
|
|
4
|
+
Summary: Raindrop AI (Python SDK)
|
|
5
|
+
Home-page: https://raindrop.ai
|
|
6
|
+
Author: Raindrop AI
|
|
7
|
+
Author-email: sdk@raindrop.ai
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.6
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: requests
|
|
19
|
+
Dynamic: author
|
|
20
|
+
Dynamic: author-email
|
|
21
|
+
Dynamic: classifier
|
|
22
|
+
Dynamic: description
|
|
23
|
+
Dynamic: description-content-type
|
|
24
|
+
Dynamic: home-page
|
|
25
|
+
Dynamic: requires-dist
|
|
26
|
+
Dynamic: summary
|
|
27
|
+
|
|
28
|
+
For questions, email us at sdk@raindrop.ai
|
|
File without changes
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import time
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Union, List, Dict, Optional, Literal
|
|
5
|
+
import requests
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import logging
|
|
8
|
+
import json
|
|
9
|
+
import uuid
|
|
10
|
+
import atexit
|
|
11
|
+
from raindrop.version import VERSION
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Configure logging
|
|
15
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
write_key = None
|
|
19
|
+
api_url = "https://api.raindrop.ai/v1/"
|
|
20
|
+
max_queue_size = 10000
|
|
21
|
+
upload_size = 10
|
|
22
|
+
upload_interval = 1.0
|
|
23
|
+
buffer = []
|
|
24
|
+
flush_lock = threading.Lock()
|
|
25
|
+
debug_logs = False
|
|
26
|
+
flush_thread = None
|
|
27
|
+
shutdown_event = threading.Event()
|
|
28
|
+
max_ingest_size_bytes = 1 * 1024 * 1024 # 1 MB
|
|
29
|
+
|
|
30
|
+
def set_debug_logs(value: bool):
|
|
31
|
+
global debug_logs
|
|
32
|
+
debug_logs = value
|
|
33
|
+
if debug_logs:
|
|
34
|
+
logger.setLevel(logging.DEBUG)
|
|
35
|
+
else:
|
|
36
|
+
logger.setLevel(logging.INFO)
|
|
37
|
+
|
|
38
|
+
def start_flush_thread():
|
|
39
|
+
logger.debug("Opening flush thread")
|
|
40
|
+
global flush_thread
|
|
41
|
+
if flush_thread is None:
|
|
42
|
+
flush_thread = threading.Thread(target=flush_loop)
|
|
43
|
+
flush_thread.daemon = True
|
|
44
|
+
flush_thread.start()
|
|
45
|
+
|
|
46
|
+
def flush_loop():
|
|
47
|
+
while not shutdown_event.is_set():
|
|
48
|
+
try:
|
|
49
|
+
flush()
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.error(f"Error in flush loop: {e}")
|
|
52
|
+
time.sleep(upload_interval)
|
|
53
|
+
|
|
54
|
+
def flush() -> None:
|
|
55
|
+
global buffer
|
|
56
|
+
|
|
57
|
+
if buffer is None:
|
|
58
|
+
logger.error("No buffer available")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
logger.debug("Starting flush")
|
|
62
|
+
|
|
63
|
+
with flush_lock:
|
|
64
|
+
current_buffer = buffer
|
|
65
|
+
buffer = []
|
|
66
|
+
|
|
67
|
+
logger.debug(f"Flushing buffer size: {len(current_buffer)}")
|
|
68
|
+
|
|
69
|
+
grouped_events = {}
|
|
70
|
+
for event in current_buffer:
|
|
71
|
+
endpoint = event["type"]
|
|
72
|
+
data = event["data"]
|
|
73
|
+
if endpoint not in grouped_events:
|
|
74
|
+
grouped_events[endpoint] = []
|
|
75
|
+
grouped_events[endpoint].append(data)
|
|
76
|
+
|
|
77
|
+
for endpoint, events_data in grouped_events.items():
|
|
78
|
+
for i in range(0, len(events_data), upload_size):
|
|
79
|
+
batch = events_data[i:i+upload_size]
|
|
80
|
+
logger.debug(f"Sending {len(batch)} events to {endpoint}")
|
|
81
|
+
send_request(endpoint, batch)
|
|
82
|
+
|
|
83
|
+
logger.debug("Flush complete")
|
|
84
|
+
|
|
85
|
+
def send_request(endpoint: str, data_entries: List[Dict[str, Union[str, Dict]]]) -> None:
|
|
86
|
+
|
|
87
|
+
url = f"{api_url}{endpoint}"
|
|
88
|
+
headers = {
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
"Authorization": f"Bearer {write_key}",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
max_retries = 3
|
|
94
|
+
for attempt in range(max_retries):
|
|
95
|
+
try:
|
|
96
|
+
response = requests.post(url, json=data_entries, headers=headers)
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
logger.debug(f"Request successful: {response.status_code}")
|
|
99
|
+
break
|
|
100
|
+
except requests.exceptions.RequestException as e:
|
|
101
|
+
logger.error(f"Error sending request (attempt {attempt + 1}/{max_retries}): {e}")
|
|
102
|
+
if attempt == max_retries - 1:
|
|
103
|
+
logger.error(f"Failed to send request after {max_retries} attempts")
|
|
104
|
+
|
|
105
|
+
def save_to_buffer(event: Dict[str, Union[str, Dict]]) -> None:
|
|
106
|
+
global buffer
|
|
107
|
+
|
|
108
|
+
if len(buffer) >= max_queue_size * 0.8:
|
|
109
|
+
logger.warning(f"Buffer is at {len(buffer) / max_queue_size * 100:.2f}% capacity")
|
|
110
|
+
|
|
111
|
+
if len(buffer) >= max_queue_size:
|
|
112
|
+
logger.error("Buffer is full. Discarding event.")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
logger.debug(f"Adding event to buffer: {event}")
|
|
116
|
+
|
|
117
|
+
with flush_lock:
|
|
118
|
+
buffer.append(event)
|
|
119
|
+
|
|
120
|
+
start_flush_thread()
|
|
121
|
+
|
|
122
|
+
def identify(user_id: str, traits: Dict[str, Union[str, int, bool, float]]) -> None:
|
|
123
|
+
if not _check_write_key():
|
|
124
|
+
return
|
|
125
|
+
data = {"user_id": user_id, "traits": traits}
|
|
126
|
+
save_to_buffer({"type": "users/identify", "data": data})
|
|
127
|
+
|
|
128
|
+
def track(
|
|
129
|
+
user_id: str,
|
|
130
|
+
event: str,
|
|
131
|
+
properties: Optional[Dict[str, Union[str, int, bool, float]]] = None,
|
|
132
|
+
timestamp: Optional[str] = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
if not _check_write_key():
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
data = {
|
|
138
|
+
"event_id": str(uuid.uuid4()),
|
|
139
|
+
"user_id": user_id,
|
|
140
|
+
"event": event,
|
|
141
|
+
"properties": properties,
|
|
142
|
+
"timestamp": timestamp if timestamp else _get_timestamp(),
|
|
143
|
+
}
|
|
144
|
+
data.setdefault("properties", {})["$context"] = _get_context()
|
|
145
|
+
|
|
146
|
+
save_to_buffer({"type": "events/track", "data": data})
|
|
147
|
+
|
|
148
|
+
def track_ai(
|
|
149
|
+
user_id: str,
|
|
150
|
+
event: str,
|
|
151
|
+
model: Optional[str] = None,
|
|
152
|
+
user_input: Optional[str] = None,
|
|
153
|
+
output: Optional[str] = None,
|
|
154
|
+
convo_id: Optional[str] = None,
|
|
155
|
+
properties: Optional[Dict[str, Union[str, int, bool, float]]] = None,
|
|
156
|
+
timestamp: Optional[str] = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
if not _check_write_key():
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
if not user_input and not output:
|
|
162
|
+
raise ValueError("One of user_input or output must be provided and not empty.")
|
|
163
|
+
|
|
164
|
+
event_id = str(uuid.uuid4())
|
|
165
|
+
|
|
166
|
+
data = {
|
|
167
|
+
"event_id": event_id,
|
|
168
|
+
"user_id": user_id,
|
|
169
|
+
"event": event,
|
|
170
|
+
"properties": properties or {},
|
|
171
|
+
"timestamp": timestamp if timestamp else _get_timestamp(),
|
|
172
|
+
"ai_data": {
|
|
173
|
+
"model": model,
|
|
174
|
+
"input": user_input,
|
|
175
|
+
"output": output,
|
|
176
|
+
"convo_id": convo_id,
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
data.setdefault("properties", {})["$context"] = _get_context()
|
|
180
|
+
|
|
181
|
+
size = _get_size(data)
|
|
182
|
+
if size > max_ingest_size_bytes:
|
|
183
|
+
logger.warning(
|
|
184
|
+
f"[raindrop] Events larger than {max_ingest_size_bytes / (1024 * 1024)} MB may have properties truncated - "
|
|
185
|
+
f"an event of size {size / (1024 * 1024):.2f} MB was logged"
|
|
186
|
+
)
|
|
187
|
+
return None # Skip adding oversized events to buffer
|
|
188
|
+
|
|
189
|
+
save_to_buffer({"type": "events/track", "data": data})
|
|
190
|
+
return event_id
|
|
191
|
+
|
|
192
|
+
def shutdown():
|
|
193
|
+
logger.info("Shutting down raindrop analytics")
|
|
194
|
+
shutdown_event.set()
|
|
195
|
+
if flush_thread:
|
|
196
|
+
flush_thread.join(timeout=10)
|
|
197
|
+
flush() # Final flush to ensure all events are sent
|
|
198
|
+
|
|
199
|
+
def _check_write_key():
|
|
200
|
+
if write_key is None:
|
|
201
|
+
logger.warning("write_key is not set. Please set it before using raindrop analytics.")
|
|
202
|
+
return False
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
def _get_context():
|
|
206
|
+
return {
|
|
207
|
+
"library": {
|
|
208
|
+
"name": "python-sdk",
|
|
209
|
+
"version": VERSION,
|
|
210
|
+
},
|
|
211
|
+
"metadata": {
|
|
212
|
+
"pyVersion": f"v{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
def _get_timestamp():
|
|
217
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _get_size(event: dict[str, any]) -> int:
|
|
221
|
+
try:
|
|
222
|
+
data = json.dumps(event)
|
|
223
|
+
return len(data.encode('utf-8'))
|
|
224
|
+
except (TypeError, OverflowError) as e:
|
|
225
|
+
logger.error(f"Error serializing event for size calculation: {e}")
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
# Signal types
|
|
229
|
+
SignalType = Literal["default", "feedback", "edit"]
|
|
230
|
+
|
|
231
|
+
def track_signal(
|
|
232
|
+
event_id: str,
|
|
233
|
+
name: str,
|
|
234
|
+
signal_type: Optional[SignalType] = "default",
|
|
235
|
+
timestamp: Optional[str] = None,
|
|
236
|
+
properties: Optional[Dict[str, any]] = None,
|
|
237
|
+
attachment_id: Optional[str] = None,
|
|
238
|
+
comment: Optional[str] = None,
|
|
239
|
+
after: Optional[str] = None,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""
|
|
242
|
+
Track a signal event.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
event_id: The ID of the event to attach the signal to
|
|
246
|
+
name: Name of the signal (e.g. "thumbs_up", "thumbs_down")
|
|
247
|
+
signal_type: Type of signal ("default", "feedback", or "edit")
|
|
248
|
+
timestamp: Optional timestamp for the signal
|
|
249
|
+
properties: Optional properties for the signal
|
|
250
|
+
attachment_id: Optional ID of an attachment
|
|
251
|
+
comment: Optional comment (only for feedback signals)
|
|
252
|
+
after: Optional after content (only for edit signals)
|
|
253
|
+
"""
|
|
254
|
+
if not _check_write_key():
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# Prepare properties with optional comment and after fields
|
|
258
|
+
signal_properties = properties or {}
|
|
259
|
+
if comment is not None:
|
|
260
|
+
signal_properties["comment"] = comment
|
|
261
|
+
if after is not None:
|
|
262
|
+
signal_properties["after"] = after
|
|
263
|
+
|
|
264
|
+
data = {
|
|
265
|
+
"event_id": event_id,
|
|
266
|
+
"signal_name": name,
|
|
267
|
+
"signal_type": signal_type,
|
|
268
|
+
"timestamp": timestamp if timestamp else _get_timestamp(),
|
|
269
|
+
"properties": signal_properties,
|
|
270
|
+
"attachment_id": attachment_id,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
size = _get_size(data)
|
|
274
|
+
if size > max_ingest_size_bytes:
|
|
275
|
+
logger.warning(
|
|
276
|
+
f"[raindrop] Events larger than {max_ingest_size_bytes / (1024 * 1024)} MB may have properties truncated - "
|
|
277
|
+
f"an event of size {size / (1024 * 1024):.2f} MB was logged"
|
|
278
|
+
)
|
|
279
|
+
return # Skip adding oversized events to buffer
|
|
280
|
+
|
|
281
|
+
save_to_buffer({"type": "signals/track", "data": data})
|
|
282
|
+
|
|
283
|
+
atexit.register(shutdown)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.0.19"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: raindrop-ai
|
|
3
|
+
Version: 0.0.19
|
|
4
|
+
Summary: Raindrop AI (Python SDK)
|
|
5
|
+
Home-page: https://raindrop.ai
|
|
6
|
+
Author: Raindrop AI
|
|
7
|
+
Author-email: sdk@raindrop.ai
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.6
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: requests
|
|
19
|
+
Dynamic: author
|
|
20
|
+
Dynamic: author-email
|
|
21
|
+
Dynamic: classifier
|
|
22
|
+
Dynamic: description
|
|
23
|
+
Dynamic: description-content-type
|
|
24
|
+
Dynamic: home-page
|
|
25
|
+
Dynamic: requires-dist
|
|
26
|
+
Dynamic: summary
|
|
27
|
+
|
|
28
|
+
For questions, email us at sdk@raindrop.ai
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
setup.py
|
|
3
|
+
raindrop/__init__.py
|
|
4
|
+
raindrop/analytics.py
|
|
5
|
+
raindrop/version.py
|
|
6
|
+
raindrop_ai.egg-info/PKG-INFO
|
|
7
|
+
raindrop_ai.egg-info/SOURCES.txt
|
|
8
|
+
raindrop_ai.egg-info/dependency_links.txt
|
|
9
|
+
raindrop_ai.egg-info/requires.txt
|
|
10
|
+
raindrop_ai.egg-info/top_level.txt
|
|
11
|
+
tests/test_analytics.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
raindrop
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from setuptools import setup, find_packages
|
|
5
|
+
|
|
6
|
+
# Don't import raindrop-ai module here, since deps may not be installed
|
|
7
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'raindrop'))
|
|
8
|
+
from version import VERSION
|
|
9
|
+
|
|
10
|
+
setup(
|
|
11
|
+
name="raindrop-ai",
|
|
12
|
+
version=VERSION,
|
|
13
|
+
description="Raindrop AI (Python SDK)",
|
|
14
|
+
author="Raindrop AI",
|
|
15
|
+
author_email="sdk@raindrop.ai",
|
|
16
|
+
long_description="For questions, email us at sdk@raindrop.ai",
|
|
17
|
+
long_description_content_type="text/markdown",
|
|
18
|
+
url="https://raindrop.ai",
|
|
19
|
+
packages=find_packages(include=["raindrop", "README.md"]),
|
|
20
|
+
install_requires=[
|
|
21
|
+
"requests",
|
|
22
|
+
],
|
|
23
|
+
classifiers=[
|
|
24
|
+
"Development Status :: 3 - Alpha",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Programming Language :: Python",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.6",
|
|
29
|
+
"Programming Language :: Python :: 3.7",
|
|
30
|
+
"Programming Language :: Python :: 3.8",
|
|
31
|
+
"Programming Language :: Python :: 3.9",
|
|
32
|
+
"Programming Language :: Python :: 3.10",
|
|
33
|
+
],
|
|
34
|
+
)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import unittest
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
import raindrop.analytics as analytics
|
|
5
|
+
from raindrop.version import VERSION
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestAnalytics(unittest.TestCase):
|
|
10
|
+
def setUp(self):
|
|
11
|
+
# Set up any necessary test data or configurations
|
|
12
|
+
analytics.write_key = "0000"
|
|
13
|
+
analytics.api_url = "http://localhost:3000/"
|
|
14
|
+
|
|
15
|
+
def tearDown(self):
|
|
16
|
+
# Clean up any resources or reset any state after each test
|
|
17
|
+
analytics.flush()
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
def test_identify(self):
|
|
21
|
+
with patch('requests.post') as mock_post:
|
|
22
|
+
user_id = "user123"
|
|
23
|
+
traits = {"email": "john@example.com", "name": "John"}
|
|
24
|
+
|
|
25
|
+
analytics.identify(user_id, traits)
|
|
26
|
+
analytics.flush() # Force flush to trigger request
|
|
27
|
+
|
|
28
|
+
# Verify the POST request was made
|
|
29
|
+
mock_post.assert_called_once()
|
|
30
|
+
|
|
31
|
+
# Get the data that was sent
|
|
32
|
+
call_args = mock_post.call_args
|
|
33
|
+
url = call_args[0][0]
|
|
34
|
+
data = call_args[1]['json'][0] # First event in the batch
|
|
35
|
+
|
|
36
|
+
# Verify URL and data
|
|
37
|
+
self.assertEqual(url, "http://localhost:3000/users/identify")
|
|
38
|
+
self.assertEqual(data['user_id'], user_id)
|
|
39
|
+
self.assertEqual(data['traits'], traits)
|
|
40
|
+
|
|
41
|
+
@patch('requests.post')
|
|
42
|
+
def test_track(self, mock_post):
|
|
43
|
+
# Test data
|
|
44
|
+
user_id = "user123"
|
|
45
|
+
event = "signed_up"
|
|
46
|
+
properties = {"plan": "Premium"}
|
|
47
|
+
|
|
48
|
+
# Track the event
|
|
49
|
+
analytics.track(user_id, event, properties)
|
|
50
|
+
|
|
51
|
+
# Force a flush to trigger the HTTP request
|
|
52
|
+
analytics.flush()
|
|
53
|
+
|
|
54
|
+
# Verify the POST request was made
|
|
55
|
+
mock_post.assert_called_once()
|
|
56
|
+
|
|
57
|
+
# Get the data that was sent
|
|
58
|
+
call_args = mock_post.call_args
|
|
59
|
+
url = call_args[0][0]
|
|
60
|
+
data = call_args[1]['json'][0] # First event in the batch
|
|
61
|
+
|
|
62
|
+
# Verify URL
|
|
63
|
+
self.assertEqual(url, "http://localhost:3000/events/track")
|
|
64
|
+
|
|
65
|
+
# Verify request structure
|
|
66
|
+
self.assertEqual(data['user_id'], user_id)
|
|
67
|
+
self.assertEqual(data['event'], event)
|
|
68
|
+
self.assertEqual(data['properties']['plan'], "Premium")
|
|
69
|
+
|
|
70
|
+
# Verify context data
|
|
71
|
+
self.assertEqual(data['properties']['$context']['library']['name'], "python-sdk")
|
|
72
|
+
self.assertEqual(data['properties']['$context']['library']['version'], VERSION)
|
|
73
|
+
self.assertEqual(
|
|
74
|
+
data['properties']['$context']['metadata']['pyVersion'],
|
|
75
|
+
f"v{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Verify other required fields
|
|
79
|
+
self.assertIn('event_id', data)
|
|
80
|
+
self.assertIn('timestamp', data)
|
|
81
|
+
|
|
82
|
+
@patch('requests.post')
|
|
83
|
+
def test_track_ai(self, mock_post):
|
|
84
|
+
# Test data
|
|
85
|
+
user_id = "user123"
|
|
86
|
+
event = "ai_completion"
|
|
87
|
+
model = "gpt-3.5"
|
|
88
|
+
user_input = "Hello"
|
|
89
|
+
output = "Hi there!"
|
|
90
|
+
convo_id = "conv123"
|
|
91
|
+
properties = {"temperature": 0.7}
|
|
92
|
+
|
|
93
|
+
# Track the AI event
|
|
94
|
+
analytics.track_ai(
|
|
95
|
+
user_id=user_id,
|
|
96
|
+
event=event,
|
|
97
|
+
model=model,
|
|
98
|
+
user_input=user_input,
|
|
99
|
+
output=output,
|
|
100
|
+
convo_id=convo_id,
|
|
101
|
+
properties=properties
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Force a flush
|
|
105
|
+
analytics.flush()
|
|
106
|
+
|
|
107
|
+
# Verify the POST request was made
|
|
108
|
+
mock_post.assert_called_once()
|
|
109
|
+
|
|
110
|
+
# Get the data that was sent
|
|
111
|
+
call_args = mock_post.call_args
|
|
112
|
+
data = call_args[1]['json'][0] # First event in the batch
|
|
113
|
+
|
|
114
|
+
# Verify AI-specific fields
|
|
115
|
+
self.assertEqual(data['ai_data']['model'], model)
|
|
116
|
+
self.assertEqual(data['ai_data']['input'], user_input)
|
|
117
|
+
self.assertEqual(data['ai_data']['output'], output)
|
|
118
|
+
self.assertEqual(data['ai_data']['convo_id'], convo_id)
|
|
119
|
+
|
|
120
|
+
# Verify common fields
|
|
121
|
+
self.assertEqual(data['user_id'], user_id)
|
|
122
|
+
self.assertEqual(data['event'], event)
|
|
123
|
+
self.assertEqual(data['properties']['temperature'], 0.7)
|
|
124
|
+
self.assertIn('event_id', data)
|
|
125
|
+
self.assertIn('timestamp', data)
|
|
126
|
+
|
|
127
|
+
def test_flush(self):
|
|
128
|
+
with patch('requests.post') as mock_post:
|
|
129
|
+
user_id = "user123"
|
|
130
|
+
event = "ai_chat"
|
|
131
|
+
model = "GPT-3"
|
|
132
|
+
input_text = "Hello"
|
|
133
|
+
output_text = "Hi there!"
|
|
134
|
+
|
|
135
|
+
analytics.track_ai(
|
|
136
|
+
user_id, event, model=model, user_input=input_text, output=output_text
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
analytics.flush() # Force flush
|
|
140
|
+
|
|
141
|
+
# Verify the buffer is empty after flush
|
|
142
|
+
self.assertEqual(len(analytics.buffer), 0)
|
|
143
|
+
|
|
144
|
+
# Verify the POST request was made
|
|
145
|
+
mock_post.assert_called_once()
|
|
146
|
+
|
|
147
|
+
def test_track_ai_with_size_limit(self):
|
|
148
|
+
with patch('requests.post') as mock_post:
|
|
149
|
+
user_id = "user123"
|
|
150
|
+
event = "ai_chat_test"
|
|
151
|
+
model = "GPT-3"
|
|
152
|
+
input_text = "Hello"
|
|
153
|
+
output_text = "Hi there!"
|
|
154
|
+
properties = {
|
|
155
|
+
"key": "v" * 10000,
|
|
156
|
+
"key2": "v" * 10000,
|
|
157
|
+
"key3": "v" * 1048576, # 1 MB of data (1024 * 1024 bytes)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# Capture logged output
|
|
161
|
+
with self.assertLogs('dawnai.analytics', level='WARNING') as log_capture:
|
|
162
|
+
analytics.track_ai(
|
|
163
|
+
user_id, event, model=model, user_input=input_text, output=output_text, properties=properties
|
|
164
|
+
)
|
|
165
|
+
analytics.flush() # Force flush
|
|
166
|
+
|
|
167
|
+
# Check the logged output
|
|
168
|
+
self.assertTrue(any("[dawn] Events larger than" in message for message in log_capture.output),
|
|
169
|
+
"Expected size warning is not logged")
|
|
170
|
+
|
|
171
|
+
# Verify no request was made since event was too large
|
|
172
|
+
mock_post.assert_not_called()
|
|
173
|
+
|
|
174
|
+
@patch('requests.post')
|
|
175
|
+
def test_track_signal(self, mock_post):
|
|
176
|
+
# Test basic signal tracking
|
|
177
|
+
event_id = "event123"
|
|
178
|
+
name = "thumbs_up"
|
|
179
|
+
signal_type = "feedback"
|
|
180
|
+
properties = {"rating": 5}
|
|
181
|
+
comment = "Great response!"
|
|
182
|
+
attachment_id = "attach123"
|
|
183
|
+
after = "Updated content"
|
|
184
|
+
|
|
185
|
+
# Track signal with all fields
|
|
186
|
+
analytics.track_signal(
|
|
187
|
+
event_id=event_id,
|
|
188
|
+
name=name,
|
|
189
|
+
signal_type=signal_type,
|
|
190
|
+
properties=properties,
|
|
191
|
+
comment=comment,
|
|
192
|
+
attachment_id=attachment_id,
|
|
193
|
+
after=after
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Force a flush
|
|
197
|
+
analytics.flush()
|
|
198
|
+
|
|
199
|
+
# Verify the POST request was made
|
|
200
|
+
mock_post.assert_called_once()
|
|
201
|
+
|
|
202
|
+
# Get the data that was sent
|
|
203
|
+
call_args = mock_post.call_args
|
|
204
|
+
url = call_args[0][0]
|
|
205
|
+
data = call_args[1]['json'][0] # First event in the batch
|
|
206
|
+
|
|
207
|
+
# Verify URL
|
|
208
|
+
self.assertEqual(url, "http://localhost:3000/signals/track")
|
|
209
|
+
|
|
210
|
+
# Verify signal data
|
|
211
|
+
self.assertEqual(data['event_id'], event_id)
|
|
212
|
+
self.assertEqual(data['signal_name'], name)
|
|
213
|
+
self.assertEqual(data['signal_type'], signal_type)
|
|
214
|
+
self.assertEqual(data['attachment_id'], attachment_id)
|
|
215
|
+
|
|
216
|
+
# Verify properties including comment and after
|
|
217
|
+
self.assertEqual(data['properties']['rating'], 5)
|
|
218
|
+
self.assertEqual(data['properties']['comment'], comment)
|
|
219
|
+
self.assertEqual(data['properties']['after'], after)
|
|
220
|
+
|
|
221
|
+
# Test size limit handling
|
|
222
|
+
mock_post.reset_mock()
|
|
223
|
+
large_properties = {
|
|
224
|
+
"key": "v" * 10000,
|
|
225
|
+
"key2": "v" * 10000,
|
|
226
|
+
"key3": "v" * 1048576, # 1 MB of data
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# Capture logged output for oversized event
|
|
230
|
+
with self.assertLogs('dawnai.analytics', level='WARNING') as log_capture:
|
|
231
|
+
analytics.track_signal(
|
|
232
|
+
event_id=event_id,
|
|
233
|
+
name=name,
|
|
234
|
+
properties=large_properties
|
|
235
|
+
)
|
|
236
|
+
analytics.flush()
|
|
237
|
+
|
|
238
|
+
# Check the logged output
|
|
239
|
+
self.assertTrue(any("[dawn] Events larger than" in message for message in log_capture.output),
|
|
240
|
+
"Expected size warning is not logged")
|
|
241
|
+
|
|
242
|
+
# Test different signal types
|
|
243
|
+
mock_post.reset_mock()
|
|
244
|
+
for signal_type in ["default", "feedback", "edit"]:
|
|
245
|
+
analytics.track_signal(
|
|
246
|
+
event_id=event_id,
|
|
247
|
+
name=name,
|
|
248
|
+
signal_type=signal_type
|
|
249
|
+
)
|
|
250
|
+
analytics.flush()
|
|
251
|
+
|
|
252
|
+
data = mock_post.call_args[1]['json'][0]
|
|
253
|
+
self.assertEqual(data['signal_type'], signal_type)
|
|
254
|
+
mock_post.reset_mock()
|