Skip to content

Commit 1670889

Browse files
authored
Fix listing traces and add tests (#121)
1 parent 0ed5513 commit 1670889

File tree

2 files changed

+297
-4
lines changed

2 files changed

+297
-4
lines changed

quotientai/resources/tracing.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def list(
9999
List traces with optional filtering parameters.
100100
101101
Args:
102-
time_range: Optional time range filter (e.g., "1d", "1h", "1m")
102+
time_range: Optional time range filter (e.g., "1d", "30m", "6M"). Valid units: "d" for days, "m" for minutes, "M" for months. Hours are not supported.
103103
app_name: Optional app name filter
104104
environments: Optional list of environments to filter by
105105
compress: Whether to request compressed response
@@ -111,10 +111,23 @@ def list(
111111
params = {}
112112
if time_range:
113113
params["time_range"] = time_range
114-
# convert time range from 1d / 1h / 1m to 1 DAY / 1 HOUR / 1 MINUTE, months to MONTHS
115-
params["time_range"] = params["time_range"].replace("d", " DAY").replace("h", " HOUR").replace("m", " MINUTE").replace("M", " MONTHS")
116-
# add a space between the number and the unit
114+
# First add spaces between numbers and units using regex
117115
params["time_range"] = re.sub(r'(\d+)([a-zA-Z]+)', r'\1 \2', params["time_range"])
116+
# Then convert time range with proper pluralization
117+
# Extract number and unit, then convert with pluralization
118+
match = re.match(r'(\d+)\s+([a-zA-Z]+)', params["time_range"])
119+
if match:
120+
number = int(match.group(1))
121+
unit = match.group(2)
122+
123+
# Convert units with pluralization
124+
if unit == "d":
125+
params["time_range"] = f"{number} {'DAY' if number == 1 else 'DAYS'}"
126+
elif unit == "m":
127+
params["time_range"] = f"{number} {'MINUTE' if number == 1 else 'MINUTES'}"
128+
elif unit == "M":
129+
params["time_range"] = f"{number} {'MONTH' if number == 1 else 'MONTHS'}"
130+
# Note: 'h' (hours) is not a valid unit, so we don't handle it
118131

119132
if app_name:
120133
params["app_name"] = app_name

tests/resources/test_tracing.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import pytest
2+
from datetime import datetime
3+
from unittest.mock import Mock, patch
4+
from quotientai.resources.tracing import Trace, Traces, TracesResource
5+
6+
7+
# Fixtures
8+
@pytest.fixture
9+
def mock_client():
10+
return Mock()
11+
12+
13+
@pytest.fixture
14+
def traces_resource(mock_client):
15+
return TracesResource(mock_client)
16+
17+
18+
@pytest.fixture
19+
def sample_trace_data():
20+
return {
21+
"trace_id": "test-trace-id",
22+
"root_span": {"span_id": "root-1"},
23+
"total_duration_ms": 1500.5,
24+
"start_time": "2024-01-01T00:00:00Z",
25+
"end_time": "2024-01-01T00:00:01Z",
26+
"span_list": [{"span_id": "span-1"}, {"span_id": "span-2"}],
27+
}
28+
29+
30+
# Trace Tests
31+
class TestTrace:
32+
"""Tests for the Trace dataclass"""
33+
34+
def test_trace_creation(self, sample_trace_data):
35+
"""Test basic creation of Trace"""
36+
trace = Trace(
37+
trace_id=sample_trace_data["trace_id"],
38+
root_span=sample_trace_data["root_span"],
39+
total_duration_ms=sample_trace_data["total_duration_ms"],
40+
start_time=datetime.fromisoformat(sample_trace_data["start_time"].replace('Z', '+00:00')),
41+
end_time=datetime.fromisoformat(sample_trace_data["end_time"].replace('Z', '+00:00')),
42+
span_list=sample_trace_data["span_list"],
43+
)
44+
assert trace.trace_id == "test-trace-id"
45+
assert trace.total_duration_ms == 1500.5
46+
assert len(trace.span_list) == 2
47+
48+
def test_trace_with_defaults(self):
49+
"""Test Trace creation with default values"""
50+
trace = Trace(trace_id="test-id")
51+
assert trace.trace_id == "test-id"
52+
assert trace.root_span is None
53+
assert trace.total_duration_ms == 0
54+
assert trace.start_time is None
55+
assert trace.end_time is None
56+
assert trace.span_list == []
57+
58+
def test_trace_rich_repr(self, sample_trace_data):
59+
"""Test Trace rich representation"""
60+
trace = Trace(
61+
trace_id=sample_trace_data["trace_id"],
62+
total_duration_ms=sample_trace_data["total_duration_ms"],
63+
)
64+
# Test that rich_repr method exists and works
65+
repr_items = list(trace.__rich_repr__())
66+
assert any(item[0] == "id" and item[1] == "test-trace-id" for item in repr_items)
67+
assert any(item[0] == "total_duration_ms" and item[1] == 1500.5 for item in repr_items)
68+
69+
70+
# Traces Tests
71+
class TestTraces:
72+
"""Tests for the Traces container class"""
73+
74+
def test_traces_creation(self, sample_trace_data):
75+
"""Test basic creation of Traces container"""
76+
trace = Trace(**sample_trace_data)
77+
traces = Traces(data=[trace], count=1)
78+
assert traces.count == 1
79+
assert len(traces.data) == 1
80+
assert traces.data[0].trace_id == "test-trace-id"
81+
82+
def test_traces_repr(self, sample_trace_data):
83+
"""Test Traces string representation"""
84+
trace = Trace(**sample_trace_data)
85+
traces = Traces(data=[trace], count=1)
86+
repr_str = repr(traces)
87+
assert "count=1" in repr_str
88+
assert "Trace" in repr_str
89+
90+
def test_traces_to_jsonl(self, sample_trace_data, tmp_path):
91+
"""Test Traces JSON Lines export"""
92+
# Create trace with proper datetime objects
93+
trace = Trace(
94+
trace_id=sample_trace_data["trace_id"],
95+
root_span=sample_trace_data["root_span"],
96+
total_duration_ms=sample_trace_data["total_duration_ms"],
97+
start_time=datetime.fromisoformat(sample_trace_data["start_time"].replace('Z', '+00:00')),
98+
end_time=datetime.fromisoformat(sample_trace_data["end_time"].replace('Z', '+00:00')),
99+
span_list=sample_trace_data["span_list"],
100+
)
101+
traces = Traces(data=[trace], count=1)
102+
103+
# Test string output
104+
jsonl_data = traces.to_jsonl()
105+
assert "test-trace-id" in jsonl_data
106+
107+
# Test file output
108+
filename = tmp_path / "traces.jsonl"
109+
jsonl_data = traces.to_jsonl(filename=str(filename))
110+
assert filename.exists()
111+
with open(filename, 'r') as f:
112+
content = f.read()
113+
assert "test-trace-id" in content
114+
115+
116+
# TracesResource Tests
117+
class TestTracesResource:
118+
"""Tests for the TracesResource class"""
119+
120+
def test_traces_resource_creation(self, mock_client):
121+
"""Test TracesResource initialization"""
122+
resource = TracesResource(mock_client)
123+
assert resource._client == mock_client
124+
125+
@patch('quotientai.resources.tracing.logger')
126+
def test_list_traces_time_range_conversion_singular(self, mock_logger, traces_resource, sample_trace_data):
127+
"""Test time range conversion with singular units"""
128+
# Mock the client response
129+
traces_resource._client._get.return_value = {
130+
"traces": [sample_trace_data]
131+
}
132+
133+
# Test singular units
134+
result = traces_resource.list(time_range="1d")
135+
136+
# Verify the time_range was converted correctly
137+
traces_resource._client._get.assert_called_once()
138+
call_args = traces_resource._client._get.call_args
139+
params = call_args[1]['params']
140+
assert params["time_range"] == "1 DAY"
141+
142+
# Test other singular units
143+
traces_resource._client._get.reset_mock()
144+
traces_resource._client._get.return_value = {"traces": [sample_trace_data]}
145+
result = traces_resource.list(time_range="1m")
146+
call_args = traces_resource._client._get.call_args
147+
params = call_args[1]['params']
148+
assert params["time_range"] == "1 MINUTE"
149+
150+
traces_resource._client._get.reset_mock()
151+
traces_resource._client._get.return_value = {"traces": [sample_trace_data]}
152+
result = traces_resource.list(time_range="1M")
153+
call_args = traces_resource._client._get.call_args
154+
params = call_args[1]['params']
155+
assert params["time_range"] == "1 MONTH"
156+
157+
@patch('quotientai.resources.tracing.logger')
158+
def test_list_traces_time_range_conversion_plural(self, mock_logger, traces_resource, sample_trace_data):
159+
"""Test time range conversion with plural units"""
160+
# Mock the client response
161+
traces_resource._client._get.return_value = {
162+
"traces": [sample_trace_data]
163+
}
164+
165+
# Test plural units
166+
result = traces_resource.list(time_range="30m")
167+
168+
# Verify the time_range was converted correctly
169+
traces_resource._client._get.assert_called_once()
170+
call_args = traces_resource._client._get.call_args
171+
params = call_args[1]['params']
172+
assert params["time_range"] == "30 MINUTES"
173+
174+
# Test other plural units
175+
traces_resource._client._get.reset_mock()
176+
traces_resource._client._get.return_value = {"traces": [sample_trace_data]}
177+
result = traces_resource.list(time_range="2d")
178+
call_args = traces_resource._client._get.call_args
179+
params = call_args[1]['params']
180+
assert params["time_range"] == "2 DAYS"
181+
182+
traces_resource._client._get.reset_mock()
183+
traces_resource._client._get.return_value = {"traces": [sample_trace_data]}
184+
result = traces_resource.list(time_range="6M")
185+
call_args = traces_resource._client._get.call_args
186+
params = call_args[1]['params']
187+
assert params["time_range"] == "6 MONTHS"
188+
189+
@patch('quotientai.resources.tracing.logger')
190+
def test_list_traces_time_range_no_conversion(self, mock_logger, traces_resource, sample_trace_data):
191+
"""Test that time range without valid units is not converted"""
192+
# Mock the client response
193+
traces_resource._client._get.return_value = {
194+
"traces": [sample_trace_data]
195+
}
196+
197+
# Test with hours (not a valid unit)
198+
result = traces_resource.list(time_range="2h")
199+
200+
# Verify the time_range was not converted (should remain as "2 h" after regex spacing)
201+
traces_resource._client._get.assert_called_once()
202+
call_args = traces_resource._client._get.call_args
203+
params = call_args[1]['params']
204+
assert params["time_range"] == "2 h"
205+
206+
# Test with no time_range
207+
traces_resource._client._get.reset_mock()
208+
traces_resource._client._get.return_value = {"traces": [sample_trace_data]}
209+
result = traces_resource.list()
210+
call_args = traces_resource._client._get.call_args
211+
params = call_args[1]['params']
212+
assert "time_range" not in params
213+
214+
@patch('quotientai.resources.tracing.logger')
215+
def test_list_traces_with_other_params(self, mock_logger, traces_resource, sample_trace_data):
216+
"""Test list traces with other parameters"""
217+
# Mock the client response
218+
traces_resource._client._get.return_value = {
219+
"traces": [sample_trace_data]
220+
}
221+
222+
# Test with all parameters
223+
result = traces_resource.list(
224+
time_range="1d",
225+
app_name="test-app",
226+
environments=["prod", "staging"],
227+
compress=True
228+
)
229+
230+
# Verify all parameters were passed correctly
231+
traces_resource._client._get.assert_called_once()
232+
call_args = traces_resource._client._get.call_args
233+
params = call_args[1]['params']
234+
assert params["time_range"] == "1 DAY"
235+
assert params["app_name"] == "test-app"
236+
assert params["environments"] == ["prod", "staging"]
237+
assert params["compress"] == "true"
238+
239+
@patch('quotientai.resources.tracing.logger')
240+
def test_get_trace(self, mock_logger, traces_resource, sample_trace_data):
241+
"""Test getting a specific trace"""
242+
# Mock the client response
243+
traces_resource._client._get.return_value = sample_trace_data
244+
245+
# Test getting a trace
246+
result = traces_resource.get("test-trace-id")
247+
248+
# Verify the correct endpoint was called
249+
traces_resource._client._get.assert_called_once_with("/traces/test-trace-id")
250+
251+
# Verify the result
252+
assert result.trace_id == "test-trace-id"
253+
assert result.total_duration_ms == 1500.5
254+
assert len(result.span_list) == 2
255+
256+
@patch('quotientai.resources.tracing.logger')
257+
def test_list_traces_error_handling(self, mock_logger, traces_resource):
258+
"""Test error handling in list traces"""
259+
# Mock the client to raise an exception
260+
traces_resource._client._get.side_effect = Exception("API Error")
261+
262+
# Test that the exception is logged and re-raised
263+
with pytest.raises(Exception, match="API Error"):
264+
traces_resource.list(time_range="1d")
265+
266+
# Verify error was logged
267+
mock_logger.error.assert_called_once()
268+
269+
@patch('quotientai.resources.tracing.logger')
270+
def test_get_trace_error_handling(self, mock_logger, traces_resource):
271+
"""Test error handling in get trace"""
272+
# Mock the client to raise an exception
273+
traces_resource._client._get.side_effect = Exception("API Error")
274+
275+
# Test that the exception is logged and re-raised
276+
with pytest.raises(Exception, match="API Error"):
277+
traces_resource.get("test-trace-id")
278+
279+
# Verify error was logged
280+
mock_logger.error.assert_called_once()

0 commit comments

Comments
 (0)