raindrop-ai 0.0.19__tar.gz → 0.0.21__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.21/PKG-INFO +41 -0
- raindrop_ai-0.0.21/README.md +24 -0
- raindrop_ai-0.0.21/pyproject.toml +21 -0
- raindrop_ai-0.0.21/raindrop/analytics.py +405 -0
- raindrop_ai-0.0.21/raindrop/interaction.py +49 -0
- raindrop_ai-0.0.21/raindrop/models.py +133 -0
- raindrop_ai-0.0.19/PKG-INFO +0 -28
- raindrop_ai-0.0.19/README.md +0 -3
- raindrop_ai-0.0.19/raindrop/analytics.py +0 -283
- raindrop_ai-0.0.19/raindrop_ai.egg-info/PKG-INFO +0 -28
- raindrop_ai-0.0.19/raindrop_ai.egg-info/SOURCES.txt +0 -11
- raindrop_ai-0.0.19/raindrop_ai.egg-info/dependency_links.txt +0 -1
- raindrop_ai-0.0.19/raindrop_ai.egg-info/requires.txt +0 -1
- raindrop_ai-0.0.19/raindrop_ai.egg-info/top_level.txt +0 -1
- raindrop_ai-0.0.19/setup.cfg +0 -4
- raindrop_ai-0.0.19/setup.py +0 -34
- raindrop_ai-0.0.19/tests/test_analytics.py +0 -254
- {raindrop_ai-0.0.19 → raindrop_ai-0.0.21}/raindrop/__init__.py +0 -0
- {raindrop_ai-0.0.19 → raindrop_ai-0.0.21}/raindrop/version.py +0 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: raindrop-ai
|
|
3
|
+
Version: 0.0.21
|
|
4
|
+
Summary: Raindrop AI (Python SDK)
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Raindrop AI
|
|
7
|
+
Author-email: sdk@raindrop.ai
|
|
8
|
+
Requires-Python: >=3.11, !=2.7.*, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*, !=3.7.*, !=3.8.*, !=3.9.*, !=3.10.*
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Dist: pydantic (>=2.11,<3)
|
|
14
|
+
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Raindrop Python SDK
|
|
18
|
+
|
|
19
|
+
## Installation dependencies
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install poetry
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
poetry install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
## Run tests
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
poetry run green -vv
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "raindrop-ai"
|
|
3
|
+
version = "0.0.21"
|
|
4
|
+
description = "Raindrop AI (Python SDK)"
|
|
5
|
+
authors = ["Raindrop AI <sdk@raindrop.ai>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
packages = [{include = "raindrop"}]
|
|
9
|
+
|
|
10
|
+
[tool.poetry.dependencies]
|
|
11
|
+
python = ">=3.11,<3.12.1 || >3.12.1,<4.0"
|
|
12
|
+
pydantic = ">=2.11,<3"
|
|
13
|
+
requests = "^2.32.3"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
[tool.poetry.group.dev.dependencies]
|
|
17
|
+
green = "^4.0.2"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["poetry-core"]
|
|
21
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import time
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Union, List, Dict, Optional, Literal, Any
|
|
5
|
+
import requests
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import logging
|
|
8
|
+
import json
|
|
9
|
+
import uuid
|
|
10
|
+
import atexit
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
from threading import Timer
|
|
13
|
+
from raindrop.version import VERSION
|
|
14
|
+
from raindrop.models import TrackAIEvent, Attachment, SignalEvent, DefaultSignal, FeedbackSignal, EditSignal, PartialTrackAIEvent, PartialAIData
|
|
15
|
+
from raindrop.interaction import Interaction
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Configure logging
|
|
20
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
write_key = None
|
|
24
|
+
api_url = "https://api.raindrop.ai/v1/"
|
|
25
|
+
max_queue_size = 10000
|
|
26
|
+
upload_size = 10
|
|
27
|
+
upload_interval = 1.0
|
|
28
|
+
buffer = []
|
|
29
|
+
flush_lock = threading.Lock()
|
|
30
|
+
debug_logs = False
|
|
31
|
+
flush_thread = None
|
|
32
|
+
shutdown_event = threading.Event()
|
|
33
|
+
max_ingest_size_bytes = 1 * 1024 * 1024 # 1 MB
|
|
34
|
+
|
|
35
|
+
_partial_buffers: dict[str, PartialTrackAIEvent] = {}
|
|
36
|
+
_partial_timers: dict[str, Timer] = {}
|
|
37
|
+
_PARTIAL_TIMEOUT = 2 # 2 seconds
|
|
38
|
+
|
|
39
|
+
def set_debug_logs(value: bool):
|
|
40
|
+
global debug_logs
|
|
41
|
+
debug_logs = value
|
|
42
|
+
if debug_logs:
|
|
43
|
+
logger.setLevel(logging.DEBUG)
|
|
44
|
+
else:
|
|
45
|
+
logger.setLevel(logging.INFO)
|
|
46
|
+
|
|
47
|
+
def start_flush_thread():
|
|
48
|
+
logger.debug("Opening flush thread")
|
|
49
|
+
global flush_thread
|
|
50
|
+
if flush_thread is None:
|
|
51
|
+
flush_thread = threading.Thread(target=flush_loop)
|
|
52
|
+
flush_thread.daemon = True
|
|
53
|
+
flush_thread.start()
|
|
54
|
+
|
|
55
|
+
def flush_loop():
|
|
56
|
+
while not shutdown_event.is_set():
|
|
57
|
+
try:
|
|
58
|
+
flush()
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.error(f"Error in flush loop: {e}")
|
|
61
|
+
time.sleep(upload_interval)
|
|
62
|
+
|
|
63
|
+
def flush() -> None:
|
|
64
|
+
global buffer
|
|
65
|
+
|
|
66
|
+
if buffer is None:
|
|
67
|
+
logger.error("No buffer available")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
logger.debug("Starting flush")
|
|
71
|
+
|
|
72
|
+
with flush_lock:
|
|
73
|
+
current_buffer = buffer
|
|
74
|
+
buffer = []
|
|
75
|
+
|
|
76
|
+
logger.debug(f"Flushing buffer size: {len(current_buffer)}")
|
|
77
|
+
|
|
78
|
+
grouped_events = {}
|
|
79
|
+
for event in current_buffer:
|
|
80
|
+
endpoint = event["type"]
|
|
81
|
+
data = event["data"]
|
|
82
|
+
if endpoint not in grouped_events:
|
|
83
|
+
grouped_events[endpoint] = []
|
|
84
|
+
grouped_events[endpoint].append(data)
|
|
85
|
+
|
|
86
|
+
for endpoint, events_data in grouped_events.items():
|
|
87
|
+
for i in range(0, len(events_data), upload_size):
|
|
88
|
+
batch = events_data[i:i+upload_size]
|
|
89
|
+
logger.debug(f"Sending {len(batch)} events to {endpoint}")
|
|
90
|
+
send_request(endpoint, batch)
|
|
91
|
+
|
|
92
|
+
logger.debug("Flush complete")
|
|
93
|
+
|
|
94
|
+
def send_request(endpoint: str, data_entries: List[Dict[str, Union[str, Dict]]]) -> None:
|
|
95
|
+
|
|
96
|
+
url = f"{api_url}{endpoint}"
|
|
97
|
+
headers = {
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
"Authorization": f"Bearer {write_key}",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
max_retries = 3
|
|
103
|
+
for attempt in range(max_retries):
|
|
104
|
+
try:
|
|
105
|
+
response = requests.post(url, json=data_entries, headers=headers)
|
|
106
|
+
response.raise_for_status()
|
|
107
|
+
logger.debug(f"Request successful: {response.status_code}")
|
|
108
|
+
break
|
|
109
|
+
except requests.exceptions.RequestException as e:
|
|
110
|
+
logger.error(f"Error sending request (attempt {attempt + 1}/{max_retries}): {e}")
|
|
111
|
+
if attempt == max_retries - 1:
|
|
112
|
+
logger.error(f"Failed to send request after {max_retries} attempts")
|
|
113
|
+
|
|
114
|
+
def save_to_buffer(event: Dict[str, Union[str, Dict]]) -> None:
|
|
115
|
+
global buffer
|
|
116
|
+
|
|
117
|
+
if len(buffer) >= max_queue_size * 0.8:
|
|
118
|
+
logger.warning(f"Buffer is at {len(buffer) / max_queue_size * 100:.2f}% capacity")
|
|
119
|
+
|
|
120
|
+
if len(buffer) >= max_queue_size:
|
|
121
|
+
logger.error("Buffer is full. Discarding event.")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
logger.debug(f"Adding event to buffer: {event}")
|
|
125
|
+
|
|
126
|
+
with flush_lock:
|
|
127
|
+
buffer.append(event)
|
|
128
|
+
|
|
129
|
+
start_flush_thread()
|
|
130
|
+
|
|
131
|
+
def identify(user_id: str, traits: Dict[str, Union[str, int, bool, float]]) -> None:
|
|
132
|
+
if not _check_write_key():
|
|
133
|
+
return
|
|
134
|
+
data = {"user_id": user_id, "traits": traits}
|
|
135
|
+
save_to_buffer({"type": "users/identify", "data": data})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def track_ai(
|
|
139
|
+
user_id: str,
|
|
140
|
+
event: str,
|
|
141
|
+
event_id: Optional[str] = None,
|
|
142
|
+
model: Optional[str] = None,
|
|
143
|
+
input: Optional[str] = None,
|
|
144
|
+
output: Optional[str] = None,
|
|
145
|
+
convo_id: Optional[str] = None,
|
|
146
|
+
properties: Optional[Dict[str, Union[str, int, bool, float]]] = None,
|
|
147
|
+
timestamp: Optional[str] = None,
|
|
148
|
+
attachments: Optional[List[Attachment]] = None,
|
|
149
|
+
) -> str:
|
|
150
|
+
if not _check_write_key():
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
event_id = event_id or str(uuid.uuid4())
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
payload = TrackAIEvent(
|
|
157
|
+
event_id=event_id,
|
|
158
|
+
user_id=user_id,
|
|
159
|
+
event=event,
|
|
160
|
+
timestamp=timestamp or _get_timestamp(),
|
|
161
|
+
properties=properties or {},
|
|
162
|
+
ai_data=dict( # Pydantic will coerce to AIData
|
|
163
|
+
model=model,
|
|
164
|
+
input=input,
|
|
165
|
+
output=output,
|
|
166
|
+
convo_id=convo_id,
|
|
167
|
+
),
|
|
168
|
+
attachments=attachments,
|
|
169
|
+
)
|
|
170
|
+
except ValidationError as err:
|
|
171
|
+
logger.error(f"[raindrop] Invalid data passed to track_ai: {err}")
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if payload.properties is None:
|
|
176
|
+
payload.properties = {}
|
|
177
|
+
payload.properties["$context"] = _get_context()
|
|
178
|
+
|
|
179
|
+
data = payload.model_dump()
|
|
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
|
+
for eid in list(_partial_timers.keys()):
|
|
195
|
+
_flush_partial_event(eid)
|
|
196
|
+
|
|
197
|
+
shutdown_event.set()
|
|
198
|
+
if flush_thread:
|
|
199
|
+
flush_thread.join(timeout=10)
|
|
200
|
+
flush() # Final flush to ensure all events are sent
|
|
201
|
+
|
|
202
|
+
def _check_write_key():
|
|
203
|
+
if write_key is None:
|
|
204
|
+
logger.warning("write_key is not set. Please set it before using raindrop analytics.")
|
|
205
|
+
return False
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
def _get_context():
|
|
209
|
+
return {
|
|
210
|
+
"library": {
|
|
211
|
+
"name": "python-sdk",
|
|
212
|
+
"version": VERSION,
|
|
213
|
+
},
|
|
214
|
+
"metadata": {
|
|
215
|
+
"pyVersion": f"v{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
def _get_timestamp():
|
|
220
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _get_size(event: dict[str, any]) -> int:
|
|
224
|
+
try:
|
|
225
|
+
# Add default=str to handle types like datetime
|
|
226
|
+
data = json.dumps(event, default=str)
|
|
227
|
+
return len(data.encode('utf-8'))
|
|
228
|
+
except (TypeError, OverflowError) as e:
|
|
229
|
+
logger.error(f"Error serializing event for size calculation: {e}")
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
# Signal types - This is now defined in models.py
|
|
233
|
+
# SignalType = Literal["default", "feedback", "edit"]
|
|
234
|
+
|
|
235
|
+
def track_signal(
|
|
236
|
+
event_id: str,
|
|
237
|
+
name: str,
|
|
238
|
+
signal_type: Literal["default", "feedback", "edit"] = "default",
|
|
239
|
+
timestamp: Optional[str] = None,
|
|
240
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
241
|
+
attachment_id: Optional[str] = None,
|
|
242
|
+
comment: Optional[str] = None,
|
|
243
|
+
after: Optional[str] = None
|
|
244
|
+
) -> None:
|
|
245
|
+
"""
|
|
246
|
+
Track a signal event.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
event_id: The ID of the event to attach the signal to
|
|
250
|
+
name: Name of the signal (e.g. "thumbs_up", "thumbs_down")
|
|
251
|
+
signal_type: Type of signal ("default", "feedback", or "edit")
|
|
252
|
+
timestamp: Optional timestamp for the signal (ISO 8601 format)
|
|
253
|
+
properties: Optional dictionary of additional properties.
|
|
254
|
+
attachment_id: Optional ID of an attachment
|
|
255
|
+
comment: Optional comment string (required and used only if signal_type is 'feedback').
|
|
256
|
+
after: Optional after content string (required and used only if signal_type is 'edit').
|
|
257
|
+
"""
|
|
258
|
+
if not _check_write_key():
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
# Prepare the final properties dictionary
|
|
262
|
+
final_properties = properties.copy() if properties else {}
|
|
263
|
+
if signal_type == "feedback" and comment is not None:
|
|
264
|
+
if "comment" in final_properties:
|
|
265
|
+
logger.warning("'comment' provided as both argument and in properties; argument value used.")
|
|
266
|
+
final_properties["comment"] = comment
|
|
267
|
+
elif signal_type == "edit" and after is not None:
|
|
268
|
+
if "after" in final_properties:
|
|
269
|
+
logger.warning("'after' provided as both argument and in properties; argument value used.")
|
|
270
|
+
final_properties["after"] = after
|
|
271
|
+
|
|
272
|
+
# Prepare base arguments for all signal types
|
|
273
|
+
base_args = {
|
|
274
|
+
"event_id": event_id,
|
|
275
|
+
"signal_name": name,
|
|
276
|
+
"timestamp": timestamp or _get_timestamp(),
|
|
277
|
+
"properties": final_properties,
|
|
278
|
+
"attachment_id": attachment_id,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
# Construct the specific signal model based on signal_type
|
|
283
|
+
if signal_type == "feedback":
|
|
284
|
+
payload = FeedbackSignal(**base_args, signal_type=signal_type)
|
|
285
|
+
elif signal_type == "edit":
|
|
286
|
+
payload = EditSignal(**base_args, signal_type=signal_type)
|
|
287
|
+
else: # signal_type == "default"
|
|
288
|
+
if comment is not None:
|
|
289
|
+
logger.warning("'comment' argument provided for non-feedback signal type; ignored.")
|
|
290
|
+
if after is not None:
|
|
291
|
+
logger.warning("'after' argument provided for non-edit signal type; ignored.")
|
|
292
|
+
payload = DefaultSignal(**base_args, signal_type=signal_type)
|
|
293
|
+
|
|
294
|
+
except ValidationError as err:
|
|
295
|
+
logger.error(f"[raindrop] Invalid data passed to track_signal: {err}")
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
# model_dump handles the timestamp correctly
|
|
299
|
+
data = payload.model_dump(mode='json')
|
|
300
|
+
|
|
301
|
+
size = _get_size(data)
|
|
302
|
+
if size > max_ingest_size_bytes:
|
|
303
|
+
logger.warning(
|
|
304
|
+
f"[raindrop] Events larger than {max_ingest_size_bytes / (1024 * 1024)} MB may have properties truncated - "
|
|
305
|
+
f"an event of size {size / (1024 * 1024):.2f} MB was logged"
|
|
306
|
+
)
|
|
307
|
+
return # Skip adding oversized events to buffer
|
|
308
|
+
|
|
309
|
+
save_to_buffer({"type": "signals/track", "data": data})
|
|
310
|
+
|
|
311
|
+
def begin(
|
|
312
|
+
user_id: str,
|
|
313
|
+
event: str,
|
|
314
|
+
event_id: str | None = None,
|
|
315
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
316
|
+
input: Optional[str] = None,
|
|
317
|
+
attachments: Optional[List[Attachment]] = None,
|
|
318
|
+
) -> Interaction:
|
|
319
|
+
"""
|
|
320
|
+
Starts (or resumes) an interaction and returns a helper object.
|
|
321
|
+
"""
|
|
322
|
+
eid = event_id or str(uuid.uuid4())
|
|
323
|
+
|
|
324
|
+
# Prepare ai_data if input is provided
|
|
325
|
+
ai_data_partial = None
|
|
326
|
+
if input:
|
|
327
|
+
ai_data_partial = PartialAIData(input=input)
|
|
328
|
+
|
|
329
|
+
# Combine properties with initial_fields, giving precedence to initial_fields if keys clash
|
|
330
|
+
final_properties = (properties or {}).copy()
|
|
331
|
+
|
|
332
|
+
partial_event = PartialTrackAIEvent(
|
|
333
|
+
event_id=eid,
|
|
334
|
+
user_id=user_id,
|
|
335
|
+
event=event,
|
|
336
|
+
ai_data=ai_data_partial,
|
|
337
|
+
properties=final_properties or None, # Pass None if empty, matching PartialTrackAIEvent defaults
|
|
338
|
+
attachments=attachments
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
_track_ai_partial(partial_event)
|
|
342
|
+
return Interaction(eid)
|
|
343
|
+
|
|
344
|
+
def resume_interaction(event_id: str) -> Interaction:
|
|
345
|
+
"""Return an Interaction wrapper for an existing eventId."""
|
|
346
|
+
return Interaction(event_id)
|
|
347
|
+
|
|
348
|
+
def _track_ai_partial(event: PartialTrackAIEvent) -> None:
|
|
349
|
+
"""
|
|
350
|
+
Merge the incoming patch into an in-memory doc and flush to backend:
|
|
351
|
+
• on `.finish()` (is_pending == False)
|
|
352
|
+
• or after 20 s of inactivity
|
|
353
|
+
"""
|
|
354
|
+
eid = event.event_id
|
|
355
|
+
|
|
356
|
+
# 1. merge
|
|
357
|
+
existing = _partial_buffers.get(eid, PartialTrackAIEvent(event_id=eid))
|
|
358
|
+
existing.is_pending = existing.is_pending if existing.is_pending is not None else True
|
|
359
|
+
merged_dict = existing.model_dump(exclude_none=True)
|
|
360
|
+
incoming = event.model_dump(exclude_none=True)
|
|
361
|
+
|
|
362
|
+
# deep merge ai_data / properties
|
|
363
|
+
def _deep(d: dict, u: dict):
|
|
364
|
+
for k, v in u.items():
|
|
365
|
+
d[k] = _deep(d.get(k, {}) if isinstance(v, dict) else v, v) if isinstance(v, dict) else v
|
|
366
|
+
return d
|
|
367
|
+
merged = _deep(merged_dict, incoming)
|
|
368
|
+
merged_obj = PartialTrackAIEvent(**merged)
|
|
369
|
+
|
|
370
|
+
_partial_buffers[eid] = merged_obj
|
|
371
|
+
|
|
372
|
+
# 2. timer handling
|
|
373
|
+
if t := _partial_timers.get(eid):
|
|
374
|
+
t.cancel()
|
|
375
|
+
if merged_obj.is_pending is False:
|
|
376
|
+
_flush_partial_event(eid)
|
|
377
|
+
else:
|
|
378
|
+
_partial_timers[eid] = Timer(_PARTIAL_TIMEOUT, _flush_partial_event, args=[eid])
|
|
379
|
+
_partial_timers[eid].daemon = True
|
|
380
|
+
_partial_timers[eid].start()
|
|
381
|
+
|
|
382
|
+
if debug_logs:
|
|
383
|
+
logger.debug(f"[raindrop] updated partial {eid}: {merged_obj.model_dump(exclude_none=True)}")
|
|
384
|
+
|
|
385
|
+
def _flush_partial_event(event_id: str) -> None:
|
|
386
|
+
"""
|
|
387
|
+
Send the accumulated patch as a single object to `events/track_partial`.
|
|
388
|
+
"""
|
|
389
|
+
if t := _partial_timers.pop(event_id, None):
|
|
390
|
+
t.cancel()
|
|
391
|
+
|
|
392
|
+
evt = _partial_buffers.pop(event_id, None)
|
|
393
|
+
if not evt:
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
# convert to ordinary TrackAIEvent-ish dict before send
|
|
397
|
+
data = evt.model_dump(mode="json", exclude_none=True)
|
|
398
|
+
size = _get_size(data)
|
|
399
|
+
if size > max_ingest_size_bytes:
|
|
400
|
+
logger.warning(f"[raindrop] partial event {event_id} > 1 MB; skipping")
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
send_request("events/track_partial", data)
|
|
404
|
+
|
|
405
|
+
atexit.register(shutdown)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Dict, List, Optional, Callable
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
from .models import Attachment, PartialTrackAIEvent
|
|
6
|
+
from . import analytics as _core
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Interaction:
|
|
10
|
+
"""
|
|
11
|
+
Thin helper returned by analytics.begin().
|
|
12
|
+
Each mutator just relays a partial update back to Analytics.
|
|
13
|
+
"""
|
|
14
|
+
__slots__ = ("_event_id", "_analytics")
|
|
15
|
+
|
|
16
|
+
def __init__(self, event_id: Optional[str] = None):
|
|
17
|
+
self._event_id = event_id or str(uuid4())
|
|
18
|
+
self._analytics = _core
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# -- mutators ----------------------------------------------------------- #
|
|
22
|
+
def set_input(self, text: str) -> None:
|
|
23
|
+
self._analytics._track_ai_partial(
|
|
24
|
+
PartialTrackAIEvent(event_id=self._event_id, ai_data={"input": text})
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def add_attachments(self, attachments: List[Attachment]) -> None:
|
|
28
|
+
self._analytics._track_ai_partial(
|
|
29
|
+
PartialTrackAIEvent(event_id=self._event_id, attachments=attachments)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def set_properties(self, props: Dict[str, Any]) -> None:
|
|
33
|
+
self._analytics._track_ai_partial(
|
|
34
|
+
PartialTrackAIEvent(event_id=self._event_id, properties=props)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def finish(self, *, output: str | None = None, **extra) -> None:
|
|
38
|
+
payload = PartialTrackAIEvent(
|
|
39
|
+
event_id=self._event_id,
|
|
40
|
+
ai_data={"output": output} if output is not None else None,
|
|
41
|
+
is_pending=False,
|
|
42
|
+
**extra,
|
|
43
|
+
)
|
|
44
|
+
self._analytics._track_ai_partial(payload)
|
|
45
|
+
|
|
46
|
+
# convenience
|
|
47
|
+
@property
|
|
48
|
+
def id(self) -> str:
|
|
49
|
+
return self._event_id
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, ValidationError, model_validator, field_validator
|
|
2
|
+
from typing import Any, Optional, Dict, Literal, List, Union
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
class _Base(BaseModel):
|
|
6
|
+
model_config = dict(extra="forbid", validate_default=True)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Attachment(BaseModel):
|
|
10
|
+
type: Literal["code", "text", "image", "iframe"]
|
|
11
|
+
value: str # URL, raw code, etc.
|
|
12
|
+
name: Optional[str] = None # e.g. "Generated SQL"
|
|
13
|
+
role: Optional[Literal["input", "output", "context"]] = None
|
|
14
|
+
language: Optional[str] = None # for code snippets
|
|
15
|
+
|
|
16
|
+
@model_validator(mode="after")
|
|
17
|
+
def _require_value(cls, values):
|
|
18
|
+
if not values.value:
|
|
19
|
+
raise ValueError("value must be non-empty.")
|
|
20
|
+
return values
|
|
21
|
+
|
|
22
|
+
class AIData(_Base):
|
|
23
|
+
model: Optional[str]
|
|
24
|
+
input: Optional[str]
|
|
25
|
+
output: Optional[str]
|
|
26
|
+
convo_id: Optional[str]
|
|
27
|
+
|
|
28
|
+
@model_validator(mode="after")
|
|
29
|
+
def _require_input_or_output(cls, values):
|
|
30
|
+
if not (values.input or values.output):
|
|
31
|
+
raise ValueError("Either 'input' or 'output' must be non-empty.")
|
|
32
|
+
return values
|
|
33
|
+
|
|
34
|
+
class TrackAIEvent(_Base):
|
|
35
|
+
event_id: Optional[str] = None
|
|
36
|
+
user_id: str
|
|
37
|
+
event: str
|
|
38
|
+
ai_data: AIData
|
|
39
|
+
properties: Dict[str, Any] = Field(default_factory=dict)
|
|
40
|
+
timestamp: datetime = Field(
|
|
41
|
+
default_factory=lambda: datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
42
|
+
)
|
|
43
|
+
attachments: Optional[List[Attachment]] = None
|
|
44
|
+
|
|
45
|
+
# Ensure user_id and event are non-empty strings
|
|
46
|
+
@field_validator("user_id", "event")
|
|
47
|
+
def _non_empty(cls, v, info):
|
|
48
|
+
if v is None or (isinstance(v, str) and v.strip() == ""):
|
|
49
|
+
raise ValueError(f"'{info.field_name}' must be a non-empty string.")
|
|
50
|
+
return v
|
|
51
|
+
|
|
52
|
+
# No need to duplicate input/output check here; AIData already enforces it
|
|
53
|
+
# but keep method to return values unchanged so that pydantic doesn't complain about unused return
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- Signal Tracking Models --- #
|
|
57
|
+
|
|
58
|
+
class BaseSignal(_Base):
|
|
59
|
+
"""Base model for signal events, containing common fields."""
|
|
60
|
+
event_id: str
|
|
61
|
+
signal_name: str
|
|
62
|
+
timestamp: datetime = Field(
|
|
63
|
+
# Return a datetime object; Pydantic's model_dump will handle serialization to string
|
|
64
|
+
default_factory=lambda: datetime.now(timezone.utc).replace(microsecond=0)
|
|
65
|
+
)
|
|
66
|
+
properties: Dict[str, Any] = Field(default_factory=dict)
|
|
67
|
+
attachment_id: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
@field_validator("event_id", "signal_name")
|
|
70
|
+
def _non_empty_strings(cls, v, info):
|
|
71
|
+
if not isinstance(v, str) or not v.strip():
|
|
72
|
+
raise ValueError(f"'{info.field_name}' must be a non-empty string.")
|
|
73
|
+
return v
|
|
74
|
+
|
|
75
|
+
class DefaultSignal(BaseSignal):
|
|
76
|
+
"""Model for default signal events."""
|
|
77
|
+
signal_type: Literal["default"] = "default"
|
|
78
|
+
|
|
79
|
+
class FeedbackSignal(BaseSignal):
|
|
80
|
+
"""Model for feedback signal events, requiring a comment."""
|
|
81
|
+
signal_type: Literal["feedback"]
|
|
82
|
+
|
|
83
|
+
@model_validator(mode="after")
|
|
84
|
+
def _check_comment_in_properties(cls, values):
|
|
85
|
+
# Check properties safely after potential initialization
|
|
86
|
+
# Use getattr to safely access properties, returning None if not present
|
|
87
|
+
props = getattr(values, 'properties', None)
|
|
88
|
+
if not isinstance(props, dict):
|
|
89
|
+
raise ValueError("'properties' must be a dictionary for feedback signals.")
|
|
90
|
+
comment = props.get("comment")
|
|
91
|
+
if not comment or not isinstance(comment, str) or not comment.strip():
|
|
92
|
+
raise ValueError("'properties' must contain a non-empty string 'comment' for feedback signals.")
|
|
93
|
+
return values
|
|
94
|
+
|
|
95
|
+
class EditSignal(BaseSignal):
|
|
96
|
+
"""Model for edit signal events, requiring after content."""
|
|
97
|
+
signal_type: Literal["edit"]
|
|
98
|
+
|
|
99
|
+
@model_validator(mode="after")
|
|
100
|
+
def _check_after_in_properties(cls, values):
|
|
101
|
+
# Check properties safely after potential initialization
|
|
102
|
+
props = getattr(values, 'properties', None)
|
|
103
|
+
if not isinstance(props, dict):
|
|
104
|
+
raise ValueError("'properties' must be a dictionary for edit signals.")
|
|
105
|
+
after = props.get("after")
|
|
106
|
+
if not after or not isinstance(after, str) or not after.strip():
|
|
107
|
+
raise ValueError("'properties' must contain a non-empty string 'after' for edit signals.")
|
|
108
|
+
return values
|
|
109
|
+
|
|
110
|
+
# Discriminated Union for Signal Events
|
|
111
|
+
# Pydantic will automatically use the 'signal_type' field to determine which model to use.
|
|
112
|
+
SignalEvent = Union[DefaultSignal, FeedbackSignal, EditSignal]
|
|
113
|
+
|
|
114
|
+
# --- End Signal Tracking Models --- #
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PartialAIData(_Base):
|
|
118
|
+
"""Looser version for incremental updates."""
|
|
119
|
+
model: Optional[str] = None
|
|
120
|
+
input: Optional[str] = None
|
|
121
|
+
output: Optional[str] = None
|
|
122
|
+
convo_id: Optional[str] = None
|
|
123
|
+
|
|
124
|
+
class PartialTrackAIEvent(_Base):
|
|
125
|
+
"""Accepts *any subset* of TrackAIEvent fields."""
|
|
126
|
+
event_id: str # always required for merge-key
|
|
127
|
+
user_id: Optional[str] = None
|
|
128
|
+
event: Optional[str] = None
|
|
129
|
+
ai_data: Optional[PartialAIData] = None
|
|
130
|
+
timestamp: Optional[datetime] = None
|
|
131
|
+
properties: Optional[Dict[str, Any]] = None
|
|
132
|
+
attachments: Optional[List[Attachment]] = None
|
|
133
|
+
is_pending: Optional[bool] = True
|
raindrop_ai-0.0.19/PKG-INFO
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
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
|
raindrop_ai-0.0.19/README.md
DELETED
|
@@ -1,283 +0,0 @@
|
|
|
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)
|
|
@@ -1,28 +0,0 @@
|
|
|
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
|
|
@@ -1,11 +0,0 @@
|
|
|
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
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
requests
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
raindrop
|
raindrop_ai-0.0.19/setup.cfg
DELETED
raindrop_ai-0.0.19/setup.py
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
)
|
|
@@ -1,254 +0,0 @@
|
|
|
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()
|
|
File without changes
|
|
File without changes
|