kairo-code 0.1.0__py3-none-any.whl → 0.2.0__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.
- kairo/backend/api/agents.py +337 -16
- kairo/backend/app.py +84 -4
- kairo/backend/config.py +4 -2
- kairo/backend/models/agent.py +216 -2
- kairo/backend/models/api_key.py +4 -1
- kairo/backend/models/task.py +31 -0
- kairo/backend/models/user_provider_key.py +26 -0
- kairo/backend/schemas/agent.py +249 -2
- kairo/backend/schemas/api_key.py +3 -0
- kairo/backend/services/agent/__init__.py +52 -0
- kairo/backend/services/agent/agent_alerts_evaluation_service.py +224 -0
- kairo/backend/services/agent/agent_alerts_service.py +201 -0
- kairo/backend/services/agent/agent_commands_service.py +142 -0
- kairo/backend/services/agent/agent_crud_service.py +150 -0
- kairo/backend/services/agent/agent_events_service.py +103 -0
- kairo/backend/services/agent/agent_heartbeat_service.py +207 -0
- kairo/backend/services/agent/agent_metrics_rollup_service.py +248 -0
- kairo/backend/services/agent/agent_metrics_service.py +259 -0
- kairo/backend/services/agent/agent_service.py +315 -0
- kairo/backend/services/agent/agent_setup_service.py +180 -0
- kairo/backend/services/agent/constants.py +28 -0
- kairo/backend/services/agent_service.py +18 -102
- kairo/backend/services/api_key_service.py +23 -3
- kairo/backend/services/byok_service.py +204 -0
- kairo/backend/services/chat_service.py +398 -63
- kairo/backend/services/deep_search_service.py +159 -0
- kairo/backend/services/email_service.py +418 -19
- kairo/backend/services/few_shot_service.py +223 -0
- kairo/backend/services/post_processor.py +261 -0
- kairo/backend/services/rag_service.py +150 -0
- kairo/backend/services/task_service.py +119 -0
- kairo/backend/tests/__init__.py +1 -0
- kairo/backend/tests/e2e/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/conftest.py +389 -0
- kairo/backend/tests/e2e/agents/test_agent_alerts.py +802 -0
- kairo/backend/tests/e2e/agents/test_agent_commands.py +456 -0
- kairo/backend/tests/e2e/agents/test_agent_crud.py +455 -0
- kairo/backend/tests/e2e/agents/test_agent_events.py +415 -0
- kairo/backend/tests/e2e/agents/test_agent_heartbeat.py +520 -0
- kairo/backend/tests/e2e/agents/test_agent_metrics.py +587 -0
- kairo/backend/tests/e2e/agents/test_agent_setup.py +349 -0
- kairo/migrations/versions/010_agent_dashboard.py +246 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/METADATA +1 -1
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/RECORD +50 -16
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/top_level.txt +1 -0
- kairo_migrations/env.py +92 -0
- kairo_migrations/versions/001_add_agent_dashboard_extensions.py +450 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/WHEEL +0 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""
|
|
2
|
+
End-to-end tests for Agent Metrics functionality.
|
|
3
|
+
|
|
4
|
+
Tests cover:
|
|
5
|
+
- Get metrics for different ranges (1h, 24h, 7d)
|
|
6
|
+
- Metrics aggregation
|
|
7
|
+
- Telemetry batch submission
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import datetime, timedelta, UTC
|
|
13
|
+
from httpx import AsyncClient
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
|
|
16
|
+
from backend.models.agent import Agent, AgentMetrics1m
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestGetMetrics:
|
|
20
|
+
"""Tests for GET /api/agents/{agent_id}/metrics"""
|
|
21
|
+
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_get_metrics_empty(
|
|
24
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
25
|
+
):
|
|
26
|
+
"""Should return empty data for agent with no metrics."""
|
|
27
|
+
response = await client.get(
|
|
28
|
+
f"/api/agents/{test_agent.id}/metrics", headers=auth_headers
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
assert response.status_code == 200
|
|
32
|
+
data = response.json()
|
|
33
|
+
assert data["agent_id"] == test_agent.id
|
|
34
|
+
assert data["data"] == []
|
|
35
|
+
assert data["summary"]["total_requests"] == 0
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_get_metrics_with_data(
|
|
39
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
40
|
+
):
|
|
41
|
+
"""Should return metrics data points."""
|
|
42
|
+
# Create metrics for the agent
|
|
43
|
+
now = datetime.now(UTC).replace(second=0, microsecond=0)
|
|
44
|
+
await agent_factory.create_metrics(
|
|
45
|
+
test_agent.id,
|
|
46
|
+
bucket_time=now - timedelta(minutes=5),
|
|
47
|
+
request_count=10,
|
|
48
|
+
error_count=1,
|
|
49
|
+
input_tokens=1000,
|
|
50
|
+
output_tokens=500,
|
|
51
|
+
total_latency_ms=5000,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
response = await client.get(
|
|
55
|
+
f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert response.status_code == 200
|
|
59
|
+
data = response.json()
|
|
60
|
+
assert len(data["data"]) == 1
|
|
61
|
+
assert data["data"][0]["request_count"] == 10
|
|
62
|
+
assert data["data"][0]["error_count"] == 1
|
|
63
|
+
|
|
64
|
+
@pytest.mark.asyncio
|
|
65
|
+
async def test_get_metrics_range_1h(
|
|
66
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
67
|
+
):
|
|
68
|
+
"""Should return metrics for 1 hour range with 1m granularity."""
|
|
69
|
+
now = datetime.now(UTC).replace(second=0, microsecond=0)
|
|
70
|
+
|
|
71
|
+
# Create metrics at different times
|
|
72
|
+
for i in range(6):
|
|
73
|
+
await agent_factory.create_metrics(
|
|
74
|
+
test_agent.id,
|
|
75
|
+
bucket_time=now - timedelta(minutes=i * 10),
|
|
76
|
+
request_count=5,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
response = await client.get(
|
|
80
|
+
f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
assert response.status_code == 200
|
|
84
|
+
data = response.json()
|
|
85
|
+
assert data["range"] == "1h"
|
|
86
|
+
assert data["granularity"] == "1m"
|
|
87
|
+
assert len(data["data"]) == 6
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_get_metrics_range_24h(
|
|
91
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
92
|
+
):
|
|
93
|
+
"""Should return metrics for 24 hour range."""
|
|
94
|
+
now = datetime.now(UTC).replace(second=0, microsecond=0)
|
|
95
|
+
|
|
96
|
+
# Create metrics spanning 24 hours
|
|
97
|
+
for i in range(24):
|
|
98
|
+
await agent_factory.create_metrics(
|
|
99
|
+
test_agent.id,
|
|
100
|
+
bucket_time=now - timedelta(hours=i),
|
|
101
|
+
request_count=10,
|
|
102
|
+
input_tokens=1000,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
response = await client.get(
|
|
106
|
+
f"/api/agents/{test_agent.id}/metrics?range=24h", headers=auth_headers
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
assert response.status_code == 200
|
|
110
|
+
data = response.json()
|
|
111
|
+
assert data["range"] == "24h"
|
|
112
|
+
assert len(data["data"]) == 24
|
|
113
|
+
|
|
114
|
+
@pytest.mark.asyncio
|
|
115
|
+
async def test_get_metrics_range_7d(
|
|
116
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
117
|
+
):
|
|
118
|
+
"""Should return metrics for 7 day range."""
|
|
119
|
+
now = datetime.now(UTC).replace(second=0, microsecond=0)
|
|
120
|
+
|
|
121
|
+
# Create metrics spanning 7 days
|
|
122
|
+
for i in range(7):
|
|
123
|
+
await agent_factory.create_metrics(
|
|
124
|
+
test_agent.id,
|
|
125
|
+
bucket_time=now - timedelta(days=i),
|
|
126
|
+
request_count=100,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
response = await client.get(
|
|
130
|
+
f"/api/agents/{test_agent.id}/metrics?range=7d", headers=auth_headers
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
assert response.status_code == 200
|
|
134
|
+
data = response.json()
|
|
135
|
+
assert data["range"] == "7d"
|
|
136
|
+
assert len(data["data"]) == 7
|
|
137
|
+
|
|
138
|
+
@pytest.mark.asyncio
|
|
139
|
+
async def test_get_metrics_agent_not_found(
|
|
140
|
+
self, client: AsyncClient, auth_headers: dict
|
|
141
|
+
):
|
|
142
|
+
"""Should return 404 for non-existent agent."""
|
|
143
|
+
fake_id = str(uuid.uuid4())
|
|
144
|
+
|
|
145
|
+
response = await client.get(
|
|
146
|
+
f"/api/agents/{fake_id}/metrics", headers=auth_headers
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
assert response.status_code == 404
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TestMetricsSummary:
|
|
153
|
+
"""Tests for metrics summary calculation."""
|
|
154
|
+
|
|
155
|
+
@pytest.mark.asyncio
|
|
156
|
+
async def test_metrics_summary_totals(
|
|
157
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
158
|
+
):
|
|
159
|
+
"""Should calculate correct totals in summary."""
|
|
160
|
+
now = datetime.now(UTC).replace(second=0, microsecond=0)
|
|
161
|
+
|
|
162
|
+
# Create metrics with known values
|
|
163
|
+
await agent_factory.create_metrics(
|
|
164
|
+
test_agent.id,
|
|
165
|
+
bucket_time=now - timedelta(minutes=5),
|
|
166
|
+
request_count=100,
|
|
167
|
+
error_count=10,
|
|
168
|
+
input_tokens=5000,
|
|
169
|
+
output_tokens=2500,
|
|
170
|
+
total_latency_ms=50000,
|
|
171
|
+
)
|
|
172
|
+
await agent_factory.create_metrics(
|
|
173
|
+
test_agent.id,
|
|
174
|
+
bucket_time=now - timedelta(minutes=10),
|
|
175
|
+
request_count=50,
|
|
176
|
+
error_count=5,
|
|
177
|
+
input_tokens=2500,
|
|
178
|
+
output_tokens=1250,
|
|
179
|
+
total_latency_ms=25000,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
response = await client.get(
|
|
183
|
+
f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
data = response.json()
|
|
187
|
+
summary = data["summary"]
|
|
188
|
+
|
|
189
|
+
assert summary["total_requests"] == 150
|
|
190
|
+
assert summary["total_errors"] == 15
|
|
191
|
+
assert summary["total_tokens"] == 11250 # 5000 + 2500 + 2500 + 1250
|
|
192
|
+
assert summary["error_rate"] == 10.0 # 15/150 * 100
|
|
193
|
+
|
|
194
|
+
@pytest.mark.asyncio
|
|
195
|
+
async def test_metrics_summary_avg_latency(
|
|
196
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
197
|
+
):
|
|
198
|
+
"""Should calculate average latency correctly."""
|
|
199
|
+
now = datetime.now(UTC).replace(second=0, microsecond=0)
|
|
200
|
+
|
|
201
|
+
await agent_factory.create_metrics(
|
|
202
|
+
test_agent.id,
|
|
203
|
+
bucket_time=now - timedelta(minutes=5),
|
|
204
|
+
request_count=10,
|
|
205
|
+
total_latency_ms=1000,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
response = await client.get(
|
|
209
|
+
f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
data = response.json()
|
|
213
|
+
summary = data["summary"]
|
|
214
|
+
|
|
215
|
+
assert summary["avg_latency_ms"] == 100.0 # 1000ms / 10 requests
|
|
216
|
+
|
|
217
|
+
@pytest.mark.asyncio
|
|
218
|
+
async def test_metrics_summary_zero_requests(
|
|
219
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
220
|
+
):
|
|
221
|
+
"""Should handle zero requests gracefully."""
|
|
222
|
+
response = await client.get(
|
|
223
|
+
f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
data = response.json()
|
|
227
|
+
summary = data["summary"]
|
|
228
|
+
|
|
229
|
+
assert summary["total_requests"] == 0
|
|
230
|
+
assert summary["error_rate"] == 0.0
|
|
231
|
+
assert summary["avg_latency_ms"] is None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class TestMetricsDataPoints:
|
|
235
|
+
"""Tests for individual metric data points."""
|
|
236
|
+
|
|
237
|
+
@pytest.mark.asyncio
|
|
238
|
+
async def test_data_point_fields(
|
|
239
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
240
|
+
):
|
|
241
|
+
"""Should include all expected fields in data points."""
|
|
242
|
+
now = datetime.now(UTC).replace(second=0, microsecond=0)
|
|
243
|
+
|
|
244
|
+
await agent_factory.create_metrics(
|
|
245
|
+
test_agent.id,
|
|
246
|
+
bucket_time=now - timedelta(minutes=5),
|
|
247
|
+
request_count=20,
|
|
248
|
+
error_count=2,
|
|
249
|
+
input_tokens=2000,
|
|
250
|
+
output_tokens=1000,
|
|
251
|
+
total_latency_ms=4000,
|
|
252
|
+
tool_calls=5,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
response = await client.get(
|
|
256
|
+
f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
data = response.json()
|
|
260
|
+
point = data["data"][0]
|
|
261
|
+
|
|
262
|
+
assert "timestamp" in point
|
|
263
|
+
assert point["request_count"] == 20
|
|
264
|
+
assert point["error_count"] == 2
|
|
265
|
+
assert point["input_tokens"] == 2000
|
|
266
|
+
assert point["output_tokens"] == 1000
|
|
267
|
+
assert point["avg_latency_ms"] == 200.0 # 4000 / 20
|
|
268
|
+
assert point["tool_calls"] == 5
|
|
269
|
+
|
|
270
|
+
@pytest.mark.asyncio
|
|
271
|
+
async def test_data_points_ordered_by_time(
|
|
272
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
273
|
+
):
|
|
274
|
+
"""Should return data points ordered by timestamp."""
|
|
275
|
+
now = datetime.now(UTC).replace(second=0, microsecond=0)
|
|
276
|
+
|
|
277
|
+
# Create metrics in non-chronological order
|
|
278
|
+
await agent_factory.create_metrics(
|
|
279
|
+
test_agent.id, bucket_time=now - timedelta(minutes=10)
|
|
280
|
+
)
|
|
281
|
+
await agent_factory.create_metrics(
|
|
282
|
+
test_agent.id, bucket_time=now - timedelta(minutes=30)
|
|
283
|
+
)
|
|
284
|
+
await agent_factory.create_metrics(
|
|
285
|
+
test_agent.id, bucket_time=now - timedelta(minutes=5)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
response = await client.get(
|
|
289
|
+
f"/api/agents/{test_agent.id}/metrics?range=1h", headers=auth_headers
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
data = response.json()
|
|
293
|
+
timestamps = [point["timestamp"] for point in data["data"]]
|
|
294
|
+
|
|
295
|
+
# Should be in ascending order
|
|
296
|
+
assert timestamps == sorted(timestamps)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class TestTelemetryBatch:
|
|
300
|
+
"""Tests for POST /api/agents/telemetry/batch"""
|
|
301
|
+
|
|
302
|
+
@pytest.mark.asyncio
|
|
303
|
+
async def test_submit_telemetry_batch_success(
|
|
304
|
+
self, client: AsyncClient, api_key_headers: dict, test_agent: Agent
|
|
305
|
+
):
|
|
306
|
+
"""Should accept valid telemetry batch."""
|
|
307
|
+
now = datetime.now(UTC)
|
|
308
|
+
payload = {
|
|
309
|
+
"agent_id": test_agent.id,
|
|
310
|
+
"events": [
|
|
311
|
+
{
|
|
312
|
+
"request_id": str(uuid.uuid4()),
|
|
313
|
+
"timestamp_start": (now - timedelta(seconds=30)).isoformat(),
|
|
314
|
+
"timestamp_end": (now - timedelta(seconds=29)).isoformat(),
|
|
315
|
+
"latency_ms": 1000,
|
|
316
|
+
"input_tokens": 100,
|
|
317
|
+
"output_tokens": 50,
|
|
318
|
+
"model": "nyx",
|
|
319
|
+
"status": "success",
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
"request_id": str(uuid.uuid4()),
|
|
323
|
+
"timestamp_start": (now - timedelta(seconds=20)).isoformat(),
|
|
324
|
+
"timestamp_end": (now - timedelta(seconds=18)).isoformat(),
|
|
325
|
+
"latency_ms": 2000,
|
|
326
|
+
"input_tokens": 200,
|
|
327
|
+
"output_tokens": 100,
|
|
328
|
+
"model": "nyx",
|
|
329
|
+
"status": "success",
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
response = await client.post(
|
|
335
|
+
"/api/agents/telemetry/batch", json=payload, headers=api_key_headers
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
assert response.status_code == 200
|
|
339
|
+
data = response.json()
|
|
340
|
+
assert data["accepted"] == 2
|
|
341
|
+
assert data["rejected"] == 0
|
|
342
|
+
assert data["errors"] == []
|
|
343
|
+
|
|
344
|
+
@pytest.mark.asyncio
|
|
345
|
+
async def test_telemetry_batch_records_metrics(
|
|
346
|
+
self, client: AsyncClient, api_key_headers: dict, test_agent: Agent, db_session
|
|
347
|
+
):
|
|
348
|
+
"""Should record telemetry events as metrics."""
|
|
349
|
+
now = datetime.now(UTC)
|
|
350
|
+
payload = {
|
|
351
|
+
"agent_id": test_agent.id,
|
|
352
|
+
"events": [
|
|
353
|
+
{
|
|
354
|
+
"request_id": str(uuid.uuid4()),
|
|
355
|
+
"timestamp_start": now.isoformat(),
|
|
356
|
+
"timestamp_end": (now + timedelta(seconds=1)).isoformat(),
|
|
357
|
+
"latency_ms": 500,
|
|
358
|
+
"input_tokens": 100,
|
|
359
|
+
"output_tokens": 50,
|
|
360
|
+
"model": "nyx",
|
|
361
|
+
"status": "success",
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
await client.post(
|
|
367
|
+
"/api/agents/telemetry/batch", json=payload, headers=api_key_headers
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# Check metrics recorded
|
|
371
|
+
stmt = select(AgentMetrics1m).where(AgentMetrics1m.agent_id == test_agent.id)
|
|
372
|
+
result = await db_session.execute(stmt)
|
|
373
|
+
metrics = result.scalar_one_or_none()
|
|
374
|
+
|
|
375
|
+
assert metrics is not None
|
|
376
|
+
assert metrics.request_count == 1
|
|
377
|
+
assert metrics.input_tokens == 100
|
|
378
|
+
assert metrics.output_tokens == 50
|
|
379
|
+
|
|
380
|
+
@pytest.mark.asyncio
|
|
381
|
+
async def test_telemetry_batch_error_events(
|
|
382
|
+
self, client: AsyncClient, api_key_headers: dict, test_agent: Agent, db_session
|
|
383
|
+
):
|
|
384
|
+
"""Should count error events correctly."""
|
|
385
|
+
now = datetime.now(UTC)
|
|
386
|
+
payload = {
|
|
387
|
+
"agent_id": test_agent.id,
|
|
388
|
+
"events": [
|
|
389
|
+
{
|
|
390
|
+
"request_id": str(uuid.uuid4()),
|
|
391
|
+
"timestamp_start": now.isoformat(),
|
|
392
|
+
"timestamp_end": (now + timedelta(seconds=1)).isoformat(),
|
|
393
|
+
"latency_ms": 500,
|
|
394
|
+
"input_tokens": 100,
|
|
395
|
+
"output_tokens": 0,
|
|
396
|
+
"model": "nyx",
|
|
397
|
+
"status": "error",
|
|
398
|
+
"error_type": "rate_limit",
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await client.post(
|
|
404
|
+
"/api/agents/telemetry/batch", json=payload, headers=api_key_headers
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Check error count
|
|
408
|
+
stmt = select(AgentMetrics1m).where(AgentMetrics1m.agent_id == test_agent.id)
|
|
409
|
+
result = await db_session.execute(stmt)
|
|
410
|
+
metrics = result.scalar_one_or_none()
|
|
411
|
+
|
|
412
|
+
assert metrics.error_count == 1
|
|
413
|
+
|
|
414
|
+
@pytest.mark.asyncio
|
|
415
|
+
async def test_telemetry_batch_timeout_events(
|
|
416
|
+
self, client: AsyncClient, api_key_headers: dict, test_agent: Agent, db_session
|
|
417
|
+
):
|
|
418
|
+
"""Should count timeout events correctly."""
|
|
419
|
+
now = datetime.now(UTC)
|
|
420
|
+
payload = {
|
|
421
|
+
"agent_id": test_agent.id,
|
|
422
|
+
"events": [
|
|
423
|
+
{
|
|
424
|
+
"request_id": str(uuid.uuid4()),
|
|
425
|
+
"timestamp_start": now.isoformat(),
|
|
426
|
+
"timestamp_end": (now + timedelta(seconds=30)).isoformat(),
|
|
427
|
+
"latency_ms": 30000,
|
|
428
|
+
"input_tokens": 100,
|
|
429
|
+
"output_tokens": 0,
|
|
430
|
+
"model": "nyx",
|
|
431
|
+
"status": "timeout",
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
await client.post(
|
|
437
|
+
"/api/agents/telemetry/batch", json=payload, headers=api_key_headers
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
stmt = select(AgentMetrics1m).where(AgentMetrics1m.agent_id == test_agent.id)
|
|
441
|
+
result = await db_session.execute(stmt)
|
|
442
|
+
metrics = result.scalar_one_or_none()
|
|
443
|
+
|
|
444
|
+
assert metrics.timeout_count == 1
|
|
445
|
+
assert metrics.error_count == 1 # timeout also counts as error
|
|
446
|
+
|
|
447
|
+
@pytest.mark.asyncio
|
|
448
|
+
async def test_telemetry_batch_with_tool_calls(
|
|
449
|
+
self, client: AsyncClient, api_key_headers: dict, test_agent: Agent, db_session
|
|
450
|
+
):
|
|
451
|
+
"""Should record tool call counts."""
|
|
452
|
+
now = datetime.now(UTC)
|
|
453
|
+
payload = {
|
|
454
|
+
"agent_id": test_agent.id,
|
|
455
|
+
"events": [
|
|
456
|
+
{
|
|
457
|
+
"request_id": str(uuid.uuid4()),
|
|
458
|
+
"timestamp_start": now.isoformat(),
|
|
459
|
+
"timestamp_end": (now + timedelta(seconds=2)).isoformat(),
|
|
460
|
+
"latency_ms": 2000,
|
|
461
|
+
"input_tokens": 500,
|
|
462
|
+
"output_tokens": 200,
|
|
463
|
+
"model": "nyx",
|
|
464
|
+
"status": "success",
|
|
465
|
+
"tool_calls": [
|
|
466
|
+
{"tool": "web_search", "duration_ms": 500},
|
|
467
|
+
{"tool": "file_read", "duration_ms": 100},
|
|
468
|
+
{"tool": "web_search", "duration_ms": 300},
|
|
469
|
+
],
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
await client.post(
|
|
475
|
+
"/api/agents/telemetry/batch", json=payload, headers=api_key_headers
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
stmt = select(AgentMetrics1m).where(AgentMetrics1m.agent_id == test_agent.id)
|
|
479
|
+
result = await db_session.execute(stmt)
|
|
480
|
+
metrics = result.scalar_one_or_none()
|
|
481
|
+
|
|
482
|
+
assert metrics.tool_calls == 3
|
|
483
|
+
|
|
484
|
+
@pytest.mark.asyncio
|
|
485
|
+
async def test_telemetry_batch_agent_not_found(
|
|
486
|
+
self, client: AsyncClient, api_key_headers: dict
|
|
487
|
+
):
|
|
488
|
+
"""Should return 404 for non-existent agent."""
|
|
489
|
+
fake_id = str(uuid.uuid4())
|
|
490
|
+
payload = {
|
|
491
|
+
"agent_id": fake_id,
|
|
492
|
+
"events": [],
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
response = await client.post(
|
|
496
|
+
"/api/agents/telemetry/batch", json=payload, headers=api_key_headers
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
assert response.status_code == 404
|
|
500
|
+
|
|
501
|
+
@pytest.mark.asyncio
|
|
502
|
+
async def test_telemetry_batch_empty_events(
|
|
503
|
+
self, client: AsyncClient, api_key_headers: dict, test_agent: Agent
|
|
504
|
+
):
|
|
505
|
+
"""Should handle empty events list."""
|
|
506
|
+
payload = {
|
|
507
|
+
"agent_id": test_agent.id,
|
|
508
|
+
"events": [],
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
response = await client.post(
|
|
512
|
+
"/api/agents/telemetry/batch", json=payload, headers=api_key_headers
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
assert response.status_code == 200
|
|
516
|
+
data = response.json()
|
|
517
|
+
assert data["accepted"] == 0
|
|
518
|
+
assert data["rejected"] == 0
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class TestMetricsGranularity:
|
|
522
|
+
"""Tests for metrics granularity auto-selection."""
|
|
523
|
+
|
|
524
|
+
@pytest.mark.asyncio
|
|
525
|
+
async def test_auto_granularity_1h_returns_1m(
|
|
526
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
527
|
+
):
|
|
528
|
+
"""Should use 1m granularity for 1h range."""
|
|
529
|
+
response = await client.get(
|
|
530
|
+
f"/api/agents/{test_agent.id}/metrics?range=1h&granularity=auto",
|
|
531
|
+
headers=auth_headers,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
assert response.status_code == 200
|
|
535
|
+
assert response.json()["granularity"] == "1m"
|
|
536
|
+
|
|
537
|
+
@pytest.mark.asyncio
|
|
538
|
+
async def test_auto_granularity_6h_returns_1m(
|
|
539
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
540
|
+
):
|
|
541
|
+
"""Should use 1m granularity for 6h range."""
|
|
542
|
+
response = await client.get(
|
|
543
|
+
f"/api/agents/{test_agent.id}/metrics?range=6h&granularity=auto",
|
|
544
|
+
headers=auth_headers,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
assert response.status_code == 200
|
|
548
|
+
assert response.json()["granularity"] == "1m"
|
|
549
|
+
|
|
550
|
+
@pytest.mark.asyncio
|
|
551
|
+
async def test_auto_granularity_24h_returns_1h(
|
|
552
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
553
|
+
):
|
|
554
|
+
"""Should use 1h granularity for 24h range."""
|
|
555
|
+
response = await client.get(
|
|
556
|
+
f"/api/agents/{test_agent.id}/metrics?range=24h&granularity=auto",
|
|
557
|
+
headers=auth_headers,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
assert response.status_code == 200
|
|
561
|
+
assert response.json()["granularity"] == "1h"
|
|
562
|
+
|
|
563
|
+
@pytest.mark.asyncio
|
|
564
|
+
async def test_auto_granularity_30d_returns_1d(
|
|
565
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
566
|
+
):
|
|
567
|
+
"""Should use 1d granularity for 30d range."""
|
|
568
|
+
response = await client.get(
|
|
569
|
+
f"/api/agents/{test_agent.id}/metrics?range=30d&granularity=auto",
|
|
570
|
+
headers=auth_headers,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
assert response.status_code == 200
|
|
574
|
+
assert response.json()["granularity"] == "1d"
|
|
575
|
+
|
|
576
|
+
@pytest.mark.asyncio
|
|
577
|
+
async def test_explicit_granularity_override(
|
|
578
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
579
|
+
):
|
|
580
|
+
"""Should respect explicit granularity setting."""
|
|
581
|
+
response = await client.get(
|
|
582
|
+
f"/api/agents/{test_agent.id}/metrics?range=24h&granularity=1m",
|
|
583
|
+
headers=auth_headers,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
assert response.status_code == 200
|
|
587
|
+
assert response.json()["granularity"] == "1m"
|