Skip to content

Commit 8b0ad84

Browse files
authored
feat(sdk): client, annotations (#2452)
1 parent 36dd9b3 commit 8b0ad84

File tree

11 files changed

+399
-36
lines changed

11 files changed

+399
-36
lines changed

packages/sample-app/sample_app/methods_decorated_app.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,17 @@ def joke_workflow():
6868
eng_joke = create_joke()
6969
pirate_joke = translate_joke_to_pirate(eng_joke)
7070
signature = generate_signature(pirate_joke)
71-
print(pirate_joke + "\n\n" + signature)
71+
72+
traceloop_client = Traceloop.get()
73+
traceloop_client.user_feedback.create(
74+
"sample-annotation-task",
75+
"user_12345",
76+
{"sentiment": "positive", "score": 0.95, "tones": ["happy", "surprised"]},
77+
)
78+
79+
result = pirate_joke + "\n\n" + signature
80+
print(result)
81+
return result
7282

7383

7484
joke_workflow()
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import pytest
2+
from traceloop.sdk.client import Client
3+
from traceloop.sdk.client.http import HTTPClient
4+
from traceloop.sdk.annotation.user_feedback import UserFeedback
5+
6+
7+
def test_client_initialization():
8+
"""Test basic client initialization"""
9+
client = Client(api_key="test-key", app_name="test-app")
10+
11+
assert client.app_name == "test-app"
12+
assert client.api_key == "test-key"
13+
assert client.api_endpoint == "https://api.traceloop.com"
14+
assert isinstance(client._http, HTTPClient)
15+
16+
17+
def test_client_custom_endpoint():
18+
"""Test client initialization with custom endpoint"""
19+
client = Client(api_key="test-key", app_name="test-app", api_endpoint="https://custom.endpoint.com")
20+
21+
assert client.api_endpoint == "https://custom.endpoint.com"
22+
assert client._http.base_url == "https://custom.endpoint.com"
23+
24+
25+
def test_client_default_app_name():
26+
"""Test client initialization with default app_name"""
27+
client = Client(api_key="test-key")
28+
29+
# Default app_name should be sys.argv[0]
30+
import sys
31+
32+
assert client.app_name == sys.argv[0]
33+
34+
35+
@pytest.mark.parametrize("api_key", [None, "", " "])
36+
def test_client_requires_api_key(api_key):
37+
"""Test that client requires a valid API key"""
38+
with pytest.raises(ValueError, match="API key is required"):
39+
Client(api_key=api_key)
40+
41+
42+
def test_user_feedback_initialization():
43+
"""Test user_feedback is properly initialized"""
44+
client = Client(api_key="test-key", app_name="test-app")
45+
46+
assert isinstance(client.user_feedback, UserFeedback)
47+
assert client.user_feedback._http == client._http
48+
assert client.user_feedback._app_name == client.app_name
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Tests for the UserFeedback class.
3+
4+
These tests verify:
5+
1. Proper initialization of UserFeedback instances
6+
2. Basic feedback submission with minimal parameters
7+
3. Handling of complex tag structures
8+
4. Proper API endpoint construction and payload formatting
9+
"""
10+
11+
import pytest
12+
from unittest.mock import Mock
13+
from traceloop.sdk.annotation.user_feedback import UserFeedback
14+
from traceloop.sdk.client.http import HTTPClient
15+
16+
17+
@pytest.fixture
18+
def mock_http():
19+
"""Create a mock HTTP client"""
20+
http = Mock(spec=HTTPClient)
21+
http.post.return_value = {"status": "success"}
22+
return http
23+
24+
25+
@pytest.fixture
26+
def user_feedback(mock_http):
27+
"""Create a UserFeedback instance with mock HTTP client"""
28+
return UserFeedback(mock_http, "test-app")
29+
30+
31+
def test_user_feedback_initialization(mock_http):
32+
"""Test UserFeedback is properly initialized"""
33+
feedback = UserFeedback(mock_http, "test-app")
34+
assert feedback._http == mock_http
35+
assert feedback._app_name == "test-app"
36+
37+
38+
def test_create_basic_feedback(user_feedback, mock_http):
39+
"""Test creating basic user feedback"""
40+
user_feedback.create(
41+
annotation_task="task_123", entity_id="instance_456", tags={"sentiment": "positive"}
42+
)
43+
44+
mock_http.post.assert_called_once_with(
45+
"annotation-tasks/task_123/annotations",
46+
{
47+
"entity_instance_id": "instance_456",
48+
"tags": {"sentiment": "positive"},
49+
"source": "sdk",
50+
"flow": "user_feedback",
51+
"actor": {
52+
"type": "service",
53+
"id": "test-app",
54+
},
55+
},
56+
)
57+
58+
59+
def test_create_feedback_complex_tags(user_feedback, mock_http):
60+
"""Test creating user feedback with complex tags"""
61+
tags = {"sentiment": "positive", "relevance": 0.95, "tones": ["happy", "nice"]}
62+
63+
user_feedback.create(annotation_task="task_123", entity_id="instance_456", tags=tags)
64+
65+
mock_http.post.assert_called_once_with(
66+
"annotation-tasks/task_123/annotations",
67+
{
68+
"entity_instance_id": "instance_456",
69+
"tags": tags,
70+
"source": "sdk",
71+
"flow": "user_feedback",
72+
"actor": {
73+
"type": "service",
74+
"id": "test-app",
75+
},
76+
},
77+
)
78+
79+
80+
def test_create_feedback_parameter_validation(user_feedback):
81+
"""Test parameter validation for feedback creation"""
82+
with pytest.raises(ValueError, match="annotation_task is required"):
83+
user_feedback.create(annotation_task="", entity_id="instance_456", tags={"sentiment": "positive"})
84+
85+
with pytest.raises(ValueError, match="entity_id is required"):
86+
user_feedback.create(annotation_task="task_123", entity_id="", tags={"sentiment": "positive"})
87+
88+
with pytest.raises(ValueError, match="tags cannot be empty"):
89+
user_feedback.create(annotation_task="task_123", entity_id="instance_456", tags={})

packages/traceloop-sdk/traceloop/sdk/__init__.py

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
set_external_prompt_tracing_context,
3131
)
3232
from typing import Dict
33+
from .client import Client
3334

3435

3536
class Traceloop:
@@ -39,31 +40,33 @@ class Traceloop:
3940
AUTO_CREATED_URL = str(Path.home() / ".cache" / "traceloop" / "auto_created_url")
4041

4142
__tracer_wrapper: TracerWrapper
42-
__fetcher: Fetcher = None
43+
__fetcher: Optional[Fetcher] = None
44+
__app_name: Optional[str] = None
45+
__client: Optional[Client] = None
4346

4447
@staticmethod
4548
def init(
46-
app_name: Optional[str] = sys.argv[0],
49+
app_name: str = sys.argv[0],
4750
api_endpoint: str = "https://api.traceloop.com",
48-
api_key: str = None,
51+
api_key: Optional[str] = None,
4952
enabled: bool = True,
5053
headers: Dict[str, str] = {},
5154
disable_batch=False,
5255
telemetry_enabled: bool = True,
53-
exporter: SpanExporter = None,
56+
exporter: Optional[SpanExporter] = None,
5457
metrics_exporter: MetricExporter = None,
5558
metrics_headers: Dict[str, str] = None,
5659
logging_exporter: LogExporter = None,
5760
logging_headers: Dict[str, str] = None,
58-
processor: SpanProcessor = None,
61+
processor: Optional[SpanProcessor] = None,
5962
propagator: TextMapPropagator = None,
6063
traceloop_sync_enabled: bool = False,
6164
should_enrich_metrics: bool = True,
6265
resource_attributes: dict = {},
6366
instruments: Optional[Set[Instruments]] = None,
6467
block_instruments: Optional[Set[Instruments]] = None,
6568
image_uploader: Optional[ImageUploader] = None,
66-
) -> None:
69+
) -> Optional[Client]:
6770
if not enabled:
6871
TracerWrapper.set_disabled(True)
6972
print(
@@ -82,13 +85,14 @@ def init(
8285

8386
api_endpoint = os.getenv("TRACELOOP_BASE_URL") or api_endpoint
8487
api_key = os.getenv("TRACELOOP_API_KEY") or api_key
88+
Traceloop.__app_name = app_name
8589

8690
if (
8791
traceloop_sync_enabled
8892
and api_endpoint.find("traceloop.com") != -1
8993
and api_key
90-
and not exporter
91-
and not processor
94+
and (exporter is None)
95+
and (processor is None)
9296
):
9397
Traceloop.__fetcher = Fetcher(base_url=api_endpoint, api_key=api_key)
9498
Traceloop.__fetcher.run()
@@ -186,32 +190,32 @@ def init(
186190
)
187191
Traceloop.__logger_wrapper = LoggerWrapper(exporter=logging_exporter)
188192

193+
if not api_key:
194+
return
195+
Traceloop.__client = Client(api_key=api_key, app_name=app_name, api_endpoint=api_endpoint)
196+
return Traceloop.__client
197+
189198
def set_association_properties(properties: dict) -> None:
190199
set_association_properties(properties)
191200

192201
def set_prompt(template: str, variables: dict, version: int):
193202
set_external_prompt_tracing_context(template, variables, version)
194203

195-
def report_score(
196-
association_property_name: str,
197-
association_property_id: str,
198-
score: float,
199-
):
200-
if not Traceloop.__fetcher:
201-
print(
202-
Fore.RED
203-
+ "Error: Cannot report score. Missing Traceloop API key,"
204-
+ " go to https://app.traceloop.com/settings/api-keys to create one"
204+
@staticmethod
205+
def get():
206+
"""
207+
Returns the shared SDK client instance, using the current global configuration.
208+
209+
To use the SDK as a singleton, first make sure you have called :func:`Traceloop.init()`
210+
at startup time. Then ``get()`` will return the same shared :class:`Traceloop.client.Client`
211+
instance each time. The client will be initialized if it has not been already.
212+
213+
If you need to create multiple client instances with different configurations, instead of this
214+
singleton approach you can call the :class:`Traceloop.client.Client` constructor directly instead.
215+
"""
216+
if not Traceloop.__client:
217+
raise Exception(
218+
"Client not initialized, you should call Traceloop.init() first. "
219+
"If you are still getting this error - you are missing the api key"
205220
)
206-
print("Set the TRACELOOP_API_KEY environment variable to the key")
207-
print(Fore.RESET)
208-
return
209-
210-
Traceloop.__fetcher.post(
211-
"score",
212-
{
213-
"entity_name": f"traceloop.association.properties.{association_property_name}",
214-
"entity_id": association_property_id,
215-
"score": score,
216-
},
217-
)
221+
return Traceloop.__client
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .base_annotation import BaseAnnotation
2+
from .user_feedback import UserFeedback
3+
4+
__all__ = ["BaseAnnotation", "UserFeedback"]
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from typing import Dict, Any
2+
3+
from ..client.http import HTTPClient
4+
5+
6+
class BaseAnnotation:
7+
"""
8+
Annotation class for creating annotations in Traceloop.
9+
10+
This class provides functionality to create annotations for specific tasks.
11+
"""
12+
13+
_http: HTTPClient
14+
_app_name: str
15+
16+
def __init__(self, http: HTTPClient, app_name: str, flow: str):
17+
self._http = http
18+
self._app_name = app_name
19+
self._flow = flow
20+
21+
def create(
22+
self,
23+
annotation_task: str,
24+
entity_id: str,
25+
tags: Dict[str, Any],
26+
) -> None:
27+
"""Create an user feedback annotation for a specific task.
28+
29+
Args:
30+
annotation_task (str): The ID/slug of the annotation task to report to.
31+
Can be found at app.traceloop.com/annotation_tasks/:annotation_task_id
32+
entity_id (str): The ID of the specific entity instance being annotated, should be reported
33+
in the association properties
34+
tags (Dict[str, Any]): Dictionary containing the tags to be reported.
35+
Should match the tags defined in the annotation task
36+
37+
Example:
38+
```python
39+
client = Client(api_key="your-key")
40+
client.annotation.create(
41+
annotation_task="task_123",
42+
entity_id="instance_456",
43+
tags={
44+
"sentiment": "positive",
45+
"relevance": 0.95,
46+
"tones": ["happy", "nice"]
47+
},
48+
)
49+
```
50+
"""
51+
52+
if not annotation_task:
53+
raise ValueError("annotation_task is required")
54+
if not entity_id:
55+
raise ValueError("entity_id is required")
56+
if not tags:
57+
raise ValueError("tags cannot be empty")
58+
59+
self._http.post(
60+
f"annotation-tasks/{annotation_task}/annotations",
61+
{
62+
"entity_instance_id": entity_id,
63+
"tags": tags,
64+
"source": "sdk",
65+
"flow": self._flow,
66+
"actor": {
67+
"type": "service",
68+
"id": self._app_name,
69+
},
70+
},
71+
)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from typing import Any, Dict
2+
3+
from traceloop.sdk.client.http import HTTPClient
4+
from .base_annotation import BaseAnnotation
5+
6+
7+
class UserFeedback(BaseAnnotation):
8+
def __init__(self, http: HTTPClient, app_name: str):
9+
super().__init__(http, app_name, "user_feedback")
10+
11+
12+
def create(
13+
self,
14+
annotation_task: str,
15+
entity_instance_id: str,
16+
tags: Dict[str, Any],
17+
) -> None:
18+
"""Create an annotation for a specific task.
19+
20+
Args:
21+
annotation_task (str): The ID/slug of the annotation task to report to.
22+
Can be found at app.traceloop.com/annotation_tasks/:annotation_task_id
23+
entity_instance_id (str): The ID of the specific entity instance being annotated, should be reported
24+
in the association properties
25+
tags (Dict[str, Any]): Dictionary containing the tags to be reported.
26+
Should match the tags defined in the annotation task
27+
28+
Example:
29+
```python
30+
client = Client(api_key="your-key")
31+
client.annotation.create(
32+
annotation_task="task_123",
33+
entity_instance_id="instance_456",
34+
tags={
35+
"sentiment": "positive",
36+
"relevance": 0.95,
37+
"tones": ["happy", "nice"]
38+
},
39+
)
40+
```
41+
"""
42+
43+
return BaseAnnotation.create(self, annotation_task, entity_instance_id, tags)

0 commit comments

Comments
 (0)