agno 2.3.3__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 (108) 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 +546 -14
  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 +17 -0
  37. agno/models/anthropic/claude.py +19 -12
  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 +61 -4
  43. agno/models/cerebras/cerebras_openai.py +17 -0
  44. agno/models/cohere/chat.py +5 -1
  45. agno/models/cometapi/cometapi.py +18 -1
  46. agno/models/dashscope/dashscope.py +2 -3
  47. agno/models/deepinfra/deepinfra.py +18 -1
  48. agno/models/deepseek/deepseek.py +2 -3
  49. agno/models/fireworks/fireworks.py +18 -1
  50. agno/models/google/gemini.py +8 -2
  51. agno/models/groq/groq.py +5 -2
  52. agno/models/internlm/internlm.py +18 -1
  53. agno/models/langdb/langdb.py +13 -1
  54. agno/models/litellm/chat.py +2 -2
  55. agno/models/litellm/litellm_openai.py +18 -1
  56. agno/models/meta/llama_openai.py +19 -2
  57. agno/models/nebius/nebius.py +2 -3
  58. agno/models/nvidia/nvidia.py +20 -3
  59. agno/models/openai/chat.py +17 -2
  60. agno/models/openai/responses.py +17 -2
  61. agno/models/openrouter/openrouter.py +21 -2
  62. agno/models/perplexity/perplexity.py +17 -1
  63. agno/models/portkey/portkey.py +7 -6
  64. agno/models/requesty/requesty.py +19 -2
  65. agno/models/response.py +2 -1
  66. agno/models/sambanova/sambanova.py +20 -3
  67. agno/models/siliconflow/siliconflow.py +19 -2
  68. agno/models/together/together.py +20 -3
  69. agno/models/vercel/v0.py +20 -3
  70. agno/models/vllm/vllm.py +19 -14
  71. agno/models/xai/xai.py +19 -2
  72. agno/os/app.py +104 -0
  73. agno/os/config.py +13 -0
  74. agno/os/interfaces/whatsapp/router.py +0 -1
  75. agno/os/mcp.py +1 -0
  76. agno/os/router.py +31 -0
  77. agno/os/routers/traces/__init__.py +3 -0
  78. agno/os/routers/traces/schemas.py +414 -0
  79. agno/os/routers/traces/traces.py +499 -0
  80. agno/os/schema.py +22 -1
  81. agno/os/utils.py +57 -0
  82. agno/run/agent.py +1 -0
  83. agno/run/base.py +17 -0
  84. agno/run/team.py +4 -0
  85. agno/session/team.py +1 -0
  86. agno/table.py +10 -0
  87. agno/team/team.py +215 -65
  88. agno/tools/function.py +10 -8
  89. agno/tools/nano_banana.py +1 -1
  90. agno/tracing/__init__.py +12 -0
  91. agno/tracing/exporter.py +157 -0
  92. agno/tracing/schemas.py +276 -0
  93. agno/tracing/setup.py +111 -0
  94. agno/utils/agent.py +4 -4
  95. agno/utils/hooks.py +56 -1
  96. agno/vectordb/qdrant/qdrant.py +22 -22
  97. agno/workflow/condition.py +8 -0
  98. agno/workflow/loop.py +8 -0
  99. agno/workflow/parallel.py +8 -0
  100. agno/workflow/router.py +8 -0
  101. agno/workflow/step.py +20 -0
  102. agno/workflow/steps.py +8 -0
  103. agno/workflow/workflow.py +83 -17
  104. {agno-2.3.3.dist-info → agno-2.3.5.dist-info}/METADATA +2 -2
  105. {agno-2.3.3.dist-info → agno-2.3.5.dist-info}/RECORD +108 -98
  106. {agno-2.3.3.dist-info → agno-2.3.5.dist-info}/WHEEL +0 -0
  107. {agno-2.3.3.dist-info → agno-2.3.5.dist-info}/licenses/LICENSE +0 -0
  108. {agno-2.3.3.dist-info → agno-2.3.5.dist-info}/top_level.txt +0 -0
agno/db/sqlite/schemas.py CHANGED
@@ -95,6 +95,42 @@ METRICS_TABLE_SCHEMA = {
95
95
  ],
96
96
  }
97
97
 
98
+ TRACE_TABLE_SCHEMA = {
99
+ "trace_id": {"type": String, "primary_key": True, "nullable": False},
100
+ "name": {"type": String, "nullable": False},
101
+ "status": {"type": String, "nullable": False, "index": True},
102
+ "start_time": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
103
+ "end_time": {"type": String, "nullable": False}, # ISO 8601 datetime string
104
+ "duration_ms": {"type": BigInteger, "nullable": False},
105
+ "run_id": {"type": String, "nullable": True, "index": True},
106
+ "session_id": {"type": String, "nullable": True, "index": True},
107
+ "user_id": {"type": String, "nullable": True, "index": True},
108
+ "agent_id": {"type": String, "nullable": True, "index": True},
109
+ "team_id": {"type": String, "nullable": True, "index": True},
110
+ "workflow_id": {"type": String, "nullable": True, "index": True},
111
+ "created_at": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
112
+ }
113
+
114
+ SPAN_TABLE_SCHEMA = {
115
+ "span_id": {"type": String, "primary_key": True, "nullable": False},
116
+ "trace_id": {
117
+ "type": String,
118
+ "nullable": False,
119
+ "index": True,
120
+ "foreign_key": "agno_traces.trace_id", # Foreign key to traces table
121
+ },
122
+ "parent_span_id": {"type": String, "nullable": True, "index": True},
123
+ "name": {"type": String, "nullable": False},
124
+ "span_kind": {"type": String, "nullable": False},
125
+ "status_code": {"type": String, "nullable": False},
126
+ "status_message": {"type": String, "nullable": True},
127
+ "start_time": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
128
+ "end_time": {"type": String, "nullable": False}, # ISO 8601 datetime string
129
+ "duration_ms": {"type": BigInteger, "nullable": False},
130
+ "attributes": {"type": JSON, "nullable": True},
131
+ "created_at": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
132
+ }
133
+
98
134
  CULTURAL_KNOWLEDGE_TABLE_SCHEMA = {
99
135
  "id": {"type": String, "primary_key": True, "nullable": False},
100
136
  "name": {"type": String, "nullable": False, "index": True},
@@ -132,6 +168,8 @@ def get_table_schema_definition(table_type: str) -> dict[str, Any]:
132
168
  "metrics": METRICS_TABLE_SCHEMA,
133
169
  "memories": USER_MEMORY_TABLE_SCHEMA,
134
170
  "knowledge": KNOWLEDGE_TABLE_SCHEMA,
171
+ "traces": TRACE_TABLE_SCHEMA,
172
+ "spans": SPAN_TABLE_SCHEMA,
135
173
  "culture": CULTURAL_KNOWLEDGE_TABLE_SCHEMA,
136
174
  "versions": VERSIONS_TABLE_SCHEMA,
137
175
  }
agno/db/sqlite/sqlite.py CHANGED
@@ -1,9 +1,12 @@
1
1
  import time
2
2
  from datetime import date, datetime, timedelta, timezone
3
3
  from pathlib import Path
4
- from typing import Any, Dict, List, Optional, Sequence, Tuple, Union, cast
4
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union, cast
5
5
  from uuid import uuid4
6
6
 
7
+ if TYPE_CHECKING:
8
+ from agno.tracing.schemas import Span, Trace
9
+
7
10
  from agno.db.base import BaseDb, SessionType
8
11
  from agno.db.migrations.manager import MigrationManager
9
12
  from agno.db.schemas.culture import CulturalKnowledge
@@ -28,11 +31,11 @@ from agno.utils.log import log_debug, log_error, log_info, log_warning
28
31
  from agno.utils.string import generate_id
29
32
 
30
33
  try:
31
- from sqlalchemy import Column, MetaData, String, Table, func, select, text
34
+ from sqlalchemy import Column, MetaData, String, Table, func, select, text, update
32
35
  from sqlalchemy.dialects import sqlite
33
36
  from sqlalchemy.engine import Engine, create_engine
34
37
  from sqlalchemy.orm import scoped_session, sessionmaker
35
- from sqlalchemy.schema import Index, UniqueConstraint
38
+ from sqlalchemy.schema import ForeignKey, Index, UniqueConstraint
36
39
  except ImportError:
37
40
  raise ImportError("`sqlalchemy` not installed. Please install it using `pip install sqlalchemy`")
38
41
 
@@ -49,6 +52,8 @@ class SqliteDb(BaseDb):
49
52
  metrics_table: Optional[str] = None,
50
53
  eval_table: Optional[str] = None,
51
54
  knowledge_table: Optional[str] = None,
55
+ traces_table: Optional[str] = None,
56
+ spans_table: Optional[str] = None,
52
57
  versions_table: Optional[str] = None,
53
58
  id: Optional[str] = None,
54
59
  ):
@@ -71,6 +76,8 @@ class SqliteDb(BaseDb):
71
76
  metrics_table (Optional[str]): Name of the table to store metrics.
72
77
  eval_table (Optional[str]): Name of the table to store evaluation runs data.
73
78
  knowledge_table (Optional[str]): Name of the table to store knowledge documents data.
79
+ traces_table (Optional[str]): Name of the table to store run traces.
80
+ spans_table (Optional[str]): Name of the table to store span events.
74
81
  versions_table (Optional[str]): Name of the table to store schema versions.
75
82
  id (Optional[str]): ID of the database.
76
83
 
@@ -89,6 +96,8 @@ class SqliteDb(BaseDb):
89
96
  metrics_table=metrics_table,
90
97
  eval_table=eval_table,
91
98
  knowledge_table=knowledge_table,
99
+ traces_table=traces_table,
100
+ spans_table=spans_table,
92
101
  versions_table=versions_table,
93
102
  )
94
103
 
@@ -155,7 +164,7 @@ class SqliteDb(BaseDb):
155
164
  Table: SQLAlchemy Table object
156
165
  """
157
166
  try:
158
- table_schema = get_table_schema_definition(table_type)
167
+ table_schema = get_table_schema_definition(table_type).copy()
159
168
 
160
169
  columns: List[Column] = []
161
170
  indexes: List[str] = []
@@ -177,6 +186,15 @@ class SqliteDb(BaseDb):
177
186
  column_kwargs["unique"] = True
178
187
  unique_constraints.append(col_name)
179
188
 
189
+ # Handle foreign key constraint
190
+ if "foreign_key" in col_config:
191
+ fk_ref = col_config["foreign_key"]
192
+ # For spans table, dynamically replace the traces table reference
193
+ # with the actual trace table name configured for this db instance
194
+ if table_type == "spans" and "trace_id" in fk_ref:
195
+ fk_ref = f"{self.trace_table_name}.trace_id"
196
+ column_args.append(ForeignKey(fk_ref))
197
+
180
198
  columns.append(Column(*column_args, **column_kwargs)) # type: ignore
181
199
 
182
200
  # Create the table object
@@ -275,6 +293,26 @@ class SqliteDb(BaseDb):
275
293
  )
276
294
  return self.knowledge_table
277
295
 
296
+ elif table_type == "traces":
297
+ self.traces_table = self._get_or_create_table(
298
+ table_name=self.trace_table_name,
299
+ table_type="traces",
300
+ create_table_if_not_found=create_table_if_not_found,
301
+ )
302
+ return self.traces_table
303
+
304
+ elif table_type == "spans":
305
+ # Ensure traces table exists first (spans has FK to traces)
306
+ if create_table_if_not_found:
307
+ self._get_table(table_type="traces", create_table_if_not_found=True)
308
+
309
+ self.spans_table = self._get_or_create_table(
310
+ table_name=self.span_table_name,
311
+ table_type="spans",
312
+ create_table_if_not_found=create_table_if_not_found,
313
+ )
314
+ return self.spans_table
315
+
278
316
  elif table_type == "culture":
279
317
  self.culture_table = self._get_or_create_table(
280
318
  table_name=self.culture_table_name,
@@ -316,7 +354,6 @@ class SqliteDb(BaseDb):
316
354
  if not table_is_available:
317
355
  if not create_table_if_not_found:
318
356
  return None
319
-
320
357
  return self._create_table(table_name=table_name, table_type=table_type)
321
358
 
322
359
  # SQLite version of table validation (no schema)
@@ -2079,6 +2116,501 @@ class SqliteDb(BaseDb):
2079
2116
  log_error(f"Error renaming eval run {eval_run_id}: {e}")
2080
2117
  raise e
2081
2118
 
2119
+ # -- Trace methods --
2120
+
2121
+ def _get_traces_base_query(self, table: Table, spans_table: Optional[Table] = None):
2122
+ """Build base query for traces with aggregated span counts.
2123
+
2124
+ Args:
2125
+ table: The traces table.
2126
+ spans_table: The spans table (optional).
2127
+
2128
+ Returns:
2129
+ SQLAlchemy select statement with total_spans and error_count calculated dynamically.
2130
+ """
2131
+ from sqlalchemy import case, func, literal
2132
+
2133
+ if spans_table is not None:
2134
+ # JOIN with spans table to calculate total_spans and error_count
2135
+ return (
2136
+ select(
2137
+ table,
2138
+ func.coalesce(func.count(spans_table.c.span_id), 0).label("total_spans"),
2139
+ func.coalesce(func.sum(case((spans_table.c.status_code == "ERROR", 1), else_=0)), 0).label(
2140
+ "error_count"
2141
+ ),
2142
+ )
2143
+ .select_from(table.outerjoin(spans_table, table.c.trace_id == spans_table.c.trace_id))
2144
+ .group_by(table.c.trace_id)
2145
+ )
2146
+ else:
2147
+ # Fallback if spans table doesn't exist
2148
+ return select(table, literal(0).label("total_spans"), literal(0).label("error_count"))
2149
+
2150
+ def create_trace(self, trace: "Trace") -> None:
2151
+ """Create a single trace record in the database.
2152
+
2153
+ Args:
2154
+ trace: The Trace object to store (one per trace_id).
2155
+ """
2156
+ try:
2157
+ table = self._get_table(table_type="traces", create_table_if_not_found=True)
2158
+ if table is None:
2159
+ return
2160
+
2161
+ with self.Session() as sess, sess.begin():
2162
+ # Check if trace exists
2163
+ existing = sess.execute(table.select().where(table.c.trace_id == trace.trace_id)).fetchone()
2164
+
2165
+ if existing:
2166
+ # workflow (level 3) > team (level 2) > agent (level 1) > child/unknown (level 0)
2167
+
2168
+ def get_component_level(workflow_id, team_id, agent_id, name):
2169
+ # Check if name indicates a root span
2170
+ is_root_name = ".run" in name or ".arun" in name
2171
+
2172
+ if not is_root_name:
2173
+ return 0 # Child span (not a root)
2174
+ elif workflow_id:
2175
+ return 3 # Workflow root
2176
+ elif team_id:
2177
+ return 2 # Team root
2178
+ elif agent_id:
2179
+ return 1 # Agent root
2180
+ else:
2181
+ return 0 # Unknown
2182
+
2183
+ existing_level = get_component_level(
2184
+ existing.workflow_id, existing.team_id, existing.agent_id, existing.name
2185
+ )
2186
+ new_level = get_component_level(trace.workflow_id, trace.team_id, trace.agent_id, trace.name)
2187
+
2188
+ # Only update name if new trace is from a higher or equal level
2189
+ should_update_name = new_level > existing_level
2190
+
2191
+ # Parse existing start_time to calculate correct duration
2192
+ existing_start_time_str = existing.start_time
2193
+ if isinstance(existing_start_time_str, str):
2194
+ existing_start_time = datetime.fromisoformat(existing_start_time_str.replace("Z", "+00:00"))
2195
+ else:
2196
+ existing_start_time = trace.start_time
2197
+
2198
+ recalculated_duration_ms = int((trace.end_time - existing_start_time).total_seconds() * 1000)
2199
+
2200
+ update_values = {
2201
+ "end_time": trace.end_time.isoformat(),
2202
+ "duration_ms": recalculated_duration_ms,
2203
+ "status": trace.status,
2204
+ "name": trace.name if should_update_name else existing.name,
2205
+ }
2206
+
2207
+ # Update context fields ONLY if new value is not None (preserve non-null values)
2208
+ if trace.run_id is not None:
2209
+ update_values["run_id"] = trace.run_id
2210
+ if trace.session_id is not None:
2211
+ update_values["session_id"] = trace.session_id
2212
+ if trace.user_id is not None:
2213
+ update_values["user_id"] = trace.user_id
2214
+ if trace.agent_id is not None:
2215
+ update_values["agent_id"] = trace.agent_id
2216
+ if trace.team_id is not None:
2217
+ update_values["team_id"] = trace.team_id
2218
+ if trace.workflow_id is not None:
2219
+ update_values["workflow_id"] = trace.workflow_id
2220
+
2221
+ log_debug(
2222
+ f" Updating trace with context: run_id={update_values.get('run_id', 'unchanged')}, "
2223
+ f"session_id={update_values.get('session_id', 'unchanged')}, "
2224
+ f"user_id={update_values.get('user_id', 'unchanged')}, "
2225
+ f"agent_id={update_values.get('agent_id', 'unchanged')}, "
2226
+ f"team_id={update_values.get('team_id', 'unchanged')}, "
2227
+ )
2228
+
2229
+ stmt = update(table).where(table.c.trace_id == trace.trace_id).values(**update_values)
2230
+ sess.execute(stmt)
2231
+ else:
2232
+ trace_dict = trace.to_dict()
2233
+ trace_dict.pop("total_spans", None)
2234
+ trace_dict.pop("error_count", None)
2235
+ stmt = sqlite.insert(table).values(trace_dict)
2236
+ sess.execute(stmt)
2237
+
2238
+ except Exception as e:
2239
+ log_error(f"Error creating trace: {e}")
2240
+ # Don't raise - tracing should not break the main application flow
2241
+
2242
+ def get_trace(
2243
+ self,
2244
+ trace_id: Optional[str] = None,
2245
+ run_id: Optional[str] = None,
2246
+ ):
2247
+ """Get a single trace by trace_id or other filters.
2248
+
2249
+ Args:
2250
+ trace_id: The unique trace identifier.
2251
+ run_id: Filter by run ID (returns first match).
2252
+
2253
+ Returns:
2254
+ Optional[Trace]: The trace if found, None otherwise.
2255
+
2256
+ Note:
2257
+ If multiple filters are provided, trace_id takes precedence.
2258
+ For other filters, the most recent trace is returned.
2259
+ """
2260
+ try:
2261
+ from agno.tracing.schemas import Trace
2262
+
2263
+ table = self._get_table(table_type="traces")
2264
+ if table is None:
2265
+ return None
2266
+
2267
+ # Get spans table for JOIN
2268
+ spans_table = self._get_table(table_type="spans")
2269
+
2270
+ with self.Session() as sess:
2271
+ # Build query with aggregated span counts
2272
+ stmt = self._get_traces_base_query(table, spans_table)
2273
+
2274
+ if trace_id:
2275
+ stmt = stmt.where(table.c.trace_id == trace_id)
2276
+ elif run_id:
2277
+ stmt = stmt.where(table.c.run_id == run_id)
2278
+ else:
2279
+ log_debug("get_trace called without any filter parameters")
2280
+ return None
2281
+
2282
+ # Order by most recent and get first result
2283
+ stmt = stmt.order_by(table.c.start_time.desc()).limit(1)
2284
+ result = sess.execute(stmt).fetchone()
2285
+
2286
+ if result:
2287
+ return Trace.from_dict(dict(result._mapping))
2288
+ return None
2289
+
2290
+ except Exception as e:
2291
+ log_error(f"Error getting trace: {e}")
2292
+ return None
2293
+
2294
+ def get_traces(
2295
+ self,
2296
+ run_id: Optional[str] = None,
2297
+ session_id: Optional[str] = None,
2298
+ user_id: Optional[str] = None,
2299
+ agent_id: Optional[str] = None,
2300
+ team_id: Optional[str] = None,
2301
+ workflow_id: Optional[str] = None,
2302
+ status: Optional[str] = None,
2303
+ start_time: Optional[datetime] = None,
2304
+ end_time: Optional[datetime] = None,
2305
+ limit: Optional[int] = 20,
2306
+ page: Optional[int] = 1,
2307
+ ) -> tuple[List, int]:
2308
+ """Get traces matching the provided filters with pagination.
2309
+
2310
+ Args:
2311
+ run_id: Filter by run ID.
2312
+ session_id: Filter by session ID.
2313
+ user_id: Filter by user ID.
2314
+ agent_id: Filter by agent ID.
2315
+ team_id: Filter by team ID.
2316
+ workflow_id: Filter by workflow ID.
2317
+ status: Filter by status (OK, ERROR, UNSET).
2318
+ start_time: Filter traces starting after this datetime.
2319
+ end_time: Filter traces ending before this datetime.
2320
+ limit: Maximum number of traces to return per page.
2321
+ page: Page number (1-indexed).
2322
+
2323
+ Returns:
2324
+ tuple[List[Trace], int]: Tuple of (list of matching traces, total count).
2325
+ """
2326
+ try:
2327
+ from sqlalchemy import func
2328
+
2329
+ from agno.tracing.schemas import Trace
2330
+
2331
+ log_debug(
2332
+ f"get_traces called with filters: run_id={run_id}, session_id={session_id}, user_id={user_id}, agent_id={agent_id}, page={page}, limit={limit}"
2333
+ )
2334
+
2335
+ table = self._get_table(table_type="traces")
2336
+ if table is None:
2337
+ log_debug(" Traces table not found")
2338
+ return [], 0
2339
+
2340
+ # Get spans table for JOIN
2341
+ spans_table = self._get_table(table_type="spans")
2342
+
2343
+ with self.Session() as sess:
2344
+ # Build base query with aggregated span counts
2345
+ base_stmt = self._get_traces_base_query(table, spans_table)
2346
+
2347
+ # Apply filters
2348
+ if run_id:
2349
+ base_stmt = base_stmt.where(table.c.run_id == run_id)
2350
+ if session_id:
2351
+ log_debug(f"Filtering by session_id={session_id}")
2352
+ base_stmt = base_stmt.where(table.c.session_id == session_id)
2353
+ if user_id:
2354
+ base_stmt = base_stmt.where(table.c.user_id == user_id)
2355
+ if agent_id:
2356
+ base_stmt = base_stmt.where(table.c.agent_id == agent_id)
2357
+ if team_id:
2358
+ base_stmt = base_stmt.where(table.c.team_id == team_id)
2359
+ if workflow_id:
2360
+ base_stmt = base_stmt.where(table.c.workflow_id == workflow_id)
2361
+ if status:
2362
+ base_stmt = base_stmt.where(table.c.status == status)
2363
+ if start_time:
2364
+ # Convert datetime to ISO string for comparison
2365
+ base_stmt = base_stmt.where(table.c.start_time >= start_time.isoformat())
2366
+ if end_time:
2367
+ # Convert datetime to ISO string for comparison
2368
+ base_stmt = base_stmt.where(table.c.end_time <= end_time.isoformat())
2369
+
2370
+ # Get total count
2371
+ count_stmt = select(func.count()).select_from(base_stmt.alias())
2372
+ total_count = sess.execute(count_stmt).scalar() or 0
2373
+ log_debug(f"Total matching traces: {total_count}")
2374
+
2375
+ # Apply pagination
2376
+ offset = (page - 1) * limit if page and limit else 0
2377
+ paginated_stmt = base_stmt.order_by(table.c.start_time.desc()).limit(limit).offset(offset)
2378
+
2379
+ results = sess.execute(paginated_stmt).fetchall()
2380
+ log_debug(f"Returning page {page} with {len(results)} traces")
2381
+
2382
+ traces = [Trace.from_dict(dict(row._mapping)) for row in results]
2383
+ return traces, total_count
2384
+
2385
+ except Exception as e:
2386
+ log_error(f"Error getting traces: {e}")
2387
+ return [], 0
2388
+
2389
+ def get_trace_stats(
2390
+ self,
2391
+ user_id: Optional[str] = None,
2392
+ agent_id: Optional[str] = None,
2393
+ team_id: Optional[str] = None,
2394
+ workflow_id: Optional[str] = None,
2395
+ start_time: Optional[datetime] = None,
2396
+ end_time: Optional[datetime] = None,
2397
+ limit: Optional[int] = 20,
2398
+ page: Optional[int] = 1,
2399
+ ) -> tuple[List[Dict[str, Any]], int]:
2400
+ """Get trace statistics grouped by session.
2401
+
2402
+ Args:
2403
+ user_id: Filter by user ID.
2404
+ agent_id: Filter by agent ID.
2405
+ team_id: Filter by team ID.
2406
+ workflow_id: Filter by workflow ID.
2407
+ start_time: Filter sessions with traces created after this datetime.
2408
+ end_time: Filter sessions with traces created before this datetime.
2409
+ limit: Maximum number of sessions to return per page.
2410
+ page: Page number (1-indexed).
2411
+
2412
+ Returns:
2413
+ tuple[List[Dict], int]: Tuple of (list of session stats dicts, total count).
2414
+ """
2415
+ try:
2416
+ from sqlalchemy import func
2417
+
2418
+ log_debug(
2419
+ f"get_trace_stats called with filters: user_id={user_id}, agent_id={agent_id}, "
2420
+ f"workflow_id={workflow_id}, team_id={team_id}, "
2421
+ f"start_time={start_time}, end_time={end_time}, page={page}, limit={limit}"
2422
+ )
2423
+
2424
+ table = self._get_table(table_type="traces")
2425
+ if table is None:
2426
+ log_debug("Traces table not found")
2427
+ return [], 0
2428
+
2429
+ with self.Session() as sess:
2430
+ # Build base query grouped by session_id
2431
+ base_stmt = (
2432
+ select(
2433
+ table.c.session_id,
2434
+ table.c.user_id,
2435
+ table.c.agent_id,
2436
+ table.c.team_id,
2437
+ table.c.workflow_id,
2438
+ func.count(table.c.trace_id).label("total_traces"),
2439
+ func.min(table.c.created_at).label("first_trace_at"),
2440
+ func.max(table.c.created_at).label("last_trace_at"),
2441
+ )
2442
+ .where(table.c.session_id.isnot(None)) # Only sessions with session_id
2443
+ .group_by(
2444
+ table.c.session_id, table.c.user_id, table.c.agent_id, table.c.team_id, table.c.workflow_id
2445
+ )
2446
+ )
2447
+
2448
+ # Apply filters
2449
+ if user_id:
2450
+ base_stmt = base_stmt.where(table.c.user_id == user_id)
2451
+ if workflow_id:
2452
+ base_stmt = base_stmt.where(table.c.workflow_id == workflow_id)
2453
+ if team_id:
2454
+ base_stmt = base_stmt.where(table.c.team_id == team_id)
2455
+ if agent_id:
2456
+ base_stmt = base_stmt.where(table.c.agent_id == agent_id)
2457
+ if start_time:
2458
+ # Convert datetime to ISO string for comparison
2459
+ base_stmt = base_stmt.where(table.c.created_at >= start_time.isoformat())
2460
+ if end_time:
2461
+ # Convert datetime to ISO string for comparison
2462
+ base_stmt = base_stmt.where(table.c.created_at <= end_time.isoformat())
2463
+
2464
+ # Get total count of sessions
2465
+ count_stmt = select(func.count()).select_from(base_stmt.alias())
2466
+ total_count = sess.execute(count_stmt).scalar() or 0
2467
+ log_debug(f"Total matching sessions: {total_count}")
2468
+
2469
+ # Apply pagination and ordering
2470
+ offset = (page - 1) * limit if page and limit else 0
2471
+ paginated_stmt = base_stmt.order_by(func.max(table.c.created_at).desc()).limit(limit).offset(offset)
2472
+
2473
+ results = sess.execute(paginated_stmt).fetchall()
2474
+ log_debug(f"Returning page {page} with {len(results)} session stats")
2475
+
2476
+ # Convert to list of dicts with datetime objects
2477
+ from datetime import datetime
2478
+
2479
+ stats_list = []
2480
+ for row in results:
2481
+ # Convert ISO strings to datetime objects
2482
+ first_trace_at_str = row.first_trace_at
2483
+ last_trace_at_str = row.last_trace_at
2484
+
2485
+ # Parse ISO format strings to datetime objects
2486
+ first_trace_at = datetime.fromisoformat(first_trace_at_str.replace("Z", "+00:00"))
2487
+ last_trace_at = datetime.fromisoformat(last_trace_at_str.replace("Z", "+00:00"))
2488
+
2489
+ stats_list.append(
2490
+ {
2491
+ "session_id": row.session_id,
2492
+ "user_id": row.user_id,
2493
+ "agent_id": row.agent_id,
2494
+ "team_id": row.team_id,
2495
+ "workflow_id": row.workflow_id,
2496
+ "total_traces": row.total_traces,
2497
+ "first_trace_at": first_trace_at,
2498
+ "last_trace_at": last_trace_at,
2499
+ }
2500
+ )
2501
+
2502
+ return stats_list, total_count
2503
+
2504
+ except Exception as e:
2505
+ log_error(f"Error getting trace stats: {e}")
2506
+ return [], 0
2507
+
2508
+ # -- Span methods --
2509
+
2510
+ def create_span(self, span: "Span") -> None:
2511
+ """Create a single span in the database.
2512
+
2513
+ Args:
2514
+ span: The Span object to store.
2515
+ """
2516
+ try:
2517
+ table = self._get_table(table_type="spans", create_table_if_not_found=True)
2518
+ if table is None:
2519
+ return
2520
+
2521
+ with self.Session() as sess, sess.begin():
2522
+ stmt = sqlite.insert(table).values(span.to_dict())
2523
+ sess.execute(stmt)
2524
+
2525
+ except Exception as e:
2526
+ log_error(f"Error creating span: {e}")
2527
+
2528
+ def create_spans(self, spans: List) -> None:
2529
+ """Create multiple spans in the database as a batch.
2530
+
2531
+ Args:
2532
+ spans: List of Span objects to store.
2533
+ """
2534
+ if not spans:
2535
+ return
2536
+
2537
+ try:
2538
+ table = self._get_table(table_type="spans", create_table_if_not_found=True)
2539
+ if table is None:
2540
+ return
2541
+
2542
+ with self.Session() as sess, sess.begin():
2543
+ for span in spans:
2544
+ stmt = sqlite.insert(table).values(span.to_dict())
2545
+ sess.execute(stmt)
2546
+
2547
+ except Exception as e:
2548
+ log_error(f"Error creating spans batch: {e}")
2549
+
2550
+ def get_span(self, span_id: str):
2551
+ """Get a single span by its span_id.
2552
+
2553
+ Args:
2554
+ span_id: The unique span identifier.
2555
+
2556
+ Returns:
2557
+ Optional[Span]: The span if found, None otherwise.
2558
+ """
2559
+ try:
2560
+ from agno.tracing.schemas import Span
2561
+
2562
+ table = self._get_table(table_type="spans")
2563
+ if table is None:
2564
+ return None
2565
+
2566
+ with self.Session() as sess:
2567
+ stmt = table.select().where(table.c.span_id == span_id)
2568
+ result = sess.execute(stmt).fetchone()
2569
+ if result:
2570
+ return Span.from_dict(dict(result._mapping))
2571
+ return None
2572
+
2573
+ except Exception as e:
2574
+ log_error(f"Error getting span: {e}")
2575
+ return None
2576
+
2577
+ def get_spans(
2578
+ self,
2579
+ trace_id: Optional[str] = None,
2580
+ parent_span_id: Optional[str] = None,
2581
+ ) -> List:
2582
+ """Get spans matching the provided filters.
2583
+
2584
+ Args:
2585
+ trace_id: Filter by trace ID.
2586
+ parent_span_id: Filter by parent span ID.
2587
+
2588
+ Returns:
2589
+ List[Span]: List of matching spans.
2590
+ """
2591
+ try:
2592
+ from agno.tracing.schemas import Span
2593
+
2594
+ table = self._get_table(table_type="spans")
2595
+ if table is None:
2596
+ return []
2597
+
2598
+ with self.Session() as sess:
2599
+ stmt = table.select()
2600
+
2601
+ # Apply filters
2602
+ if trace_id:
2603
+ stmt = stmt.where(table.c.trace_id == trace_id)
2604
+ if parent_span_id:
2605
+ stmt = stmt.where(table.c.parent_span_id == parent_span_id)
2606
+
2607
+ results = sess.execute(stmt).fetchall()
2608
+ return [Span.from_dict(dict(row._mapping)) for row in results]
2609
+
2610
+ except Exception as e:
2611
+ log_error(f"Error getting spans: {e}")
2612
+ return []
2613
+
2082
2614
  # -- Migrations --
2083
2615
 
2084
2616
  def migrate_table_from_v1_to_v2(self, v1_db_schema: str, v1_table_name: str, v1_table_type: str):