agno 2.3.4__py3-none-any.whl → 2.3.5__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.
Files changed (112) hide show
  1. agno/agent/agent.py +177 -41
  2. agno/culture/manager.py +2 -2
  3. agno/db/base.py +330 -8
  4. agno/db/dynamo/dynamo.py +722 -2
  5. agno/db/dynamo/schemas.py +127 -0
  6. agno/db/firestore/firestore.py +573 -1
  7. agno/db/firestore/schemas.py +40 -0
  8. agno/db/gcs_json/gcs_json_db.py +446 -1
  9. agno/db/in_memory/in_memory_db.py +143 -1
  10. agno/db/json/json_db.py +438 -1
  11. agno/db/mongo/async_mongo.py +522 -0
  12. agno/db/mongo/mongo.py +523 -1
  13. agno/db/mongo/schemas.py +29 -0
  14. agno/db/mysql/mysql.py +536 -3
  15. agno/db/mysql/schemas.py +38 -0
  16. agno/db/postgres/async_postgres.py +541 -13
  17. agno/db/postgres/postgres.py +535 -2
  18. agno/db/postgres/schemas.py +38 -0
  19. agno/db/redis/redis.py +468 -1
  20. agno/db/redis/schemas.py +32 -0
  21. agno/db/singlestore/schemas.py +38 -0
  22. agno/db/singlestore/singlestore.py +523 -1
  23. agno/db/sqlite/async_sqlite.py +548 -9
  24. agno/db/sqlite/schemas.py +38 -0
  25. agno/db/sqlite/sqlite.py +537 -5
  26. agno/db/sqlite/utils.py +6 -8
  27. agno/db/surrealdb/models.py +25 -0
  28. agno/db/surrealdb/surrealdb.py +548 -1
  29. agno/eval/accuracy.py +10 -4
  30. agno/eval/performance.py +10 -4
  31. agno/eval/reliability.py +22 -13
  32. agno/exceptions.py +11 -0
  33. agno/hooks/__init__.py +3 -0
  34. agno/hooks/decorator.py +164 -0
  35. agno/knowledge/chunking/semantic.py +2 -2
  36. agno/models/aimlapi/aimlapi.py +2 -3
  37. agno/models/anthropic/claude.py +18 -13
  38. agno/models/aws/bedrock.py +3 -4
  39. agno/models/aws/claude.py +5 -1
  40. agno/models/azure/ai_foundry.py +2 -2
  41. agno/models/azure/openai_chat.py +8 -0
  42. agno/models/cerebras/cerebras.py +63 -11
  43. agno/models/cerebras/cerebras_openai.py +2 -3
  44. agno/models/cohere/chat.py +1 -5
  45. agno/models/cometapi/cometapi.py +2 -3
  46. agno/models/dashscope/dashscope.py +2 -3
  47. agno/models/deepinfra/deepinfra.py +2 -3
  48. agno/models/deepseek/deepseek.py +2 -3
  49. agno/models/fireworks/fireworks.py +2 -3
  50. agno/models/google/gemini.py +9 -7
  51. agno/models/groq/groq.py +2 -3
  52. agno/models/huggingface/huggingface.py +1 -5
  53. agno/models/ibm/watsonx.py +1 -5
  54. agno/models/internlm/internlm.py +2 -3
  55. agno/models/langdb/langdb.py +6 -4
  56. agno/models/litellm/chat.py +2 -2
  57. agno/models/litellm/litellm_openai.py +2 -3
  58. agno/models/meta/llama.py +1 -5
  59. agno/models/meta/llama_openai.py +4 -5
  60. agno/models/mistral/mistral.py +1 -5
  61. agno/models/nebius/nebius.py +2 -3
  62. agno/models/nvidia/nvidia.py +4 -5
  63. agno/models/openai/chat.py +14 -3
  64. agno/models/openai/responses.py +14 -3
  65. agno/models/openrouter/openrouter.py +4 -5
  66. agno/models/perplexity/perplexity.py +2 -3
  67. agno/models/portkey/portkey.py +7 -6
  68. agno/models/requesty/requesty.py +4 -5
  69. agno/models/response.py +2 -1
  70. agno/models/sambanova/sambanova.py +4 -5
  71. agno/models/siliconflow/siliconflow.py +3 -4
  72. agno/models/together/together.py +4 -5
  73. agno/models/vercel/v0.py +4 -5
  74. agno/models/vllm/vllm.py +19 -14
  75. agno/models/xai/xai.py +4 -5
  76. agno/os/app.py +104 -0
  77. agno/os/config.py +13 -0
  78. agno/os/interfaces/whatsapp/router.py +0 -1
  79. agno/os/mcp.py +1 -0
  80. agno/os/router.py +31 -0
  81. agno/os/routers/traces/__init__.py +3 -0
  82. agno/os/routers/traces/schemas.py +414 -0
  83. agno/os/routers/traces/traces.py +499 -0
  84. agno/os/schema.py +10 -1
  85. agno/os/utils.py +57 -0
  86. agno/run/agent.py +1 -0
  87. agno/run/base.py +17 -0
  88. agno/run/team.py +4 -0
  89. agno/session/team.py +1 -0
  90. agno/table.py +10 -0
  91. agno/team/team.py +214 -65
  92. agno/tools/function.py +10 -8
  93. agno/tools/nano_banana.py +1 -1
  94. agno/tracing/__init__.py +12 -0
  95. agno/tracing/exporter.py +157 -0
  96. agno/tracing/schemas.py +276 -0
  97. agno/tracing/setup.py +111 -0
  98. agno/utils/agent.py +4 -4
  99. agno/utils/hooks.py +56 -1
  100. agno/vectordb/qdrant/qdrant.py +22 -22
  101. agno/workflow/condition.py +8 -0
  102. agno/workflow/loop.py +8 -0
  103. agno/workflow/parallel.py +8 -0
  104. agno/workflow/router.py +8 -0
  105. agno/workflow/step.py +20 -0
  106. agno/workflow/steps.py +8 -0
  107. agno/workflow/workflow.py +83 -17
  108. {agno-2.3.4.dist-info → agno-2.3.5.dist-info}/METADATA +2 -2
  109. {agno-2.3.4.dist-info → agno-2.3.5.dist-info}/RECORD +112 -102
  110. {agno-2.3.4.dist-info → agno-2.3.5.dist-info}/WHEEL +0 -0
  111. {agno-2.3.4.dist-info → agno-2.3.5.dist-info}/licenses/LICENSE +0 -0
  112. {agno-2.3.4.dist-info → agno-2.3.5.dist-info}/top_level.txt +0 -0
agno/db/dynamo/dynamo.py CHANGED
@@ -2,7 +2,10 @@ import json
2
2
  import time
3
3
  from datetime import date, datetime, timedelta, timezone
4
4
  from os import getenv
5
- from typing import Any, Dict, List, Optional, Tuple, Union
5
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
6
+
7
+ if TYPE_CHECKING:
8
+ from agno.tracing.schemas import Span, Trace
6
9
 
7
10
  from agno.db.base import BaseDb, SessionType
8
11
  from agno.db.dynamo.schemas import get_table_schema_definition
@@ -60,6 +63,8 @@ class DynamoDb(BaseDb):
60
63
  metrics_table: Optional[str] = None,
61
64
  eval_table: Optional[str] = None,
62
65
  knowledge_table: Optional[str] = None,
66
+ traces_table: Optional[str] = None,
67
+ spans_table: Optional[str] = None,
63
68
  id: Optional[str] = None,
64
69
  ):
65
70
  """
@@ -76,6 +81,8 @@ class DynamoDb(BaseDb):
76
81
  metrics_table: The name of the metrics table.
77
82
  eval_table: The name of the eval table.
78
83
  knowledge_table: The name of the knowledge table.
84
+ traces_table: The name of the traces table.
85
+ spans_table: The name of the spans table.
79
86
  id: ID of the database.
80
87
  """
81
88
  if id is None:
@@ -90,6 +97,8 @@ class DynamoDb(BaseDb):
90
97
  metrics_table=metrics_table,
91
98
  eval_table=eval_table,
92
99
  knowledge_table=knowledge_table,
100
+ traces_table=traces_table,
101
+ spans_table=spans_table,
93
102
  )
94
103
 
95
104
  if db_client is not None:
@@ -149,7 +158,7 @@ class DynamoDb(BaseDb):
149
158
  Get table name and ensure the table exists, creating it if needed.
150
159
 
151
160
  Args:
152
- table_type: Type of table ("sessions", "memories", "metrics", "evals", "knowledge_sources")
161
+ table_type: Type of table ("sessions", "memories", "metrics", "evals", "knowledge", "culture", "traces", "spans")
153
162
 
154
163
  Returns:
155
164
  str: The table name
@@ -171,6 +180,12 @@ class DynamoDb(BaseDb):
171
180
  table_name = self.knowledge_table_name
172
181
  elif table_type == "culture":
173
182
  table_name = self.culture_table_name
183
+ elif table_type == "traces":
184
+ table_name = self.trace_table_name
185
+ elif table_type == "spans":
186
+ # Ensure traces table exists first (spans reference traces)
187
+ self._get_table("traces", create_table_if_not_found=True)
188
+ table_name = self.span_table_name
174
189
  else:
175
190
  raise ValueError(f"Unknown table type: {table_type}")
176
191
 
@@ -2059,3 +2074,708 @@ class DynamoDb(BaseDb):
2059
2074
  except Exception as e:
2060
2075
  log_error(f"Failed to upsert cultural knowledge: {e}")
2061
2076
  raise e
2077
+
2078
+ # --- Traces ---
2079
+ def create_trace(self, trace: "Trace") -> None:
2080
+ """Create a single trace record in the database.
2081
+
2082
+ Args:
2083
+ trace: The Trace object to store (one per trace_id).
2084
+ """
2085
+ try:
2086
+ table_name = self._get_table("traces", create_table_if_not_found=True)
2087
+ if table_name is None:
2088
+ return
2089
+
2090
+ # Check if trace already exists
2091
+ response = self.client.get_item(
2092
+ TableName=table_name,
2093
+ Key={"trace_id": {"S": trace.trace_id}},
2094
+ )
2095
+
2096
+ existing_item = response.get("Item")
2097
+ if existing_item:
2098
+ # Update existing trace
2099
+ existing = deserialize_from_dynamodb_item(existing_item)
2100
+
2101
+ # Determine component level for name update priority
2102
+ def get_component_level(workflow_id, team_id, agent_id, name):
2103
+ is_root_name = ".run" in name or ".arun" in name
2104
+ if not is_root_name:
2105
+ return 0
2106
+ elif workflow_id:
2107
+ return 3
2108
+ elif team_id:
2109
+ return 2
2110
+ elif agent_id:
2111
+ return 1
2112
+ else:
2113
+ return 0
2114
+
2115
+ existing_level = get_component_level(
2116
+ existing.get("workflow_id"),
2117
+ existing.get("team_id"),
2118
+ existing.get("agent_id"),
2119
+ existing.get("name", ""),
2120
+ )
2121
+ new_level = get_component_level(trace.workflow_id, trace.team_id, trace.agent_id, trace.name)
2122
+ should_update_name = new_level > existing_level
2123
+
2124
+ # Parse existing start_time to calculate correct duration
2125
+ existing_start_time_str = existing.get("start_time")
2126
+ if isinstance(existing_start_time_str, str):
2127
+ existing_start_time = datetime.fromisoformat(existing_start_time_str.replace("Z", "+00:00"))
2128
+ else:
2129
+ existing_start_time = trace.start_time
2130
+
2131
+ recalculated_duration_ms = int((trace.end_time - existing_start_time).total_seconds() * 1000)
2132
+
2133
+ # Build update expression
2134
+ update_parts = [
2135
+ "end_time = :end_time",
2136
+ "duration_ms = :duration_ms",
2137
+ "#status = :status",
2138
+ ]
2139
+ expression_attr_names = {"#status": "status"}
2140
+ expression_attr_values: Dict[str, Any] = {
2141
+ ":end_time": {"S": trace.end_time.isoformat()},
2142
+ ":duration_ms": {"N": str(recalculated_duration_ms)},
2143
+ ":status": {"S": trace.status},
2144
+ }
2145
+
2146
+ if should_update_name:
2147
+ update_parts.append("#name = :name")
2148
+ expression_attr_names["#name"] = "name"
2149
+ expression_attr_values[":name"] = {"S": trace.name}
2150
+
2151
+ if trace.run_id is not None:
2152
+ update_parts.append("run_id = :run_id")
2153
+ expression_attr_values[":run_id"] = {"S": trace.run_id}
2154
+ if trace.session_id is not None:
2155
+ update_parts.append("session_id = :session_id")
2156
+ expression_attr_values[":session_id"] = {"S": trace.session_id}
2157
+ if trace.user_id is not None:
2158
+ update_parts.append("user_id = :user_id")
2159
+ expression_attr_values[":user_id"] = {"S": trace.user_id}
2160
+ if trace.agent_id is not None:
2161
+ update_parts.append("agent_id = :agent_id")
2162
+ expression_attr_values[":agent_id"] = {"S": trace.agent_id}
2163
+ if trace.team_id is not None:
2164
+ update_parts.append("team_id = :team_id")
2165
+ expression_attr_values[":team_id"] = {"S": trace.team_id}
2166
+ if trace.workflow_id is not None:
2167
+ update_parts.append("workflow_id = :workflow_id")
2168
+ expression_attr_values[":workflow_id"] = {"S": trace.workflow_id}
2169
+
2170
+ self.client.update_item(
2171
+ TableName=table_name,
2172
+ Key={"trace_id": {"S": trace.trace_id}},
2173
+ UpdateExpression="SET " + ", ".join(update_parts),
2174
+ ExpressionAttributeNames=expression_attr_names,
2175
+ ExpressionAttributeValues=expression_attr_values,
2176
+ )
2177
+ else:
2178
+ # Create new trace with initialized counters
2179
+ trace_dict = trace.to_dict()
2180
+ trace_dict["total_spans"] = 0
2181
+ trace_dict["error_count"] = 0
2182
+ item = serialize_to_dynamo_item(trace_dict)
2183
+ self.client.put_item(TableName=table_name, Item=item)
2184
+
2185
+ except Exception as e:
2186
+ log_error(f"Error creating trace: {e}")
2187
+
2188
+ def get_trace(
2189
+ self,
2190
+ trace_id: Optional[str] = None,
2191
+ run_id: Optional[str] = None,
2192
+ ):
2193
+ """Get a single trace by trace_id or other filters.
2194
+
2195
+ Args:
2196
+ trace_id: The unique trace identifier.
2197
+ run_id: Filter by run ID (returns first match).
2198
+
2199
+ Returns:
2200
+ Optional[Trace]: The trace if found, None otherwise.
2201
+
2202
+ Note:
2203
+ If multiple filters are provided, trace_id takes precedence.
2204
+ For other filters, the most recent trace is returned.
2205
+ """
2206
+ try:
2207
+ from agno.tracing.schemas import Trace
2208
+
2209
+ table_name = self._get_table("traces")
2210
+ if table_name is None:
2211
+ return None
2212
+
2213
+ if trace_id:
2214
+ # Direct lookup by primary key
2215
+ response = self.client.get_item(
2216
+ TableName=table_name,
2217
+ Key={"trace_id": {"S": trace_id}},
2218
+ )
2219
+ item = response.get("Item")
2220
+ if item:
2221
+ trace_data = deserialize_from_dynamodb_item(item)
2222
+ trace_data.setdefault("total_spans", 0)
2223
+ trace_data.setdefault("error_count", 0)
2224
+ return Trace.from_dict(trace_data)
2225
+ return None
2226
+
2227
+ elif run_id:
2228
+ # Query using GSI
2229
+ response = self.client.query(
2230
+ TableName=table_name,
2231
+ IndexName="run_id-start_time-index",
2232
+ KeyConditionExpression="run_id = :run_id",
2233
+ ExpressionAttributeValues={":run_id": {"S": run_id}},
2234
+ ScanIndexForward=False, # Descending order
2235
+ Limit=1,
2236
+ )
2237
+ items = response.get("Items", [])
2238
+ if items:
2239
+ trace_data = deserialize_from_dynamodb_item(items[0])
2240
+ # Use stored values (default to 0 if not present)
2241
+ trace_data.setdefault("total_spans", 0)
2242
+ trace_data.setdefault("error_count", 0)
2243
+ return Trace.from_dict(trace_data)
2244
+ return None
2245
+
2246
+ else:
2247
+ log_debug("get_trace called without any filter parameters")
2248
+ return None
2249
+
2250
+ except Exception as e:
2251
+ log_error(f"Error getting trace: {e}")
2252
+ return None
2253
+
2254
+ def get_traces(
2255
+ self,
2256
+ run_id: Optional[str] = None,
2257
+ session_id: Optional[str] = None,
2258
+ user_id: Optional[str] = None,
2259
+ agent_id: Optional[str] = None,
2260
+ team_id: Optional[str] = None,
2261
+ workflow_id: Optional[str] = None,
2262
+ status: Optional[str] = None,
2263
+ start_time: Optional[datetime] = None,
2264
+ end_time: Optional[datetime] = None,
2265
+ limit: Optional[int] = 20,
2266
+ page: Optional[int] = 1,
2267
+ ) -> tuple[List, int]:
2268
+ """Get traces matching the provided filters.
2269
+
2270
+ Args:
2271
+ run_id: Filter by run ID.
2272
+ session_id: Filter by session ID.
2273
+ user_id: Filter by user ID.
2274
+ agent_id: Filter by agent ID.
2275
+ team_id: Filter by team ID.
2276
+ workflow_id: Filter by workflow ID.
2277
+ status: Filter by status (OK, ERROR, UNSET).
2278
+ start_time: Filter traces starting after this datetime.
2279
+ end_time: Filter traces ending before this datetime.
2280
+ limit: Maximum number of traces to return per page.
2281
+ page: Page number (1-indexed).
2282
+
2283
+ Returns:
2284
+ tuple[List[Trace], int]: Tuple of (list of matching traces, total count).
2285
+ """
2286
+ try:
2287
+ from agno.tracing.schemas import Trace
2288
+
2289
+ table_name = self._get_table("traces")
2290
+ if table_name is None:
2291
+ return [], 0
2292
+
2293
+ # Determine if we can use a GSI query or need to scan
2294
+ use_gsi = False
2295
+ gsi_name = None
2296
+ key_condition = None
2297
+ key_values: Dict[str, Any] = {}
2298
+
2299
+ # Check for GSI-compatible filters (only one can be used as key condition)
2300
+ if session_id:
2301
+ use_gsi = True
2302
+ gsi_name = "session_id-start_time-index"
2303
+ key_condition = "session_id = :session_id"
2304
+ key_values[":session_id"] = {"S": session_id}
2305
+ elif user_id:
2306
+ use_gsi = True
2307
+ gsi_name = "user_id-start_time-index"
2308
+ key_condition = "user_id = :user_id"
2309
+ key_values[":user_id"] = {"S": user_id}
2310
+ elif agent_id:
2311
+ use_gsi = True
2312
+ gsi_name = "agent_id-start_time-index"
2313
+ key_condition = "agent_id = :agent_id"
2314
+ key_values[":agent_id"] = {"S": agent_id}
2315
+ elif team_id:
2316
+ use_gsi = True
2317
+ gsi_name = "team_id-start_time-index"
2318
+ key_condition = "team_id = :team_id"
2319
+ key_values[":team_id"] = {"S": team_id}
2320
+ elif workflow_id:
2321
+ use_gsi = True
2322
+ gsi_name = "workflow_id-start_time-index"
2323
+ key_condition = "workflow_id = :workflow_id"
2324
+ key_values[":workflow_id"] = {"S": workflow_id}
2325
+ elif run_id:
2326
+ use_gsi = True
2327
+ gsi_name = "run_id-start_time-index"
2328
+ key_condition = "run_id = :run_id"
2329
+ key_values[":run_id"] = {"S": run_id}
2330
+ elif status:
2331
+ use_gsi = True
2332
+ gsi_name = "status-start_time-index"
2333
+ key_condition = "#status = :status"
2334
+ key_values[":status"] = {"S": status}
2335
+
2336
+ # Build filter expression for additional filters
2337
+ filter_parts = []
2338
+ filter_values: Dict[str, Any] = {}
2339
+ expression_attr_names: Dict[str, str] = {}
2340
+
2341
+ if start_time:
2342
+ filter_parts.append("start_time >= :start_time")
2343
+ filter_values[":start_time"] = {"S": start_time.isoformat()}
2344
+ if end_time:
2345
+ filter_parts.append("end_time <= :end_time")
2346
+ filter_values[":end_time"] = {"S": end_time.isoformat()}
2347
+
2348
+ if status and gsi_name != "status-start_time-index":
2349
+ filter_parts.append("#status = :filter_status")
2350
+ filter_values[":filter_status"] = {"S": status}
2351
+ expression_attr_names["#status"] = "status"
2352
+
2353
+ items = []
2354
+ if use_gsi and gsi_name and key_condition:
2355
+ # Use GSI query
2356
+ query_kwargs: Dict[str, Any] = {
2357
+ "TableName": table_name,
2358
+ "IndexName": gsi_name,
2359
+ "KeyConditionExpression": key_condition,
2360
+ "ExpressionAttributeValues": {**key_values, **filter_values},
2361
+ "ScanIndexForward": False, # Descending order by start_time
2362
+ }
2363
+ if gsi_name == "status-start_time-index":
2364
+ expression_attr_names["#status"] = "status"
2365
+ if expression_attr_names:
2366
+ query_kwargs["ExpressionAttributeNames"] = expression_attr_names
2367
+ if filter_parts:
2368
+ query_kwargs["FilterExpression"] = " AND ".join(filter_parts)
2369
+
2370
+ response = self.client.query(**query_kwargs)
2371
+ items.extend(response.get("Items", []))
2372
+
2373
+ while "LastEvaluatedKey" in response:
2374
+ query_kwargs["ExclusiveStartKey"] = response["LastEvaluatedKey"]
2375
+ response = self.client.query(**query_kwargs)
2376
+ items.extend(response.get("Items", []))
2377
+ else:
2378
+ # Use scan
2379
+ scan_kwargs: Dict[str, Any] = {"TableName": table_name}
2380
+ if filter_parts:
2381
+ scan_kwargs["FilterExpression"] = " AND ".join(filter_parts)
2382
+ scan_kwargs["ExpressionAttributeValues"] = filter_values
2383
+ if expression_attr_names:
2384
+ scan_kwargs["ExpressionAttributeNames"] = expression_attr_names
2385
+
2386
+ response = self.client.scan(**scan_kwargs)
2387
+ items.extend(response.get("Items", []))
2388
+
2389
+ while "LastEvaluatedKey" in response:
2390
+ scan_kwargs["ExclusiveStartKey"] = response["LastEvaluatedKey"]
2391
+ response = self.client.scan(**scan_kwargs)
2392
+ items.extend(response.get("Items", []))
2393
+
2394
+ # Deserialize items
2395
+ traces_data = [deserialize_from_dynamodb_item(item) for item in items]
2396
+
2397
+ # Sort by start_time descending
2398
+ traces_data.sort(key=lambda x: x.get("start_time", ""), reverse=True)
2399
+
2400
+ # Get total count
2401
+ total_count = len(traces_data)
2402
+
2403
+ # Apply pagination
2404
+ offset = (page - 1) * limit if page and limit else 0
2405
+ paginated_data = traces_data[offset : offset + limit] if limit else traces_data
2406
+
2407
+ # Use stored total_spans and error_count (default to 0 if not present)
2408
+ traces = []
2409
+ for trace_data in paginated_data:
2410
+ # Use stored values - these are updated by create_spans
2411
+ trace_data.setdefault("total_spans", 0)
2412
+ trace_data.setdefault("error_count", 0)
2413
+ traces.append(Trace.from_dict(trace_data))
2414
+
2415
+ return traces, total_count
2416
+
2417
+ except Exception as e:
2418
+ log_error(f"Error getting traces: {e}")
2419
+ return [], 0
2420
+
2421
+ def get_trace_stats(
2422
+ self,
2423
+ user_id: Optional[str] = None,
2424
+ agent_id: Optional[str] = None,
2425
+ team_id: Optional[str] = None,
2426
+ workflow_id: Optional[str] = None,
2427
+ start_time: Optional[datetime] = None,
2428
+ end_time: Optional[datetime] = None,
2429
+ limit: Optional[int] = 20,
2430
+ page: Optional[int] = 1,
2431
+ ) -> tuple[List[Dict[str, Any]], int]:
2432
+ """Get trace statistics grouped by session.
2433
+
2434
+ Args:
2435
+ user_id: Filter by user ID.
2436
+ agent_id: Filter by agent ID.
2437
+ team_id: Filter by team ID.
2438
+ workflow_id: Filter by workflow ID.
2439
+ start_time: Filter sessions with traces created after this datetime.
2440
+ end_time: Filter sessions with traces created before this datetime.
2441
+ limit: Maximum number of sessions to return per page.
2442
+ page: Page number (1-indexed).
2443
+
2444
+ Returns:
2445
+ tuple[List[Dict], int]: Tuple of (list of session stats dicts, total count).
2446
+ Each dict contains: session_id, user_id, agent_id, team_id, workflow_id, total_traces,
2447
+ first_trace_at, last_trace_at.
2448
+ """
2449
+ try:
2450
+ table_name = self._get_table("traces")
2451
+ if table_name is None:
2452
+ return [], 0
2453
+
2454
+ # Fetch all traces and aggregate in memory (DynamoDB doesn't support GROUP BY)
2455
+ scan_kwargs: Dict[str, Any] = {"TableName": table_name}
2456
+
2457
+ # Build filter expression
2458
+ filter_parts = []
2459
+ filter_values: Dict[str, Any] = {}
2460
+
2461
+ if user_id:
2462
+ filter_parts.append("user_id = :user_id")
2463
+ filter_values[":user_id"] = {"S": user_id}
2464
+ if agent_id:
2465
+ filter_parts.append("agent_id = :agent_id")
2466
+ filter_values[":agent_id"] = {"S": agent_id}
2467
+ if team_id:
2468
+ filter_parts.append("team_id = :team_id")
2469
+ filter_values[":team_id"] = {"S": team_id}
2470
+ if workflow_id:
2471
+ filter_parts.append("workflow_id = :workflow_id")
2472
+ filter_values[":workflow_id"] = {"S": workflow_id}
2473
+ if start_time:
2474
+ filter_parts.append("created_at >= :start_time")
2475
+ filter_values[":start_time"] = {"S": start_time.isoformat()}
2476
+ if end_time:
2477
+ filter_parts.append("created_at <= :end_time")
2478
+ filter_values[":end_time"] = {"S": end_time.isoformat()}
2479
+
2480
+ # Filter for records with session_id
2481
+ filter_parts.append("attribute_exists(session_id)")
2482
+
2483
+ if filter_parts:
2484
+ scan_kwargs["FilterExpression"] = " AND ".join(filter_parts)
2485
+ if filter_values:
2486
+ scan_kwargs["ExpressionAttributeValues"] = filter_values
2487
+
2488
+ # Scan all matching traces
2489
+ items = []
2490
+ response = self.client.scan(**scan_kwargs)
2491
+ items.extend(response.get("Items", []))
2492
+
2493
+ while "LastEvaluatedKey" in response:
2494
+ scan_kwargs["ExclusiveStartKey"] = response["LastEvaluatedKey"]
2495
+ response = self.client.scan(**scan_kwargs)
2496
+ items.extend(response.get("Items", []))
2497
+
2498
+ # Aggregate by session_id
2499
+ session_stats: Dict[str, Dict[str, Any]] = {}
2500
+ for item in items:
2501
+ trace_data = deserialize_from_dynamodb_item(item)
2502
+ session_id = trace_data.get("session_id")
2503
+ if not session_id:
2504
+ continue
2505
+
2506
+ if session_id not in session_stats:
2507
+ session_stats[session_id] = {
2508
+ "session_id": session_id,
2509
+ "user_id": trace_data.get("user_id"),
2510
+ "agent_id": trace_data.get("agent_id"),
2511
+ "team_id": trace_data.get("team_id"),
2512
+ "workflow_id": trace_data.get("workflow_id"),
2513
+ "total_traces": 0,
2514
+ "first_trace_at": trace_data.get("created_at"),
2515
+ "last_trace_at": trace_data.get("created_at"),
2516
+ }
2517
+
2518
+ session_stats[session_id]["total_traces"] += 1
2519
+
2520
+ created_at = trace_data.get("created_at")
2521
+ if (
2522
+ created_at
2523
+ and session_stats[session_id]["first_trace_at"]
2524
+ and session_stats[session_id]["last_trace_at"]
2525
+ ):
2526
+ if created_at < session_stats[session_id]["first_trace_at"]:
2527
+ session_stats[session_id]["first_trace_at"] = created_at
2528
+ if created_at > session_stats[session_id]["last_trace_at"]:
2529
+ session_stats[session_id]["last_trace_at"] = created_at
2530
+
2531
+ # Convert to list and sort by last_trace_at descending
2532
+ stats_list = list(session_stats.values())
2533
+ stats_list.sort(key=lambda x: x.get("last_trace_at", ""), reverse=True)
2534
+
2535
+ # Convert datetime strings to datetime objects
2536
+ for stat in stats_list:
2537
+ first_trace_at = stat["first_trace_at"]
2538
+ last_trace_at = stat["last_trace_at"]
2539
+ if isinstance(first_trace_at, str):
2540
+ stat["first_trace_at"] = datetime.fromisoformat(first_trace_at.replace("Z", "+00:00"))
2541
+ if isinstance(last_trace_at, str):
2542
+ stat["last_trace_at"] = datetime.fromisoformat(last_trace_at.replace("Z", "+00:00"))
2543
+
2544
+ # Get total count
2545
+ total_count = len(stats_list)
2546
+
2547
+ # Apply pagination
2548
+ offset = (page - 1) * limit if page and limit else 0
2549
+ paginated_stats = stats_list[offset : offset + limit] if limit else stats_list
2550
+
2551
+ return paginated_stats, total_count
2552
+
2553
+ except Exception as e:
2554
+ log_error(f"Error getting trace stats: {e}")
2555
+ return [], 0
2556
+
2557
+ # --- Spans ---
2558
+ def create_span(self, span: "Span") -> None:
2559
+ """Create a single span in the database.
2560
+
2561
+ Args:
2562
+ span: The Span object to store.
2563
+ """
2564
+ try:
2565
+ table_name = self._get_table("spans", create_table_if_not_found=True)
2566
+ if table_name is None:
2567
+ return
2568
+
2569
+ span_dict = span.to_dict()
2570
+ # Serialize attributes as JSON string
2571
+ if "attributes" in span_dict and isinstance(span_dict["attributes"], dict):
2572
+ span_dict["attributes"] = json.dumps(span_dict["attributes"])
2573
+
2574
+ item = serialize_to_dynamo_item(span_dict)
2575
+ self.client.put_item(TableName=table_name, Item=item)
2576
+
2577
+ # Increment total_spans and error_count on trace
2578
+ traces_table_name = self._get_table("traces")
2579
+ if traces_table_name:
2580
+ try:
2581
+ update_expr = "ADD total_spans :inc"
2582
+ expr_values: Dict[str, Any] = {":inc": {"N": "1"}}
2583
+
2584
+ if span.status_code == "ERROR":
2585
+ update_expr += ", error_count :inc"
2586
+
2587
+ self.client.update_item(
2588
+ TableName=traces_table_name,
2589
+ Key={"trace_id": {"S": span.trace_id}},
2590
+ UpdateExpression=update_expr,
2591
+ ExpressionAttributeValues=expr_values,
2592
+ )
2593
+ except Exception as update_error:
2594
+ log_debug(f"Could not update trace span counts: {update_error}")
2595
+
2596
+ except Exception as e:
2597
+ log_error(f"Error creating span: {e}")
2598
+
2599
+ def create_spans(self, spans: List) -> None:
2600
+ """Create multiple spans in the database as a batch.
2601
+
2602
+ Args:
2603
+ spans: List of Span objects to store.
2604
+ """
2605
+ if not spans:
2606
+ return
2607
+
2608
+ try:
2609
+ table_name = self._get_table("spans", create_table_if_not_found=True)
2610
+ if table_name is None:
2611
+ return
2612
+
2613
+ for i in range(0, len(spans), DYNAMO_BATCH_SIZE_LIMIT):
2614
+ batch = spans[i : i + DYNAMO_BATCH_SIZE_LIMIT]
2615
+ put_requests = []
2616
+
2617
+ for span in batch:
2618
+ span_dict = span.to_dict()
2619
+ # Serialize attributes as JSON string
2620
+ if "attributes" in span_dict and isinstance(span_dict["attributes"], dict):
2621
+ span_dict["attributes"] = json.dumps(span_dict["attributes"])
2622
+
2623
+ item = serialize_to_dynamo_item(span_dict)
2624
+ put_requests.append({"PutRequest": {"Item": item}})
2625
+
2626
+ if put_requests:
2627
+ self.client.batch_write_item(RequestItems={table_name: put_requests})
2628
+
2629
+ # Update trace with total_spans and error_count using ADD (atomic increment)
2630
+ trace_id = spans[0].trace_id
2631
+ spans_count = len(spans)
2632
+ error_count = sum(1 for s in spans if s.status_code == "ERROR")
2633
+
2634
+ traces_table_name = self._get_table("traces")
2635
+ if traces_table_name:
2636
+ try:
2637
+ # Use ADD for atomic increment - works even if attributes don't exist yet
2638
+ update_expr = "ADD total_spans :spans_inc"
2639
+ expr_values: Dict[str, Any] = {":spans_inc": {"N": str(spans_count)}}
2640
+
2641
+ if error_count > 0:
2642
+ update_expr += ", error_count :error_inc"
2643
+ expr_values[":error_inc"] = {"N": str(error_count)}
2644
+
2645
+ self.client.update_item(
2646
+ TableName=traces_table_name,
2647
+ Key={"trace_id": {"S": trace_id}},
2648
+ UpdateExpression=update_expr,
2649
+ ExpressionAttributeValues=expr_values,
2650
+ )
2651
+ except Exception as update_error:
2652
+ log_debug(f"Could not update trace span counts: {update_error}")
2653
+
2654
+ except Exception as e:
2655
+ log_error(f"Error creating spans batch: {e}")
2656
+
2657
+ def get_span(self, span_id: str):
2658
+ """Get a single span by its span_id.
2659
+
2660
+ Args:
2661
+ span_id: The unique span identifier.
2662
+
2663
+ Returns:
2664
+ Optional[Span]: The span if found, None otherwise.
2665
+ """
2666
+ try:
2667
+ from agno.tracing.schemas import Span
2668
+
2669
+ table_name = self._get_table("spans")
2670
+ if table_name is None:
2671
+ return None
2672
+
2673
+ response = self.client.get_item(
2674
+ TableName=table_name,
2675
+ Key={"span_id": {"S": span_id}},
2676
+ )
2677
+
2678
+ item = response.get("Item")
2679
+ if item:
2680
+ span_data = deserialize_from_dynamodb_item(item)
2681
+ # Deserialize attributes from JSON string
2682
+ if "attributes" in span_data and isinstance(span_data["attributes"], str):
2683
+ span_data["attributes"] = json.loads(span_data["attributes"])
2684
+ return Span.from_dict(span_data)
2685
+ return None
2686
+
2687
+ except Exception as e:
2688
+ log_error(f"Error getting span: {e}")
2689
+ return None
2690
+
2691
+ def get_spans(
2692
+ self,
2693
+ trace_id: Optional[str] = None,
2694
+ parent_span_id: Optional[str] = None,
2695
+ limit: Optional[int] = 1000,
2696
+ ) -> List:
2697
+ """Get spans matching the provided filters.
2698
+
2699
+ Args:
2700
+ trace_id: Filter by trace ID.
2701
+ parent_span_id: Filter by parent span ID.
2702
+ limit: Maximum number of spans to return.
2703
+
2704
+ Returns:
2705
+ List[Span]: List of matching spans.
2706
+ """
2707
+ try:
2708
+ from agno.tracing.schemas import Span
2709
+
2710
+ table_name = self._get_table("spans")
2711
+ if table_name is None:
2712
+ return []
2713
+
2714
+ items = []
2715
+
2716
+ if trace_id:
2717
+ # Use GSI query
2718
+ query_kwargs: Dict[str, Any] = {
2719
+ "TableName": table_name,
2720
+ "IndexName": "trace_id-start_time-index",
2721
+ "KeyConditionExpression": "trace_id = :trace_id",
2722
+ "ExpressionAttributeValues": {":trace_id": {"S": trace_id}},
2723
+ }
2724
+ if limit:
2725
+ query_kwargs["Limit"] = limit
2726
+
2727
+ response = self.client.query(**query_kwargs)
2728
+ items.extend(response.get("Items", []))
2729
+
2730
+ while "LastEvaluatedKey" in response and (limit is None or len(items) < limit):
2731
+ query_kwargs["ExclusiveStartKey"] = response["LastEvaluatedKey"]
2732
+ response = self.client.query(**query_kwargs)
2733
+ items.extend(response.get("Items", []))
2734
+
2735
+ elif parent_span_id:
2736
+ # Use GSI query
2737
+ query_kwargs = {
2738
+ "TableName": table_name,
2739
+ "IndexName": "parent_span_id-start_time-index",
2740
+ "KeyConditionExpression": "parent_span_id = :parent_span_id",
2741
+ "ExpressionAttributeValues": {":parent_span_id": {"S": parent_span_id}},
2742
+ }
2743
+ if limit:
2744
+ query_kwargs["Limit"] = limit
2745
+
2746
+ response = self.client.query(**query_kwargs)
2747
+ items.extend(response.get("Items", []))
2748
+
2749
+ while "LastEvaluatedKey" in response and (limit is None or len(items) < limit):
2750
+ query_kwargs["ExclusiveStartKey"] = response["LastEvaluatedKey"]
2751
+ response = self.client.query(**query_kwargs)
2752
+ items.extend(response.get("Items", []))
2753
+
2754
+ else:
2755
+ # Scan all spans
2756
+ scan_kwargs: Dict[str, Any] = {"TableName": table_name}
2757
+ if limit:
2758
+ scan_kwargs["Limit"] = limit
2759
+
2760
+ response = self.client.scan(**scan_kwargs)
2761
+ items.extend(response.get("Items", []))
2762
+
2763
+ while "LastEvaluatedKey" in response and (limit is None or len(items) < limit):
2764
+ scan_kwargs["ExclusiveStartKey"] = response["LastEvaluatedKey"]
2765
+ response = self.client.scan(**scan_kwargs)
2766
+ items.extend(response.get("Items", []))
2767
+
2768
+ # Deserialize items
2769
+ spans = []
2770
+ for item in items[:limit] if limit else items:
2771
+ span_data = deserialize_from_dynamodb_item(item)
2772
+ # Deserialize attributes from JSON string
2773
+ if "attributes" in span_data and isinstance(span_data["attributes"], str):
2774
+ span_data["attributes"] = json.loads(span_data["attributes"])
2775
+ spans.append(Span.from_dict(span_data))
2776
+
2777
+ return spans
2778
+
2779
+ except Exception as e:
2780
+ log_error(f"Error getting spans: {e}")
2781
+ return []