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/mysql/mysql.py CHANGED
@@ -1,9 +1,12 @@
1
1
  import time
2
2
  from datetime import date, datetime, timedelta, timezone
3
- from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
3
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union
4
4
  from uuid import uuid4
5
5
 
6
- from sqlalchemy import Index, UniqueConstraint
6
+ from sqlalchemy import ForeignKey, Index, UniqueConstraint
7
+
8
+ if TYPE_CHECKING:
9
+ from agno.tracing.schemas import Span, Trace
7
10
 
8
11
  from agno.db.base import BaseDb, SessionType
9
12
  from agno.db.migrations.manager import MigrationManager
@@ -51,6 +54,8 @@ class MySQLDb(BaseDb):
51
54
  metrics_table: Optional[str] = None,
52
55
  eval_table: Optional[str] = None,
53
56
  knowledge_table: Optional[str] = None,
57
+ traces_table: Optional[str] = None,
58
+ spans_table: Optional[str] = None,
54
59
  versions_table: Optional[str] = None,
55
60
  id: Optional[str] = None,
56
61
  ):
@@ -72,6 +77,8 @@ class MySQLDb(BaseDb):
72
77
  metrics_table (Optional[str]): Name of the table to store metrics.
73
78
  eval_table (Optional[str]): Name of the table to store evaluation runs data.
74
79
  knowledge_table (Optional[str]): Name of the table to store knowledge content.
80
+ traces_table (Optional[str]): Name of the table to store run traces.
81
+ spans_table (Optional[str]): Name of the table to store span events.
75
82
  versions_table (Optional[str]): Name of the table to store schema versions.
76
83
  id (Optional[str]): ID of the database.
77
84
 
@@ -93,6 +100,8 @@ class MySQLDb(BaseDb):
93
100
  metrics_table=metrics_table,
94
101
  eval_table=eval_table,
95
102
  knowledge_table=knowledge_table,
103
+ traces_table=traces_table,
104
+ spans_table=spans_table,
96
105
  versions_table=versions_table,
97
106
  )
98
107
 
@@ -135,7 +144,7 @@ class MySQLDb(BaseDb):
135
144
  Table: SQLAlchemy Table object
136
145
  """
137
146
  try:
138
- table_schema = get_table_schema_definition(table_type)
147
+ table_schema = get_table_schema_definition(table_type).copy()
139
148
 
140
149
  columns: List[Column] = []
141
150
  indexes: List[str] = []
@@ -155,6 +164,16 @@ class MySQLDb(BaseDb):
155
164
  if col_config.get("unique", False):
156
165
  column_kwargs["unique"] = True
157
166
  unique_constraints.append(col_name)
167
+
168
+ # Handle foreign key constraint
169
+ if "foreign_key" in col_config:
170
+ fk_ref = col_config["foreign_key"]
171
+ # For spans table, dynamically replace the traces table reference
172
+ # with the actual trace table name configured for this db instance
173
+ if table_type == "spans" and "trace_id" in fk_ref:
174
+ fk_ref = f"{self.db_schema}.{self.trace_table_name}.trace_id"
175
+ column_args.append(ForeignKey(fk_ref))
176
+
158
177
  columns.append(Column(*column_args, **column_kwargs)) # type: ignore
159
178
 
160
179
  # Create the table object
@@ -296,6 +315,26 @@ class MySQLDb(BaseDb):
296
315
  )
297
316
  return self.versions_table
298
317
 
318
+ if table_type == "traces":
319
+ self.traces_table = self._get_or_create_table(
320
+ table_name=self.trace_table_name,
321
+ table_type="traces",
322
+ create_table_if_not_found=create_table_if_not_found,
323
+ )
324
+ return self.traces_table
325
+
326
+ if table_type == "spans":
327
+ # Ensure traces table exists first (spans has FK to traces)
328
+ if create_table_if_not_found:
329
+ self._get_table(table_type="traces", create_table_if_not_found=True)
330
+
331
+ self.spans_table = self._get_or_create_table(
332
+ table_name=self.span_table_name,
333
+ table_type="spans",
334
+ create_table_if_not_found=create_table_if_not_found,
335
+ )
336
+ return self.spans_table
337
+
299
338
  raise ValueError(f"Unknown table type: {table_type}")
300
339
 
301
340
  def _get_or_create_table(
@@ -2373,3 +2412,497 @@ class MySQLDb(BaseDb):
2373
2412
  for memory in memories:
2374
2413
  self.upsert_user_memory(memory)
2375
2414
  log_info(f"Migrated {len(memories)} memories to table: {self.memory_table}")
2415
+
2416
+ # --- Traces ---
2417
+ def _get_traces_base_query(self, table: Table, spans_table: Optional[Table] = None):
2418
+ """Build base query for traces with aggregated span counts.
2419
+
2420
+ Args:
2421
+ table: The traces table.
2422
+ spans_table: The spans table (optional).
2423
+
2424
+ Returns:
2425
+ SQLAlchemy select statement with total_spans and error_count calculated dynamically.
2426
+ """
2427
+ from sqlalchemy import case, literal
2428
+
2429
+ if spans_table is not None:
2430
+ # JOIN with spans table to calculate total_spans and error_count
2431
+ return (
2432
+ select(
2433
+ table,
2434
+ func.coalesce(func.count(spans_table.c.span_id), 0).label("total_spans"),
2435
+ func.coalesce(func.sum(case((spans_table.c.status_code == "ERROR", 1), else_=0)), 0).label(
2436
+ "error_count"
2437
+ ),
2438
+ )
2439
+ .select_from(table.outerjoin(spans_table, table.c.trace_id == spans_table.c.trace_id))
2440
+ .group_by(table.c.trace_id)
2441
+ )
2442
+ else:
2443
+ # Fallback if spans table doesn't exist
2444
+ return select(table, literal(0).label("total_spans"), literal(0).label("error_count"))
2445
+
2446
+ def create_trace(self, trace: "Trace") -> None:
2447
+ """Create a single trace record in the database.
2448
+
2449
+ Args:
2450
+ trace: The Trace object to store (one per trace_id).
2451
+ """
2452
+ try:
2453
+ table = self._get_table(table_type="traces", create_table_if_not_found=True)
2454
+ if table is None:
2455
+ return
2456
+
2457
+ with self.Session() as sess, sess.begin():
2458
+ # Check if trace exists
2459
+ existing = sess.execute(select(table).where(table.c.trace_id == trace.trace_id)).fetchone()
2460
+
2461
+ if existing:
2462
+ # workflow (level 3) > team (level 2) > agent (level 1) > child/unknown (level 0)
2463
+
2464
+ def get_component_level(workflow_id, team_id, agent_id, name):
2465
+ # Check if name indicates a root span
2466
+ is_root_name = ".run" in name or ".arun" in name
2467
+
2468
+ if not is_root_name:
2469
+ return 0 # Child span (not a root)
2470
+ elif workflow_id:
2471
+ return 3 # Workflow root
2472
+ elif team_id:
2473
+ return 2 # Team root
2474
+ elif agent_id:
2475
+ return 1 # Agent root
2476
+ else:
2477
+ return 0 # Unknown
2478
+
2479
+ existing_level = get_component_level(
2480
+ existing.workflow_id, existing.team_id, existing.agent_id, existing.name
2481
+ )
2482
+ new_level = get_component_level(trace.workflow_id, trace.team_id, trace.agent_id, trace.name)
2483
+
2484
+ # Only update name if new trace is from a higher or equal level
2485
+ should_update_name = new_level > existing_level
2486
+
2487
+ # Parse existing start_time to calculate correct duration
2488
+ existing_start_time_str = existing.start_time
2489
+ if isinstance(existing_start_time_str, str):
2490
+ existing_start_time = datetime.fromisoformat(existing_start_time_str.replace("Z", "+00:00"))
2491
+ else:
2492
+ existing_start_time = trace.start_time
2493
+
2494
+ recalculated_duration_ms = int((trace.end_time - existing_start_time).total_seconds() * 1000)
2495
+
2496
+ update_values = {
2497
+ "end_time": trace.end_time.isoformat(),
2498
+ "duration_ms": recalculated_duration_ms,
2499
+ "status": trace.status,
2500
+ "name": trace.name if should_update_name else existing.name,
2501
+ }
2502
+
2503
+ # Update context fields ONLY if new value is not None (preserve non-null values)
2504
+ if trace.run_id is not None:
2505
+ update_values["run_id"] = trace.run_id
2506
+ if trace.session_id is not None:
2507
+ update_values["session_id"] = trace.session_id
2508
+ if trace.user_id is not None:
2509
+ update_values["user_id"] = trace.user_id
2510
+ if trace.agent_id is not None:
2511
+ update_values["agent_id"] = trace.agent_id
2512
+ if trace.team_id is not None:
2513
+ update_values["team_id"] = trace.team_id
2514
+ if trace.workflow_id is not None:
2515
+ update_values["workflow_id"] = trace.workflow_id
2516
+
2517
+ log_debug(
2518
+ f" Updating trace with context: run_id={update_values.get('run_id', 'unchanged')}, "
2519
+ f"session_id={update_values.get('session_id', 'unchanged')}, "
2520
+ f"user_id={update_values.get('user_id', 'unchanged')}, "
2521
+ f"agent_id={update_values.get('agent_id', 'unchanged')}, "
2522
+ f"team_id={update_values.get('team_id', 'unchanged')}, "
2523
+ )
2524
+
2525
+ stmt = update(table).where(table.c.trace_id == trace.trace_id).values(**update_values)
2526
+ sess.execute(stmt)
2527
+ else:
2528
+ trace_dict = trace.to_dict()
2529
+ trace_dict.pop("total_spans", None)
2530
+ trace_dict.pop("error_count", None)
2531
+ stmt = mysql.insert(table).values(trace_dict)
2532
+ sess.execute(stmt)
2533
+
2534
+ except Exception as e:
2535
+ log_error(f"Error creating trace: {e}")
2536
+ # Don't raise - tracing should not break the main application flow
2537
+
2538
+ def get_trace(
2539
+ self,
2540
+ trace_id: Optional[str] = None,
2541
+ run_id: Optional[str] = None,
2542
+ ):
2543
+ """Get a single trace by trace_id or other filters.
2544
+
2545
+ Args:
2546
+ trace_id: The unique trace identifier.
2547
+ run_id: Filter by run ID (returns first match).
2548
+
2549
+ Returns:
2550
+ Optional[Trace]: The trace if found, None otherwise.
2551
+
2552
+ Note:
2553
+ If multiple filters are provided, trace_id takes precedence.
2554
+ For other filters, the most recent trace is returned.
2555
+ """
2556
+ try:
2557
+ from agno.tracing.schemas import Trace
2558
+
2559
+ table = self._get_table(table_type="traces")
2560
+ if table is None:
2561
+ return None
2562
+
2563
+ # Get spans table for JOIN
2564
+ spans_table = self._get_table(table_type="spans")
2565
+
2566
+ with self.Session() as sess:
2567
+ # Build query with aggregated span counts
2568
+ stmt = self._get_traces_base_query(table, spans_table)
2569
+
2570
+ if trace_id:
2571
+ stmt = stmt.where(table.c.trace_id == trace_id)
2572
+ elif run_id:
2573
+ stmt = stmt.where(table.c.run_id == run_id)
2574
+ else:
2575
+ log_debug("get_trace called without any filter parameters")
2576
+ return None
2577
+
2578
+ # Order by most recent and get first result
2579
+ stmt = stmt.order_by(table.c.start_time.desc()).limit(1)
2580
+ result = sess.execute(stmt).fetchone()
2581
+
2582
+ if result:
2583
+ return Trace.from_dict(dict(result._mapping))
2584
+ return None
2585
+
2586
+ except Exception as e:
2587
+ log_error(f"Error getting trace: {e}")
2588
+ return None
2589
+
2590
+ def get_traces(
2591
+ self,
2592
+ run_id: Optional[str] = None,
2593
+ session_id: Optional[str] = None,
2594
+ user_id: Optional[str] = None,
2595
+ agent_id: Optional[str] = None,
2596
+ team_id: Optional[str] = None,
2597
+ workflow_id: Optional[str] = None,
2598
+ status: Optional[str] = None,
2599
+ start_time: Optional[datetime] = None,
2600
+ end_time: Optional[datetime] = None,
2601
+ limit: Optional[int] = 20,
2602
+ page: Optional[int] = 1,
2603
+ ) -> tuple[List, int]:
2604
+ """Get traces matching the provided filters with pagination.
2605
+
2606
+ Args:
2607
+ run_id: Filter by run ID.
2608
+ session_id: Filter by session ID.
2609
+ user_id: Filter by user ID.
2610
+ agent_id: Filter by agent ID.
2611
+ team_id: Filter by team ID.
2612
+ workflow_id: Filter by workflow ID.
2613
+ status: Filter by status (OK, ERROR, UNSET).
2614
+ start_time: Filter traces starting after this datetime.
2615
+ end_time: Filter traces ending before this datetime.
2616
+ limit: Maximum number of traces to return per page.
2617
+ page: Page number (1-indexed).
2618
+
2619
+ Returns:
2620
+ tuple[List[Trace], int]: Tuple of (list of matching traces, total count).
2621
+ """
2622
+ try:
2623
+ from agno.tracing.schemas import Trace
2624
+
2625
+ log_debug(
2626
+ 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}"
2627
+ )
2628
+
2629
+ table = self._get_table(table_type="traces")
2630
+ if table is None:
2631
+ log_debug("Traces table not found")
2632
+ return [], 0
2633
+
2634
+ # Get spans table for JOIN
2635
+ spans_table = self._get_table(table_type="spans")
2636
+
2637
+ with self.Session() as sess:
2638
+ # Build base query with aggregated span counts
2639
+ base_stmt = self._get_traces_base_query(table, spans_table)
2640
+
2641
+ # Apply filters
2642
+ if run_id:
2643
+ base_stmt = base_stmt.where(table.c.run_id == run_id)
2644
+ if session_id:
2645
+ log_debug(f"Filtering by session_id={session_id}")
2646
+ base_stmt = base_stmt.where(table.c.session_id == session_id)
2647
+ if user_id:
2648
+ base_stmt = base_stmt.where(table.c.user_id == user_id)
2649
+ if agent_id:
2650
+ base_stmt = base_stmt.where(table.c.agent_id == agent_id)
2651
+ if team_id:
2652
+ base_stmt = base_stmt.where(table.c.team_id == team_id)
2653
+ if workflow_id:
2654
+ base_stmt = base_stmt.where(table.c.workflow_id == workflow_id)
2655
+ if status:
2656
+ base_stmt = base_stmt.where(table.c.status == status)
2657
+ if start_time:
2658
+ # Convert datetime to ISO string for comparison
2659
+ base_stmt = base_stmt.where(table.c.start_time >= start_time.isoformat())
2660
+ if end_time:
2661
+ # Convert datetime to ISO string for comparison
2662
+ base_stmt = base_stmt.where(table.c.end_time <= end_time.isoformat())
2663
+
2664
+ # Get total count
2665
+ count_stmt = select(func.count()).select_from(base_stmt.alias())
2666
+ total_count = sess.execute(count_stmt).scalar() or 0
2667
+ log_debug(f"Total matching traces: {total_count}")
2668
+
2669
+ # Apply pagination
2670
+ offset = (page - 1) * limit if page and limit else 0
2671
+ paginated_stmt = base_stmt.order_by(table.c.start_time.desc()).limit(limit).offset(offset)
2672
+
2673
+ results = sess.execute(paginated_stmt).fetchall()
2674
+ log_debug(f"Returning page {page} with {len(results)} traces")
2675
+
2676
+ traces = [Trace.from_dict(dict(row._mapping)) for row in results]
2677
+ return traces, total_count
2678
+
2679
+ except Exception as e:
2680
+ log_error(f"Error getting traces: {e}")
2681
+ return [], 0
2682
+
2683
+ def get_trace_stats(
2684
+ self,
2685
+ user_id: Optional[str] = None,
2686
+ agent_id: Optional[str] = None,
2687
+ team_id: Optional[str] = None,
2688
+ workflow_id: Optional[str] = None,
2689
+ start_time: Optional[datetime] = None,
2690
+ end_time: Optional[datetime] = None,
2691
+ limit: Optional[int] = 20,
2692
+ page: Optional[int] = 1,
2693
+ ) -> tuple[List[Dict[str, Any]], int]:
2694
+ """Get trace statistics grouped by session.
2695
+
2696
+ Args:
2697
+ user_id: Filter by user ID.
2698
+ agent_id: Filter by agent ID.
2699
+ team_id: Filter by team ID.
2700
+ workflow_id: Filter by workflow ID.
2701
+ start_time: Filter sessions with traces created after this datetime.
2702
+ end_time: Filter sessions with traces created before this datetime.
2703
+ limit: Maximum number of sessions to return per page.
2704
+ page: Page number (1-indexed).
2705
+
2706
+ Returns:
2707
+ tuple[List[Dict], int]: Tuple of (list of session stats dicts, total count).
2708
+ Each dict contains: session_id, user_id, agent_id, team_id, total_traces,
2709
+ workflow_id, first_trace_at, last_trace_at.
2710
+ """
2711
+ try:
2712
+ log_debug(
2713
+ f"get_trace_stats called with filters: user_id={user_id}, agent_id={agent_id}, "
2714
+ f"workflow_id={workflow_id}, team_id={team_id}, "
2715
+ f"start_time={start_time}, end_time={end_time}, page={page}, limit={limit}"
2716
+ )
2717
+
2718
+ table = self._get_table(table_type="traces")
2719
+ if table is None:
2720
+ log_debug("Traces table not found")
2721
+ return [], 0
2722
+
2723
+ with self.Session() as sess:
2724
+ # Build base query grouped by session_id
2725
+ base_stmt = (
2726
+ select(
2727
+ table.c.session_id,
2728
+ table.c.user_id,
2729
+ table.c.agent_id,
2730
+ table.c.team_id,
2731
+ table.c.workflow_id,
2732
+ func.count(table.c.trace_id).label("total_traces"),
2733
+ func.min(table.c.created_at).label("first_trace_at"),
2734
+ func.max(table.c.created_at).label("last_trace_at"),
2735
+ )
2736
+ .where(table.c.session_id.isnot(None)) # Only sessions with session_id
2737
+ .group_by(
2738
+ table.c.session_id, table.c.user_id, table.c.agent_id, table.c.team_id, table.c.workflow_id
2739
+ )
2740
+ )
2741
+
2742
+ # Apply filters
2743
+ if user_id:
2744
+ base_stmt = base_stmt.where(table.c.user_id == user_id)
2745
+ if workflow_id:
2746
+ base_stmt = base_stmt.where(table.c.workflow_id == workflow_id)
2747
+ if team_id:
2748
+ base_stmt = base_stmt.where(table.c.team_id == team_id)
2749
+ if agent_id:
2750
+ base_stmt = base_stmt.where(table.c.agent_id == agent_id)
2751
+ if start_time:
2752
+ # Convert datetime to ISO string for comparison
2753
+ base_stmt = base_stmt.where(table.c.created_at >= start_time.isoformat())
2754
+ if end_time:
2755
+ # Convert datetime to ISO string for comparison
2756
+ base_stmt = base_stmt.where(table.c.created_at <= end_time.isoformat())
2757
+
2758
+ # Get total count of sessions
2759
+ count_stmt = select(func.count()).select_from(base_stmt.alias())
2760
+ total_count = sess.execute(count_stmt).scalar() or 0
2761
+ log_debug(f"Total matching sessions: {total_count}")
2762
+
2763
+ # Apply pagination and ordering
2764
+ offset = (page - 1) * limit if page and limit else 0
2765
+ paginated_stmt = base_stmt.order_by(func.max(table.c.created_at).desc()).limit(limit).offset(offset)
2766
+
2767
+ results = sess.execute(paginated_stmt).fetchall()
2768
+ log_debug(f"Returning page {page} with {len(results)} session stats")
2769
+
2770
+ # Convert to list of dicts with datetime objects
2771
+ stats_list = []
2772
+ for row in results:
2773
+ # Convert ISO strings to datetime objects
2774
+ first_trace_at_str = row.first_trace_at
2775
+ last_trace_at_str = row.last_trace_at
2776
+
2777
+ # Parse ISO format strings to datetime objects
2778
+ first_trace_at = datetime.fromisoformat(first_trace_at_str.replace("Z", "+00:00"))
2779
+ last_trace_at = datetime.fromisoformat(last_trace_at_str.replace("Z", "+00:00"))
2780
+
2781
+ stats_list.append(
2782
+ {
2783
+ "session_id": row.session_id,
2784
+ "user_id": row.user_id,
2785
+ "agent_id": row.agent_id,
2786
+ "team_id": row.team_id,
2787
+ "workflow_id": row.workflow_id,
2788
+ "total_traces": row.total_traces,
2789
+ "first_trace_at": first_trace_at,
2790
+ "last_trace_at": last_trace_at,
2791
+ }
2792
+ )
2793
+
2794
+ return stats_list, total_count
2795
+
2796
+ except Exception as e:
2797
+ log_error(f"Error getting trace stats: {e}")
2798
+ return [], 0
2799
+
2800
+ # --- Spans ---
2801
+ def create_span(self, span: "Span") -> None:
2802
+ """Create a single span in the database.
2803
+
2804
+ Args:
2805
+ span: The Span object to store.
2806
+ """
2807
+ try:
2808
+ table = self._get_table(table_type="spans", create_table_if_not_found=True)
2809
+ if table is None:
2810
+ return
2811
+
2812
+ with self.Session() as sess, sess.begin():
2813
+ stmt = mysql.insert(table).values(span.to_dict())
2814
+ sess.execute(stmt)
2815
+
2816
+ except Exception as e:
2817
+ log_error(f"Error creating span: {e}")
2818
+
2819
+ def create_spans(self, spans: List) -> None:
2820
+ """Create multiple spans in the database as a batch.
2821
+
2822
+ Args:
2823
+ spans: List of Span objects to store.
2824
+ """
2825
+ if not spans:
2826
+ return
2827
+
2828
+ try:
2829
+ table = self._get_table(table_type="spans", create_table_if_not_found=True)
2830
+ if table is None:
2831
+ return
2832
+
2833
+ with self.Session() as sess, sess.begin():
2834
+ for span in spans:
2835
+ stmt = mysql.insert(table).values(span.to_dict())
2836
+ sess.execute(stmt)
2837
+
2838
+ except Exception as e:
2839
+ log_error(f"Error creating spans batch: {e}")
2840
+
2841
+ def get_span(self, span_id: str):
2842
+ """Get a single span by its span_id.
2843
+
2844
+ Args:
2845
+ span_id: The unique span identifier.
2846
+
2847
+ Returns:
2848
+ Optional[Span]: The span if found, None otherwise.
2849
+ """
2850
+ try:
2851
+ from agno.tracing.schemas import Span
2852
+
2853
+ table = self._get_table(table_type="spans")
2854
+ if table is None:
2855
+ return None
2856
+
2857
+ with self.Session() as sess:
2858
+ stmt = select(table).where(table.c.span_id == span_id)
2859
+ result = sess.execute(stmt).fetchone()
2860
+ if result:
2861
+ return Span.from_dict(dict(result._mapping))
2862
+ return None
2863
+
2864
+ except Exception as e:
2865
+ log_error(f"Error getting span: {e}")
2866
+ return None
2867
+
2868
+ def get_spans(
2869
+ self,
2870
+ trace_id: Optional[str] = None,
2871
+ parent_span_id: Optional[str] = None,
2872
+ limit: Optional[int] = 1000,
2873
+ ) -> List:
2874
+ """Get spans matching the provided filters.
2875
+
2876
+ Args:
2877
+ trace_id: Filter by trace ID.
2878
+ parent_span_id: Filter by parent span ID.
2879
+ limit: Maximum number of spans to return.
2880
+
2881
+ Returns:
2882
+ List[Span]: List of matching spans.
2883
+ """
2884
+ try:
2885
+ from agno.tracing.schemas import Span
2886
+
2887
+ table = self._get_table(table_type="spans")
2888
+ if table is None:
2889
+ return []
2890
+
2891
+ with self.Session() as sess:
2892
+ stmt = select(table)
2893
+
2894
+ # Apply filters
2895
+ if trace_id:
2896
+ stmt = stmt.where(table.c.trace_id == trace_id)
2897
+ if parent_span_id:
2898
+ stmt = stmt.where(table.c.parent_span_id == parent_span_id)
2899
+
2900
+ if limit:
2901
+ stmt = stmt.limit(limit)
2902
+
2903
+ results = sess.execute(stmt).fetchall()
2904
+ return [Span.from_dict(dict(row._mapping)) for row in results]
2905
+
2906
+ except Exception as e:
2907
+ log_error(f"Error getting spans: {e}")
2908
+ return []
agno/db/mysql/schemas.py CHANGED
@@ -120,6 +120,42 @@ VERSIONS_TABLE_SCHEMA = {
120
120
  "updated_at": {"type": lambda: String(128), "nullable": True},
121
121
  }
122
122
 
123
+ TRACE_TABLE_SCHEMA = {
124
+ "trace_id": {"type": lambda: String(128), "primary_key": True, "nullable": False},
125
+ "name": {"type": lambda: String(255), "nullable": False},
126
+ "status": {"type": lambda: String(50), "nullable": False, "index": True},
127
+ "start_time": {"type": lambda: String(128), "nullable": False, "index": True}, # ISO 8601 datetime string
128
+ "end_time": {"type": lambda: String(128), "nullable": False}, # ISO 8601 datetime string
129
+ "duration_ms": {"type": BigInteger, "nullable": False},
130
+ "run_id": {"type": lambda: String(128), "nullable": True, "index": True},
131
+ "session_id": {"type": lambda: String(128), "nullable": True, "index": True},
132
+ "user_id": {"type": lambda: String(128), "nullable": True, "index": True},
133
+ "agent_id": {"type": lambda: String(128), "nullable": True, "index": True},
134
+ "team_id": {"type": lambda: String(128), "nullable": True, "index": True},
135
+ "workflow_id": {"type": lambda: String(128), "nullable": True, "index": True},
136
+ "created_at": {"type": lambda: String(128), "nullable": False, "index": True}, # ISO 8601 datetime string
137
+ }
138
+
139
+ SPAN_TABLE_SCHEMA = {
140
+ "span_id": {"type": lambda: String(128), "primary_key": True, "nullable": False},
141
+ "trace_id": {
142
+ "type": lambda: String(128),
143
+ "nullable": False,
144
+ "index": True,
145
+ "foreign_key": "agno_traces.trace_id", # Foreign key to traces table
146
+ },
147
+ "parent_span_id": {"type": lambda: String(128), "nullable": True, "index": True},
148
+ "name": {"type": lambda: String(255), "nullable": False},
149
+ "span_kind": {"type": lambda: String(50), "nullable": False},
150
+ "status_code": {"type": lambda: String(50), "nullable": False},
151
+ "status_message": {"type": Text, "nullable": True},
152
+ "start_time": {"type": lambda: String(128), "nullable": False, "index": True}, # ISO 8601 datetime string
153
+ "end_time": {"type": lambda: String(128), "nullable": False}, # ISO 8601 datetime string
154
+ "duration_ms": {"type": BigInteger, "nullable": False},
155
+ "attributes": {"type": JSON, "nullable": True},
156
+ "created_at": {"type": lambda: String(128), "nullable": False, "index": True}, # ISO 8601 datetime string
157
+ }
158
+
123
159
 
124
160
  def get_table_schema_definition(table_type: str) -> dict[str, Any]:
125
161
  """
@@ -139,6 +175,8 @@ def get_table_schema_definition(table_type: str) -> dict[str, Any]:
139
175
  "knowledge": KNOWLEDGE_TABLE_SCHEMA,
140
176
  "culture": CULTURAL_KNOWLEDGE_TABLE_SCHEMA,
141
177
  "versions": VERSIONS_TABLE_SCHEMA,
178
+ "traces": TRACE_TABLE_SCHEMA,
179
+ "spans": SPAN_TABLE_SCHEMA,
142
180
  }
143
181
 
144
182
  schema = schemas.get(table_type, {})