raindrop-ai 0.0.28__py3-none-any.whl → 0.0.30__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.
raindrop/analytics.py CHANGED
@@ -1,7 +1,9 @@
1
1
  import sys
2
2
  import time
3
3
  import threading
4
- from typing import Union, List, Dict, Optional, Literal, Any
4
+ import os
5
+ from contextlib import contextmanager
6
+ from typing import Callable, Union, List, Dict, Optional, Literal, Any
5
7
  import requests
6
8
  from datetime import datetime, timezone
7
9
  import logging
@@ -23,6 +25,39 @@ from raindrop.models import (
23
25
  )
24
26
  from raindrop.interaction import Interaction
25
27
  from raindrop.redact import perform_pii_redaction
28
+ import weakref
29
+ import urllib.parse
30
+
31
+ from traceloop.sdk import Traceloop
32
+ from traceloop.sdk.tracing.tracing import TracerWrapper
33
+ from opentelemetry.trace import get_current_span
34
+ from traceloop.sdk.decorators import (
35
+ task as tlp_task,
36
+ workflow as tlp_workflow,
37
+ TraceloopSpanKindValues,
38
+ F,
39
+ )
40
+
41
+ __all__ = [
42
+ # Configuration functions
43
+ "set_debug_logs",
44
+ "set_redact_pii",
45
+ "init",
46
+
47
+ "identify",
48
+ "track_ai",
49
+ "track_signal",
50
+ "begin",
51
+ "resume_interaction",
52
+
53
+ "interaction",
54
+ "task",
55
+ "tool",
56
+ "set_span_properties",
57
+
58
+ "flush",
59
+ "shutdown",
60
+ ]
26
61
 
27
62
 
28
63
  # Configure logging
@@ -40,6 +75,7 @@ buffer = []
40
75
  flush_lock = threading.Lock()
41
76
  debug_logs = False
42
77
  redact_pii = False
78
+ _tracing_enabled = False
43
79
  flush_thread = None
44
80
  shutdown_event = threading.Event()
45
81
  max_ingest_size_bytes = 1 * 1024 * 1024 # 1 MB
@@ -236,6 +272,11 @@ def shutdown():
236
272
  if flush_thread:
237
273
  flush_thread.join(timeout=10)
238
274
  flush() # Final flush to ensure all events are sent
275
+ if _tracing_enabled:
276
+ try:
277
+ TracerWrapper().flush()
278
+ except Exception as e:
279
+ logger.debug(f"Could not flush TracerWrapper during shutdown: {e}")
239
280
 
240
281
 
241
282
  def _check_write_key():
@@ -365,6 +406,14 @@ def track_signal(
365
406
  save_to_buffer({"type": "signals/track", "data": data})
366
407
 
367
408
 
409
+ INTERACTION_TRACE_ID_REGISTRY: weakref.WeakValueDictionary[int, Interaction] = (
410
+ weakref.WeakValueDictionary()
411
+ )
412
+ INTERACTION_EVENT_ID_REGISTRY: weakref.WeakValueDictionary[str, Interaction] = (
413
+ weakref.WeakValueDictionary()
414
+ )
415
+
416
+
368
417
  def begin(
369
418
  user_id: str,
370
419
  event: str,
@@ -387,6 +436,10 @@ def begin(
387
436
  # Combine properties with initial_fields, giving precedence to initial_fields if keys clash
388
437
  final_properties = (properties or {}).copy()
389
438
 
439
+ current_trace_id = _safe_current_trace_id()
440
+ if current_trace_id is not None:
441
+ final_properties["trace_id"] = f"{current_trace_id:032x}"
442
+
390
443
  partial_event = PartialTrackAIEvent(
391
444
  event_id=eid,
392
445
  user_id=user_id,
@@ -397,13 +450,145 @@ def begin(
397
450
  attachments=attachments,
398
451
  )
399
452
 
453
+ span_attributes = {
454
+ "user_id": user_id,
455
+ "convo_id": convo_id,
456
+ "event": event,
457
+ "event_id": eid,
458
+ }
459
+ if _tracing_enabled:
460
+ Traceloop.set_association_properties(
461
+ {k: v for k, v in span_attributes.items() if v is not None}
462
+ )
463
+
464
+ interaction = Interaction(eid)
465
+ INTERACTION_EVENT_ID_REGISTRY[eid] = interaction
466
+ if current_trace_id is not None and current_trace_id != 0:
467
+ INTERACTION_TRACE_ID_REGISTRY[current_trace_id] = interaction
468
+
400
469
  _track_ai_partial(partial_event)
401
- return Interaction(eid)
470
+ return interaction
471
+
472
+
473
+ @contextmanager
474
+ def _temp_env(key: str, value: str):
475
+ """Temporarily sets an environment variable. Hacky helper to deal with traceloop's BS"""
476
+ orig = os.environ.get(key)
477
+ os.environ[key] = value
478
+ try:
479
+ yield
480
+ finally:
481
+ if orig is None:
482
+ del os.environ[key]
483
+ else:
484
+ os.environ[key] = orig
485
+
486
+
487
+ def init(
488
+ api_key: str,
489
+ tracing_enabled: bool = False,
490
+ **traceloop_kwargs,
491
+ ):
492
+ """Initialize Raindrop with Traceloop integration."""
493
+ global write_key
494
+ write_key = api_key
495
+
496
+ global _tracing_enabled
497
+ _tracing_enabled = tracing_enabled
498
+
499
+ if not _tracing_enabled:
500
+ return
501
+
502
+ parsed_url = urllib.parse.urlparse(api_url)
503
+ api_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}"
504
+
505
+ with _temp_env("TRACELOOP_METRICS_ENABLED", "false"):
506
+ Traceloop.init(
507
+ api_endpoint=api_endpoint,
508
+ api_key=api_key,
509
+ telemetry_enabled=False,
510
+ **traceloop_kwargs,
511
+ )
512
+
513
+
514
+ def _safe_current_trace_id() -> int | None:
515
+ """Return current trace id or None if unavailable."""
516
+ try:
517
+ trace_id = get_current_span().get_span_context().trace_id
518
+ except Exception:
519
+ return None
520
+ return trace_id if trace_id else None
521
+
522
+
523
+ def interaction(
524
+ name: Optional[str] = None,
525
+ version: Optional[int] = None,
526
+ method_name: Optional[str] = None,
527
+ ) -> Callable[[F], F]:
528
+ return tlp_workflow(
529
+ name=name,
530
+ version=version,
531
+ method_name=method_name,
532
+ tlp_span_kind=TraceloopSpanKindValues.WORKFLOW,
533
+ )
534
+
535
+
536
+ def task(
537
+ name: Optional[str] = None,
538
+ version: Optional[int] = None,
539
+ method_name: Optional[str] = None,
540
+ tlp_span_kind: Optional[TraceloopSpanKindValues] = TraceloopSpanKindValues.TASK,
541
+ ) -> Callable[[F], F]:
542
+ return tlp_task(
543
+ name=name,
544
+ version=version,
545
+ method_name=method_name,
546
+ tlp_span_kind=tlp_span_kind,
547
+ )
548
+
549
+
550
+ def tool(
551
+ name: Optional[str] = None,
552
+ version: Optional[int] = None,
553
+ method_name: Optional[str] = None,
554
+ ) -> Callable[[F], F]:
555
+ return tlp_task(
556
+ name=name,
557
+ version=version,
558
+ method_name=method_name,
559
+ tlp_span_kind=TraceloopSpanKindValues.TOOL,
560
+ )
561
+
562
+
563
+ def set_span_properties(properties: Dict[str, Any]) -> None:
564
+ """
565
+ Set association properties on the current span for tracing.
566
+
567
+ Args:
568
+ properties: Dictionary of properties to associate with the current span
569
+ """
570
+ if not _tracing_enabled:
571
+ return
572
+
573
+ Traceloop.set_association_properties(properties)
574
+
575
+
576
+ def resume_interaction(event_id: str | None = None) -> Interaction:
577
+ """Return an Interaction associated with the current trace or given event_id."""
578
+
579
+ if event_id is not None:
580
+ if (interaction := INTERACTION_EVENT_ID_REGISTRY.get(event_id)) is not None:
581
+ return interaction
582
+ return Interaction(event_id)
402
583
 
584
+ if (trace_id := _safe_current_trace_id()) is not None:
585
+ if (interaction := INTERACTION_TRACE_ID_REGISTRY.get(trace_id)) is not None:
586
+ return interaction
403
587
 
404
- def resume_interaction(event_id: str) -> Interaction:
405
- """Return an Interaction wrapper for an existing eventId."""
406
- return Interaction(event_id)
588
+ # Fallback: create a fresh Interaction when no identifiers are available
589
+ # TODO: Return No-Op interaction if event_id is None
590
+ logger.debug("No interaction found, creating a new one")
591
+ return Interaction()
407
592
 
408
593
 
409
594
  def _track_ai_partial(event: PartialTrackAIEvent) -> None:
raindrop/interaction.py CHANGED
@@ -1,9 +1,18 @@
1
1
  from __future__ import annotations
2
- from typing import Any, Dict, List, Optional, Callable
2
+ from typing import (
3
+ Any,
4
+ Dict,
5
+ List,
6
+ Optional,
7
+ Union,
8
+ Iterator,
9
+ )
3
10
  from uuid import uuid4
11
+ from dataclasses import dataclass
4
12
 
5
13
  from .models import Attachment, PartialTrackAIEvent
6
14
  from . import analytics as _core
15
+ from opentelemetry import context as context_api
7
16
 
8
17
 
9
18
  class Interaction:
@@ -11,12 +20,19 @@ class Interaction:
11
20
  Thin helper returned by analytics.begin().
12
21
  Each mutator just relays a partial update back to Analytics.
13
22
  """
14
- __slots__ = ("_event_id", "_analytics")
15
23
 
16
- def __init__(self, event_id: Optional[str] = None):
24
+ __slots__ = (
25
+ "_event_id",
26
+ "_analytics",
27
+ "__weakref__",
28
+ )
29
+
30
+ def __init__(
31
+ self,
32
+ event_id: Optional[str] = None,
33
+ ):
17
34
  self._event_id = event_id or str(uuid4())
18
35
  self._analytics = _core
19
-
20
36
 
21
37
  # -- mutators ----------------------------------------------------------- #
22
38
  def set_input(self, text: str) -> None:
@@ -34,7 +50,11 @@ class Interaction:
34
50
  PartialTrackAIEvent(event_id=self._event_id, properties=props)
35
51
  )
36
52
 
53
+ def set_property(self, key: str, value: Any) -> None:
54
+ self.set_properties({key: value})
55
+
37
56
  def finish(self, *, output: str | None = None, **extra) -> None:
57
+
38
58
  payload = PartialTrackAIEvent(
39
59
  event_id=self._event_id,
40
60
  ai_data={"output": output} if output is not None else None,
@@ -46,4 +66,4 @@ class Interaction:
46
66
  # convenience
47
67
  @property
48
68
  def id(self) -> str:
49
- return self._event_id
69
+ return self._event_id
raindrop/models.py CHANGED
@@ -1,6 +1,8 @@
1
1
  from pydantic import BaseModel, Field, ValidationError, model_validator, field_validator
2
2
  from typing import Any, Optional, Dict, Literal, List, Union
3
3
  from datetime import datetime, timezone
4
+ from dataclasses import dataclass, field
5
+
4
6
 
5
7
  class _Base(BaseModel):
6
8
  model_config = dict(extra="forbid", validate_default=True)
@@ -8,10 +10,10 @@ class _Base(BaseModel):
8
10
 
9
11
  class Attachment(BaseModel):
10
12
  type: Literal["code", "text", "image", "iframe"]
11
- value: str # URL, raw code, etc.
12
- name: Optional[str] = None # e.g. "Generated SQL"
13
+ value: str # URL, raw code, etc.
14
+ name: Optional[str] = None # e.g. "Generated SQL"
13
15
  role: Optional[Literal["input", "output", "context"]] = None
14
- language: Optional[str] = None # for code snippets
16
+ language: Optional[str] = None # for code snippets
15
17
 
16
18
  @model_validator(mode="after")
17
19
  def _require_value(cls, values):
@@ -19,6 +21,7 @@ class Attachment(BaseModel):
19
21
  raise ValueError("value must be non-empty.")
20
22
  return values
21
23
 
24
+
22
25
  class AIData(_Base):
23
26
  model: Optional[str]
24
27
  input: Optional[str]
@@ -30,7 +33,8 @@ class AIData(_Base):
30
33
  if not (values.input or values.output):
31
34
  raise ValueError("Either 'input' or 'output' must be non-empty.")
32
35
  return values
33
-
36
+
37
+
34
38
  class TrackAIEvent(_Base):
35
39
  event_id: Optional[str] = None
36
40
  user_id: str
@@ -38,7 +42,9 @@ class TrackAIEvent(_Base):
38
42
  ai_data: AIData
39
43
  properties: Dict[str, Any] = Field(default_factory=dict)
40
44
  timestamp: datetime = Field(
41
- default_factory=lambda: datetime.now(timezone.utc).replace(microsecond=0).isoformat()
45
+ default_factory=lambda: datetime.now(timezone.utc)
46
+ .replace(microsecond=0)
47
+ .isoformat()
42
48
  )
43
49
  attachments: Optional[List[Attachment]] = None
44
50
 
@@ -55,8 +61,10 @@ class TrackAIEvent(_Base):
55
61
 
56
62
  # --- Signal Tracking Models --- #
57
63
 
64
+
58
65
  class BaseSignal(_Base):
59
66
  """Base model for signal events, containing common fields."""
67
+
60
68
  event_id: str
61
69
  signal_name: str
62
70
  timestamp: datetime = Field(
@@ -73,41 +81,52 @@ class BaseSignal(_Base):
73
81
  raise ValueError(f"'{info.field_name}' must be a non-empty string.")
74
82
  return v
75
83
 
84
+
76
85
  class DefaultSignal(BaseSignal):
77
86
  """Model for default signal events."""
87
+
78
88
  signal_type: Literal["default"] = "default"
79
89
 
90
+
80
91
  class FeedbackSignal(BaseSignal):
81
92
  """Model for feedback signal events, requiring a comment."""
93
+
82
94
  signal_type: Literal["feedback"]
83
95
 
84
96
  @model_validator(mode="after")
85
97
  def _check_comment_in_properties(cls, values):
86
98
  # Check properties safely after potential initialization
87
99
  # Use getattr to safely access properties, returning None if not present
88
- props = getattr(values, 'properties', None)
100
+ props = getattr(values, "properties", None)
89
101
  if not isinstance(props, dict):
90
- raise ValueError("'properties' must be a dictionary for feedback signals.")
102
+ raise ValueError("'properties' must be a dictionary for feedback signals.")
91
103
  comment = props.get("comment")
92
104
  if not comment or not isinstance(comment, str) or not comment.strip():
93
- raise ValueError("'properties' must contain a non-empty string 'comment' for feedback signals.")
105
+ raise ValueError(
106
+ "'properties' must contain a non-empty string 'comment' for feedback signals."
107
+ )
94
108
  return values
95
109
 
110
+
96
111
  class EditSignal(BaseSignal):
97
112
  """Model for edit signal events, requiring after content."""
113
+
98
114
  signal_type: Literal["edit"]
99
115
 
100
116
  @model_validator(mode="after")
101
117
  def _check_after_in_properties(cls, values):
102
118
  # Check properties safely after potential initialization
103
- props = getattr(values, 'properties', None)
119
+ props = getattr(values, "properties", None)
104
120
  if not isinstance(props, dict):
105
- raise ValueError("'properties' must be a dictionary for edit signals.")
121
+ raise ValueError("'properties' must be a dictionary for edit signals.")
106
122
  after = props.get("after")
107
123
  if not after or not isinstance(after, str) or not after.strip():
108
- raise ValueError("'properties' must contain a non-empty string 'after' for edit signals.")
124
+ raise ValueError(
125
+ "'properties' must contain a non-empty string 'after' for edit signals."
126
+ )
109
127
  return values
110
128
 
129
+
111
130
  # Discriminated Union for Signal Events
112
131
  # Pydantic will automatically use the 'signal_type' field to determine which model to use.
113
132
  SignalEvent = Union[DefaultSignal, FeedbackSignal, EditSignal]
@@ -117,18 +136,21 @@ SignalEvent = Union[DefaultSignal, FeedbackSignal, EditSignal]
117
136
 
118
137
  class PartialAIData(_Base):
119
138
  """Looser version for incremental updates."""
139
+
120
140
  model: Optional[str] = None
121
141
  input: Optional[str] = None
122
142
  output: Optional[str] = None
123
- convo_id: Optional[str] = None
143
+ convo_id: Optional[str] = None
144
+
124
145
 
125
146
  class PartialTrackAIEvent(_Base):
126
147
  """Accepts *any subset* of TrackAIEvent fields."""
127
- event_id: str # always required for merge-key
148
+
149
+ event_id: str # always required for merge-key
128
150
  user_id: Optional[str] = None
129
151
  event: Optional[str] = None
130
152
  ai_data: Optional[PartialAIData] = None
131
153
  timestamp: Optional[datetime] = None
132
154
  properties: Optional[Dict[str, Any]] = None
133
155
  attachments: Optional[List[Attachment]] = None
134
- is_pending: Optional[bool] = True
156
+ is_pending: Optional[bool] = True
raindrop/version.py CHANGED
@@ -1 +1 @@
1
- VERSION = "0.0.28"
1
+ VERSION = "0.0.30"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: raindrop-ai
3
- Version: 0.0.28
3
+ Version: 0.0.30
4
4
  Summary: Raindrop AI (Python SDK)
5
5
  License: MIT
6
6
  Author: Raindrop AI
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Requires-Dist: pydantic (>=2.09,<3)
15
15
  Requires-Dist: requests (>=2.32.3,<3.0.0)
16
+ Requires-Dist: traceloop-sdk (==0.45.6)
16
17
  Description-Content-Type: text/markdown
17
18
 
18
19
  # Raindrop Python SDK
@@ -31,6 +32,25 @@ poetry install
31
32
 
32
33
  ## Run tests
33
34
 
35
+ ### Using pytest (recommended)
36
+ ```bash
37
+ # Run all tests
38
+ poetry run pytest
39
+
40
+ # Run tests with verbose output
41
+ poetry run pytest -v
42
+
43
+ # Run specific test file
44
+ poetry run pytest tests/test_trace_attributes.py
45
+
46
+ # Run specific test
47
+ poetry run pytest tests/test_trace_attributes.py::TestTraceAttributes::test_user_id_attribute_direct
48
+
49
+ # Run tests with coverage
50
+ poetry run pytest --cov=raindrop
51
+ ```
52
+
53
+ ### Using green (legacy)
34
54
  ```bash
35
55
  poetry run green -vv
36
56
  ```
@@ -0,0 +1,10 @@
1
+ raindrop/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ raindrop/analytics.py,sha256=-n9vKdsTkiSid_y11bxSUl0sOLZ7drqgD9ahGOpO68E,19548
3
+ raindrop/interaction.py,sha256=1gYFj2-oEobLtG56RZw5de3RjYr0pCXg_NqYDGx32Uc,1896
4
+ raindrop/models.py,sha256=dEhwTtBEgwOOW_qaPEYugCLeOqvDXuBpEfi7vUANVK0,5361
5
+ raindrop/redact.py,sha256=rMNUoI90KxOY3d_zcHAr0TFD2yQ_CDgpDz-1XJLVmHs,7658
6
+ raindrop/version.py,sha256=3Utuv7Oo1v_YaWRMBX5-yd895qkotgnwqFPSZDHaL1g,19
7
+ raindrop/well-known-names.json,sha256=9giJF6u6W1R0APW-Pf1dvNUU32OXQEoQ9CBQXSnA3ks,144403
8
+ raindrop_ai-0.0.30.dist-info/METADATA,sha256=uM1mTZbxJ6ZlVjRGpsmnrWiAEXKZ4PQ3mJv8KZQSNGM,1269
9
+ raindrop_ai-0.0.30.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
10
+ raindrop_ai-0.0.30.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- raindrop/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- raindrop/analytics.py,sha256=T7oUrbtZmB0CkC2Xlp9mm_ue7UvqmnTe8w5SMhr3kKc,14618
3
- raindrop/interaction.py,sha256=U8OapN5tZGSMVkn-dTLum5sIg_AgSYKX9lrtwgXJOuI,1613
4
- raindrop/models.py,sha256=8YKuyB34P7sfawPFL3jOt5RgYlNXzf9dEsj8_2rjZKU,5293
5
- raindrop/redact.py,sha256=rMNUoI90KxOY3d_zcHAr0TFD2yQ_CDgpDz-1XJLVmHs,7658
6
- raindrop/version.py,sha256=1s8fJcG0T-ks-jhi7sPF29aUmHv4p8qr2zQ-eevL20k,19
7
- raindrop/well-known-names.json,sha256=9giJF6u6W1R0APW-Pf1dvNUU32OXQEoQ9CBQXSnA3ks,144403
8
- raindrop_ai-0.0.28.dist-info/METADATA,sha256=rGpUHqWn6m_cueg4pdBvznT8fQCX1DMu2CtWT8AzsXM,815
9
- raindrop_ai-0.0.28.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
10
- raindrop_ai-0.0.28.dist-info/RECORD,,