agno 2.3.4__py3-none-any.whl → 2.3.6__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 (116) hide show
  1. agno/agent/agent.py +184 -45
  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 +553 -15
  17. agno/db/postgres/postgres.py +544 -5
  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 +549 -10
  24. agno/db/sqlite/schemas.py +38 -0
  25. agno/db/sqlite/sqlite.py +540 -9
  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 +18 -8
  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/integrations/discord/client.py +1 -1
  36. agno/knowledge/chunking/semantic.py +2 -2
  37. agno/models/aimlapi/aimlapi.py +2 -3
  38. agno/models/anthropic/claude.py +18 -13
  39. agno/models/aws/bedrock.py +3 -4
  40. agno/models/aws/claude.py +5 -1
  41. agno/models/azure/ai_foundry.py +2 -2
  42. agno/models/azure/openai_chat.py +8 -0
  43. agno/models/cerebras/cerebras.py +62 -11
  44. agno/models/cerebras/cerebras_openai.py +2 -3
  45. agno/models/cohere/chat.py +1 -5
  46. agno/models/cometapi/cometapi.py +2 -3
  47. agno/models/dashscope/dashscope.py +2 -3
  48. agno/models/deepinfra/deepinfra.py +2 -3
  49. agno/models/deepseek/deepseek.py +2 -3
  50. agno/models/fireworks/fireworks.py +2 -3
  51. agno/models/google/gemini.py +9 -7
  52. agno/models/groq/groq.py +2 -3
  53. agno/models/huggingface/huggingface.py +1 -5
  54. agno/models/ibm/watsonx.py +1 -5
  55. agno/models/internlm/internlm.py +2 -3
  56. agno/models/langdb/langdb.py +6 -4
  57. agno/models/litellm/chat.py +2 -2
  58. agno/models/litellm/litellm_openai.py +2 -3
  59. agno/models/meta/llama.py +1 -5
  60. agno/models/meta/llama_openai.py +4 -5
  61. agno/models/mistral/mistral.py +1 -5
  62. agno/models/nebius/nebius.py +2 -3
  63. agno/models/nvidia/nvidia.py +4 -5
  64. agno/models/openai/chat.py +14 -3
  65. agno/models/openai/responses.py +14 -3
  66. agno/models/openrouter/openrouter.py +4 -5
  67. agno/models/perplexity/perplexity.py +2 -3
  68. agno/models/portkey/portkey.py +7 -6
  69. agno/models/requesty/requesty.py +4 -5
  70. agno/models/response.py +2 -1
  71. agno/models/sambanova/sambanova.py +4 -5
  72. agno/models/siliconflow/siliconflow.py +3 -4
  73. agno/models/together/together.py +4 -5
  74. agno/models/vercel/v0.py +4 -5
  75. agno/models/vllm/vllm.py +19 -14
  76. agno/models/xai/xai.py +4 -5
  77. agno/os/app.py +104 -0
  78. agno/os/config.py +13 -0
  79. agno/os/interfaces/whatsapp/router.py +0 -1
  80. agno/os/interfaces/whatsapp/security.py +3 -1
  81. agno/os/mcp.py +1 -0
  82. agno/os/router.py +31 -0
  83. agno/os/routers/traces/__init__.py +3 -0
  84. agno/os/routers/traces/schemas.py +414 -0
  85. agno/os/routers/traces/traces.py +499 -0
  86. agno/os/schema.py +12 -2
  87. agno/os/utils.py +57 -0
  88. agno/run/agent.py +1 -0
  89. agno/run/base.py +17 -0
  90. agno/run/team.py +4 -0
  91. agno/table.py +10 -0
  92. agno/team/team.py +221 -69
  93. agno/tools/function.py +10 -8
  94. agno/tools/google_drive.py +4 -3
  95. agno/tools/nano_banana.py +1 -1
  96. agno/tools/spotify.py +922 -0
  97. agno/tracing/__init__.py +12 -0
  98. agno/tracing/exporter.py +157 -0
  99. agno/tracing/schemas.py +276 -0
  100. agno/tracing/setup.py +111 -0
  101. agno/utils/agent.py +6 -6
  102. agno/utils/hooks.py +56 -1
  103. agno/utils/mcp.py +1 -1
  104. agno/vectordb/qdrant/qdrant.py +22 -22
  105. agno/workflow/condition.py +8 -0
  106. agno/workflow/loop.py +8 -0
  107. agno/workflow/parallel.py +8 -0
  108. agno/workflow/router.py +8 -0
  109. agno/workflow/step.py +20 -0
  110. agno/workflow/steps.py +8 -0
  111. agno/workflow/workflow.py +88 -19
  112. {agno-2.3.4.dist-info → agno-2.3.6.dist-info}/METADATA +38 -33
  113. {agno-2.3.4.dist-info → agno-2.3.6.dist-info}/RECORD +116 -105
  114. {agno-2.3.4.dist-info → agno-2.3.6.dist-info}/WHEEL +0 -0
  115. {agno-2.3.4.dist-info → agno-2.3.6.dist-info}/licenses/LICENSE +0 -0
  116. {agno-2.3.4.dist-info → agno-2.3.6.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
@@ -227,9 +245,9 @@ class SqliteDb(BaseDb):
227
245
  return table
228
246
 
229
247
  except Exception as e:
230
- from traceback import format_exc
248
+ from traceback import print_exc
231
249
 
232
- print(format_exc())
250
+ print_exc()
233
251
  log_error(f"Could not create table '{table_name}': {e}")
234
252
  raise e
235
253
 
@@ -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)
@@ -1072,9 +1109,8 @@ class SqliteDb(BaseDb):
1072
1109
 
1073
1110
  with self.Session() as sess, sess.begin():
1074
1111
  # Select topics from all results
1075
- stmt = select(func.json_array_elements_text(table.c.topics)).select_from(table)
1112
+ stmt = select(table.c.topics)
1076
1113
  result = sess.execute(stmt).fetchall()
1077
-
1078
1114
  return list(set([record[0] for record in result]))
1079
1115
 
1080
1116
  except Exception as e:
@@ -2079,6 +2115,501 @@ class SqliteDb(BaseDb):
2079
2115
  log_error(f"Error renaming eval run {eval_run_id}: {e}")
2080
2116
  raise e
2081
2117
 
2118
+ # -- Trace methods --
2119
+
2120
+ def _get_traces_base_query(self, table: Table, spans_table: Optional[Table] = None):
2121
+ """Build base query for traces with aggregated span counts.
2122
+
2123
+ Args:
2124
+ table: The traces table.
2125
+ spans_table: The spans table (optional).
2126
+
2127
+ Returns:
2128
+ SQLAlchemy select statement with total_spans and error_count calculated dynamically.
2129
+ """
2130
+ from sqlalchemy import case, func, literal
2131
+
2132
+ if spans_table is not None:
2133
+ # JOIN with spans table to calculate total_spans and error_count
2134
+ return (
2135
+ select(
2136
+ table,
2137
+ func.coalesce(func.count(spans_table.c.span_id), 0).label("total_spans"),
2138
+ func.coalesce(func.sum(case((spans_table.c.status_code == "ERROR", 1), else_=0)), 0).label(
2139
+ "error_count"
2140
+ ),
2141
+ )
2142
+ .select_from(table.outerjoin(spans_table, table.c.trace_id == spans_table.c.trace_id))
2143
+ .group_by(table.c.trace_id)
2144
+ )
2145
+ else:
2146
+ # Fallback if spans table doesn't exist
2147
+ return select(table, literal(0).label("total_spans"), literal(0).label("error_count"))
2148
+
2149
+ def create_trace(self, trace: "Trace") -> None:
2150
+ """Create a single trace record in the database.
2151
+
2152
+ Args:
2153
+ trace: The Trace object to store (one per trace_id).
2154
+ """
2155
+ try:
2156
+ table = self._get_table(table_type="traces", create_table_if_not_found=True)
2157
+ if table is None:
2158
+ return
2159
+
2160
+ with self.Session() as sess, sess.begin():
2161
+ # Check if trace exists
2162
+ existing = sess.execute(table.select().where(table.c.trace_id == trace.trace_id)).fetchone()
2163
+
2164
+ if existing:
2165
+ # workflow (level 3) > team (level 2) > agent (level 1) > child/unknown (level 0)
2166
+
2167
+ def get_component_level(workflow_id, team_id, agent_id, name):
2168
+ # Check if name indicates a root span
2169
+ is_root_name = ".run" in name or ".arun" in name
2170
+
2171
+ if not is_root_name:
2172
+ return 0 # Child span (not a root)
2173
+ elif workflow_id:
2174
+ return 3 # Workflow root
2175
+ elif team_id:
2176
+ return 2 # Team root
2177
+ elif agent_id:
2178
+ return 1 # Agent root
2179
+ else:
2180
+ return 0 # Unknown
2181
+
2182
+ existing_level = get_component_level(
2183
+ existing.workflow_id, existing.team_id, existing.agent_id, existing.name
2184
+ )
2185
+ new_level = get_component_level(trace.workflow_id, trace.team_id, trace.agent_id, trace.name)
2186
+
2187
+ # Only update name if new trace is from a higher or equal level
2188
+ should_update_name = new_level > existing_level
2189
+
2190
+ # Parse existing start_time to calculate correct duration
2191
+ existing_start_time_str = existing.start_time
2192
+ if isinstance(existing_start_time_str, str):
2193
+ existing_start_time = datetime.fromisoformat(existing_start_time_str.replace("Z", "+00:00"))
2194
+ else:
2195
+ existing_start_time = trace.start_time
2196
+
2197
+ recalculated_duration_ms = int((trace.end_time - existing_start_time).total_seconds() * 1000)
2198
+
2199
+ update_values = {
2200
+ "end_time": trace.end_time.isoformat(),
2201
+ "duration_ms": recalculated_duration_ms,
2202
+ "status": trace.status,
2203
+ "name": trace.name if should_update_name else existing.name,
2204
+ }
2205
+
2206
+ # Update context fields ONLY if new value is not None (preserve non-null values)
2207
+ if trace.run_id is not None:
2208
+ update_values["run_id"] = trace.run_id
2209
+ if trace.session_id is not None:
2210
+ update_values["session_id"] = trace.session_id
2211
+ if trace.user_id is not None:
2212
+ update_values["user_id"] = trace.user_id
2213
+ if trace.agent_id is not None:
2214
+ update_values["agent_id"] = trace.agent_id
2215
+ if trace.team_id is not None:
2216
+ update_values["team_id"] = trace.team_id
2217
+ if trace.workflow_id is not None:
2218
+ update_values["workflow_id"] = trace.workflow_id
2219
+
2220
+ log_debug(
2221
+ f" Updating trace with context: run_id={update_values.get('run_id', 'unchanged')}, "
2222
+ f"session_id={update_values.get('session_id', 'unchanged')}, "
2223
+ f"user_id={update_values.get('user_id', 'unchanged')}, "
2224
+ f"agent_id={update_values.get('agent_id', 'unchanged')}, "
2225
+ f"team_id={update_values.get('team_id', 'unchanged')}, "
2226
+ )
2227
+
2228
+ stmt = update(table).where(table.c.trace_id == trace.trace_id).values(**update_values)
2229
+ sess.execute(stmt)
2230
+ else:
2231
+ trace_dict = trace.to_dict()
2232
+ trace_dict.pop("total_spans", None)
2233
+ trace_dict.pop("error_count", None)
2234
+ stmt = sqlite.insert(table).values(trace_dict)
2235
+ sess.execute(stmt)
2236
+
2237
+ except Exception as e:
2238
+ log_error(f"Error creating trace: {e}")
2239
+ # Don't raise - tracing should not break the main application flow
2240
+
2241
+ def get_trace(
2242
+ self,
2243
+ trace_id: Optional[str] = None,
2244
+ run_id: Optional[str] = None,
2245
+ ):
2246
+ """Get a single trace by trace_id or other filters.
2247
+
2248
+ Args:
2249
+ trace_id: The unique trace identifier.
2250
+ run_id: Filter by run ID (returns first match).
2251
+
2252
+ Returns:
2253
+ Optional[Trace]: The trace if found, None otherwise.
2254
+
2255
+ Note:
2256
+ If multiple filters are provided, trace_id takes precedence.
2257
+ For other filters, the most recent trace is returned.
2258
+ """
2259
+ try:
2260
+ from agno.tracing.schemas import Trace
2261
+
2262
+ table = self._get_table(table_type="traces")
2263
+ if table is None:
2264
+ return None
2265
+
2266
+ # Get spans table for JOIN
2267
+ spans_table = self._get_table(table_type="spans")
2268
+
2269
+ with self.Session() as sess:
2270
+ # Build query with aggregated span counts
2271
+ stmt = self._get_traces_base_query(table, spans_table)
2272
+
2273
+ if trace_id:
2274
+ stmt = stmt.where(table.c.trace_id == trace_id)
2275
+ elif run_id:
2276
+ stmt = stmt.where(table.c.run_id == run_id)
2277
+ else:
2278
+ log_debug("get_trace called without any filter parameters")
2279
+ return None
2280
+
2281
+ # Order by most recent and get first result
2282
+ stmt = stmt.order_by(table.c.start_time.desc()).limit(1)
2283
+ result = sess.execute(stmt).fetchone()
2284
+
2285
+ if result:
2286
+ return Trace.from_dict(dict(result._mapping))
2287
+ return None
2288
+
2289
+ except Exception as e:
2290
+ log_error(f"Error getting trace: {e}")
2291
+ return None
2292
+
2293
+ def get_traces(
2294
+ self,
2295
+ run_id: Optional[str] = None,
2296
+ session_id: Optional[str] = None,
2297
+ user_id: Optional[str] = None,
2298
+ agent_id: Optional[str] = None,
2299
+ team_id: Optional[str] = None,
2300
+ workflow_id: Optional[str] = None,
2301
+ status: Optional[str] = None,
2302
+ start_time: Optional[datetime] = None,
2303
+ end_time: Optional[datetime] = None,
2304
+ limit: Optional[int] = 20,
2305
+ page: Optional[int] = 1,
2306
+ ) -> tuple[List, int]:
2307
+ """Get traces matching the provided filters with pagination.
2308
+
2309
+ Args:
2310
+ run_id: Filter by run ID.
2311
+ session_id: Filter by session ID.
2312
+ user_id: Filter by user ID.
2313
+ agent_id: Filter by agent ID.
2314
+ team_id: Filter by team ID.
2315
+ workflow_id: Filter by workflow ID.
2316
+ status: Filter by status (OK, ERROR, UNSET).
2317
+ start_time: Filter traces starting after this datetime.
2318
+ end_time: Filter traces ending before this datetime.
2319
+ limit: Maximum number of traces to return per page.
2320
+ page: Page number (1-indexed).
2321
+
2322
+ Returns:
2323
+ tuple[List[Trace], int]: Tuple of (list of matching traces, total count).
2324
+ """
2325
+ try:
2326
+ from sqlalchemy import func
2327
+
2328
+ from agno.tracing.schemas import Trace
2329
+
2330
+ log_debug(
2331
+ 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}"
2332
+ )
2333
+
2334
+ table = self._get_table(table_type="traces")
2335
+ if table is None:
2336
+ log_debug(" Traces table not found")
2337
+ return [], 0
2338
+
2339
+ # Get spans table for JOIN
2340
+ spans_table = self._get_table(table_type="spans")
2341
+
2342
+ with self.Session() as sess:
2343
+ # Build base query with aggregated span counts
2344
+ base_stmt = self._get_traces_base_query(table, spans_table)
2345
+
2346
+ # Apply filters
2347
+ if run_id:
2348
+ base_stmt = base_stmt.where(table.c.run_id == run_id)
2349
+ if session_id:
2350
+ log_debug(f"Filtering by session_id={session_id}")
2351
+ base_stmt = base_stmt.where(table.c.session_id == session_id)
2352
+ if user_id:
2353
+ base_stmt = base_stmt.where(table.c.user_id == user_id)
2354
+ if agent_id:
2355
+ base_stmt = base_stmt.where(table.c.agent_id == agent_id)
2356
+ if team_id:
2357
+ base_stmt = base_stmt.where(table.c.team_id == team_id)
2358
+ if workflow_id:
2359
+ base_stmt = base_stmt.where(table.c.workflow_id == workflow_id)
2360
+ if status:
2361
+ base_stmt = base_stmt.where(table.c.status == status)
2362
+ if start_time:
2363
+ # Convert datetime to ISO string for comparison
2364
+ base_stmt = base_stmt.where(table.c.start_time >= start_time.isoformat())
2365
+ if end_time:
2366
+ # Convert datetime to ISO string for comparison
2367
+ base_stmt = base_stmt.where(table.c.end_time <= end_time.isoformat())
2368
+
2369
+ # Get total count
2370
+ count_stmt = select(func.count()).select_from(base_stmt.alias())
2371
+ total_count = sess.execute(count_stmt).scalar() or 0
2372
+ log_debug(f"Total matching traces: {total_count}")
2373
+
2374
+ # Apply pagination
2375
+ offset = (page - 1) * limit if page and limit else 0
2376
+ paginated_stmt = base_stmt.order_by(table.c.start_time.desc()).limit(limit).offset(offset)
2377
+
2378
+ results = sess.execute(paginated_stmt).fetchall()
2379
+ log_debug(f"Returning page {page} with {len(results)} traces")
2380
+
2381
+ traces = [Trace.from_dict(dict(row._mapping)) for row in results]
2382
+ return traces, total_count
2383
+
2384
+ except Exception as e:
2385
+ log_error(f"Error getting traces: {e}")
2386
+ return [], 0
2387
+
2388
+ def get_trace_stats(
2389
+ self,
2390
+ user_id: Optional[str] = None,
2391
+ agent_id: Optional[str] = None,
2392
+ team_id: Optional[str] = None,
2393
+ workflow_id: Optional[str] = None,
2394
+ start_time: Optional[datetime] = None,
2395
+ end_time: Optional[datetime] = None,
2396
+ limit: Optional[int] = 20,
2397
+ page: Optional[int] = 1,
2398
+ ) -> tuple[List[Dict[str, Any]], int]:
2399
+ """Get trace statistics grouped by session.
2400
+
2401
+ Args:
2402
+ user_id: Filter by user ID.
2403
+ agent_id: Filter by agent ID.
2404
+ team_id: Filter by team ID.
2405
+ workflow_id: Filter by workflow ID.
2406
+ start_time: Filter sessions with traces created after this datetime.
2407
+ end_time: Filter sessions with traces created before this datetime.
2408
+ limit: Maximum number of sessions to return per page.
2409
+ page: Page number (1-indexed).
2410
+
2411
+ Returns:
2412
+ tuple[List[Dict], int]: Tuple of (list of session stats dicts, total count).
2413
+ """
2414
+ try:
2415
+ from sqlalchemy import func
2416
+
2417
+ log_debug(
2418
+ f"get_trace_stats called with filters: user_id={user_id}, agent_id={agent_id}, "
2419
+ f"workflow_id={workflow_id}, team_id={team_id}, "
2420
+ f"start_time={start_time}, end_time={end_time}, page={page}, limit={limit}"
2421
+ )
2422
+
2423
+ table = self._get_table(table_type="traces")
2424
+ if table is None:
2425
+ log_debug("Traces table not found")
2426
+ return [], 0
2427
+
2428
+ with self.Session() as sess:
2429
+ # Build base query grouped by session_id
2430
+ base_stmt = (
2431
+ select(
2432
+ table.c.session_id,
2433
+ table.c.user_id,
2434
+ table.c.agent_id,
2435
+ table.c.team_id,
2436
+ table.c.workflow_id,
2437
+ func.count(table.c.trace_id).label("total_traces"),
2438
+ func.min(table.c.created_at).label("first_trace_at"),
2439
+ func.max(table.c.created_at).label("last_trace_at"),
2440
+ )
2441
+ .where(table.c.session_id.isnot(None)) # Only sessions with session_id
2442
+ .group_by(
2443
+ table.c.session_id, table.c.user_id, table.c.agent_id, table.c.team_id, table.c.workflow_id
2444
+ )
2445
+ )
2446
+
2447
+ # Apply filters
2448
+ if user_id:
2449
+ base_stmt = base_stmt.where(table.c.user_id == user_id)
2450
+ if workflow_id:
2451
+ base_stmt = base_stmt.where(table.c.workflow_id == workflow_id)
2452
+ if team_id:
2453
+ base_stmt = base_stmt.where(table.c.team_id == team_id)
2454
+ if agent_id:
2455
+ base_stmt = base_stmt.where(table.c.agent_id == agent_id)
2456
+ if start_time:
2457
+ # Convert datetime to ISO string for comparison
2458
+ base_stmt = base_stmt.where(table.c.created_at >= start_time.isoformat())
2459
+ if end_time:
2460
+ # Convert datetime to ISO string for comparison
2461
+ base_stmt = base_stmt.where(table.c.created_at <= end_time.isoformat())
2462
+
2463
+ # Get total count of sessions
2464
+ count_stmt = select(func.count()).select_from(base_stmt.alias())
2465
+ total_count = sess.execute(count_stmt).scalar() or 0
2466
+ log_debug(f"Total matching sessions: {total_count}")
2467
+
2468
+ # Apply pagination and ordering
2469
+ offset = (page - 1) * limit if page and limit else 0
2470
+ paginated_stmt = base_stmt.order_by(func.max(table.c.created_at).desc()).limit(limit).offset(offset)
2471
+
2472
+ results = sess.execute(paginated_stmt).fetchall()
2473
+ log_debug(f"Returning page {page} with {len(results)} session stats")
2474
+
2475
+ # Convert to list of dicts with datetime objects
2476
+ from datetime import datetime
2477
+
2478
+ stats_list = []
2479
+ for row in results:
2480
+ # Convert ISO strings to datetime objects
2481
+ first_trace_at_str = row.first_trace_at
2482
+ last_trace_at_str = row.last_trace_at
2483
+
2484
+ # Parse ISO format strings to datetime objects
2485
+ first_trace_at = datetime.fromisoformat(first_trace_at_str.replace("Z", "+00:00"))
2486
+ last_trace_at = datetime.fromisoformat(last_trace_at_str.replace("Z", "+00:00"))
2487
+
2488
+ stats_list.append(
2489
+ {
2490
+ "session_id": row.session_id,
2491
+ "user_id": row.user_id,
2492
+ "agent_id": row.agent_id,
2493
+ "team_id": row.team_id,
2494
+ "workflow_id": row.workflow_id,
2495
+ "total_traces": row.total_traces,
2496
+ "first_trace_at": first_trace_at,
2497
+ "last_trace_at": last_trace_at,
2498
+ }
2499
+ )
2500
+
2501
+ return stats_list, total_count
2502
+
2503
+ except Exception as e:
2504
+ log_error(f"Error getting trace stats: {e}")
2505
+ return [], 0
2506
+
2507
+ # -- Span methods --
2508
+
2509
+ def create_span(self, span: "Span") -> None:
2510
+ """Create a single span in the database.
2511
+
2512
+ Args:
2513
+ span: The Span object to store.
2514
+ """
2515
+ try:
2516
+ table = self._get_table(table_type="spans", create_table_if_not_found=True)
2517
+ if table is None:
2518
+ return
2519
+
2520
+ with self.Session() as sess, sess.begin():
2521
+ stmt = sqlite.insert(table).values(span.to_dict())
2522
+ sess.execute(stmt)
2523
+
2524
+ except Exception as e:
2525
+ log_error(f"Error creating span: {e}")
2526
+
2527
+ def create_spans(self, spans: List) -> None:
2528
+ """Create multiple spans in the database as a batch.
2529
+
2530
+ Args:
2531
+ spans: List of Span objects to store.
2532
+ """
2533
+ if not spans:
2534
+ return
2535
+
2536
+ try:
2537
+ table = self._get_table(table_type="spans", create_table_if_not_found=True)
2538
+ if table is None:
2539
+ return
2540
+
2541
+ with self.Session() as sess, sess.begin():
2542
+ for span in spans:
2543
+ stmt = sqlite.insert(table).values(span.to_dict())
2544
+ sess.execute(stmt)
2545
+
2546
+ except Exception as e:
2547
+ log_error(f"Error creating spans batch: {e}")
2548
+
2549
+ def get_span(self, span_id: str):
2550
+ """Get a single span by its span_id.
2551
+
2552
+ Args:
2553
+ span_id: The unique span identifier.
2554
+
2555
+ Returns:
2556
+ Optional[Span]: The span if found, None otherwise.
2557
+ """
2558
+ try:
2559
+ from agno.tracing.schemas import Span
2560
+
2561
+ table = self._get_table(table_type="spans")
2562
+ if table is None:
2563
+ return None
2564
+
2565
+ with self.Session() as sess:
2566
+ stmt = table.select().where(table.c.span_id == span_id)
2567
+ result = sess.execute(stmt).fetchone()
2568
+ if result:
2569
+ return Span.from_dict(dict(result._mapping))
2570
+ return None
2571
+
2572
+ except Exception as e:
2573
+ log_error(f"Error getting span: {e}")
2574
+ return None
2575
+
2576
+ def get_spans(
2577
+ self,
2578
+ trace_id: Optional[str] = None,
2579
+ parent_span_id: Optional[str] = None,
2580
+ ) -> List:
2581
+ """Get spans matching the provided filters.
2582
+
2583
+ Args:
2584
+ trace_id: Filter by trace ID.
2585
+ parent_span_id: Filter by parent span ID.
2586
+
2587
+ Returns:
2588
+ List[Span]: List of matching spans.
2589
+ """
2590
+ try:
2591
+ from agno.tracing.schemas import Span
2592
+
2593
+ table = self._get_table(table_type="spans")
2594
+ if table is None:
2595
+ return []
2596
+
2597
+ with self.Session() as sess:
2598
+ stmt = table.select()
2599
+
2600
+ # Apply filters
2601
+ if trace_id:
2602
+ stmt = stmt.where(table.c.trace_id == trace_id)
2603
+ if parent_span_id:
2604
+ stmt = stmt.where(table.c.parent_span_id == parent_span_id)
2605
+
2606
+ results = sess.execute(stmt).fetchall()
2607
+ return [Span.from_dict(dict(row._mapping)) for row in results]
2608
+
2609
+ except Exception as e:
2610
+ log_error(f"Error getting spans: {e}")
2611
+ return []
2612
+
2082
2613
  # -- Migrations --
2083
2614
 
2084
2615
  def migrate_table_from_v1_to_v2(self, v1_db_schema: str, v1_table_name: str, v1_table_type: str):