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
@@ -1,9 +1,12 @@
1
1
  import json
2
2
  import time
3
3
  from datetime import date, datetime, timedelta, timezone
4
- from typing import Any, Dict, List, Optional, Tuple, Union
4
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
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
@@ -52,6 +55,8 @@ class SingleStoreDb(BaseDb):
52
55
  eval_table: Optional[str] = None,
53
56
  knowledge_table: Optional[str] = None,
54
57
  versions_table: Optional[str] = None,
58
+ traces_table: Optional[str] = None,
59
+ spans_table: Optional[str] = None,
55
60
  ):
56
61
  """
57
62
  Interface for interacting with a SingleStore database.
@@ -92,6 +97,8 @@ class SingleStoreDb(BaseDb):
92
97
  eval_table=eval_table,
93
98
  knowledge_table=knowledge_table,
94
99
  versions_table=versions_table,
100
+ traces_table=traces_table,
101
+ spans_table=spans_table,
95
102
  )
96
103
 
97
104
  _engine: Optional[Engine] = db_engine
@@ -364,6 +371,24 @@ class SingleStoreDb(BaseDb):
364
371
  )
365
372
  return self.versions_table
366
373
 
374
+ if table_type == "traces":
375
+ self.traces_table = self._get_or_create_table(
376
+ table_name=self.trace_table_name,
377
+ table_type="traces",
378
+ create_table_if_not_found=create_table_if_not_found,
379
+ )
380
+ return self.traces_table
381
+
382
+ if table_type == "spans":
383
+ # Ensure traces table exists first (for foreign key)
384
+ self._get_table(table_type="traces", create_table_if_not_found=create_table_if_not_found)
385
+ self.spans_table = self._get_or_create_table(
386
+ table_name=self.span_table_name,
387
+ table_type="spans",
388
+ create_table_if_not_found=create_table_if_not_found,
389
+ )
390
+ return self.spans_table
391
+
367
392
  raise ValueError(f"Unknown table type: {table_type}")
368
393
 
369
394
  def _get_or_create_table(
@@ -2335,3 +2360,500 @@ class SingleStoreDb(BaseDb):
2335
2360
  except Exception as e:
2336
2361
  log_error(f"Error upserting cultural knowledge: {e}")
2337
2362
  raise e
2363
+
2364
+ # --- Traces ---
2365
+ def _get_traces_base_query(self, table: Table, spans_table: Optional[Table] = None):
2366
+ """Build base query for traces with aggregated span counts.
2367
+
2368
+ Args:
2369
+ table: The traces table.
2370
+ spans_table: The spans table (optional).
2371
+
2372
+ Returns:
2373
+ SQLAlchemy select statement with total_spans and error_count calculated dynamically.
2374
+ """
2375
+ from sqlalchemy import case, literal
2376
+
2377
+ if spans_table is not None:
2378
+ # JOIN with spans table to calculate total_spans and error_count
2379
+ return (
2380
+ select(
2381
+ table,
2382
+ func.coalesce(func.count(spans_table.c.span_id), 0).label("total_spans"),
2383
+ func.coalesce(func.sum(case((spans_table.c.status_code == "ERROR", 1), else_=0)), 0).label(
2384
+ "error_count"
2385
+ ),
2386
+ )
2387
+ .select_from(table.outerjoin(spans_table, table.c.trace_id == spans_table.c.trace_id))
2388
+ .group_by(table.c.trace_id)
2389
+ )
2390
+ else:
2391
+ # Fallback if spans table doesn't exist
2392
+ return select(table, literal(0).label("total_spans"), literal(0).label("error_count"))
2393
+
2394
+ def create_trace(self, trace: "Trace") -> None:
2395
+ """Create a single trace record in the database.
2396
+
2397
+ Args:
2398
+ trace: The Trace object to store (one per trace_id).
2399
+ """
2400
+ try:
2401
+ table = self._get_table(table_type="traces", create_table_if_not_found=True)
2402
+ if table is None:
2403
+ return
2404
+
2405
+ with self.Session() as sess, sess.begin():
2406
+ existing = sess.execute(select(table).where(table.c.trace_id == trace.trace_id)).fetchone()
2407
+
2408
+ if existing:
2409
+ # workflow (level 3) > team (level 2) > agent (level 1) > child/unknown (level 0)
2410
+
2411
+ def get_component_level(workflow_id, team_id, agent_id, name):
2412
+ # Check if name indicates a root span
2413
+ is_root_name = ".run" in name or ".arun" in name
2414
+
2415
+ if not is_root_name:
2416
+ return 0 # Child span (not a root)
2417
+ elif workflow_id:
2418
+ return 3 # Workflow root
2419
+ elif team_id:
2420
+ return 2 # Team root
2421
+ elif agent_id:
2422
+ return 1 # Agent root
2423
+ else:
2424
+ return 0 # Unknown
2425
+
2426
+ existing_level = get_component_level(
2427
+ existing.workflow_id, existing.team_id, existing.agent_id, existing.name
2428
+ )
2429
+ new_level = get_component_level(trace.workflow_id, trace.team_id, trace.agent_id, trace.name)
2430
+
2431
+ # Only update name if new trace is from a higher or equal level
2432
+ should_update_name = new_level > existing_level
2433
+
2434
+ # Parse existing start_time to calculate correct duration
2435
+ existing_start_time_str = existing.start_time
2436
+ if isinstance(existing_start_time_str, str):
2437
+ existing_start_time = datetime.fromisoformat(existing_start_time_str.replace("Z", "+00:00"))
2438
+ else:
2439
+ existing_start_time = trace.start_time
2440
+
2441
+ recalculated_duration_ms = int((trace.end_time - existing_start_time).total_seconds() * 1000)
2442
+
2443
+ update_values = {
2444
+ "end_time": trace.end_time.isoformat(),
2445
+ "duration_ms": recalculated_duration_ms,
2446
+ "status": trace.status,
2447
+ "name": trace.name if should_update_name else existing.name,
2448
+ }
2449
+
2450
+ # Update context fields ONLY if new value is not None (preserve non-null values)
2451
+ if trace.run_id is not None:
2452
+ update_values["run_id"] = trace.run_id
2453
+ if trace.session_id is not None:
2454
+ update_values["session_id"] = trace.session_id
2455
+ if trace.user_id is not None:
2456
+ update_values["user_id"] = trace.user_id
2457
+ if trace.agent_id is not None:
2458
+ update_values["agent_id"] = trace.agent_id
2459
+ if trace.team_id is not None:
2460
+ update_values["team_id"] = trace.team_id
2461
+ if trace.workflow_id is not None:
2462
+ update_values["workflow_id"] = trace.workflow_id
2463
+
2464
+ log_debug(
2465
+ f" Updating trace with context: run_id={update_values.get('run_id', 'unchanged')}, "
2466
+ f"session_id={update_values.get('session_id', 'unchanged')}, "
2467
+ f"user_id={update_values.get('user_id', 'unchanged')}, "
2468
+ f"agent_id={update_values.get('agent_id', 'unchanged')}, "
2469
+ f"team_id={update_values.get('team_id', 'unchanged')}, "
2470
+ )
2471
+
2472
+ update_stmt = update(table).where(table.c.trace_id == trace.trace_id).values(**update_values)
2473
+ sess.execute(update_stmt)
2474
+ else:
2475
+ trace_dict = trace.to_dict()
2476
+ trace_dict.pop("total_spans", None)
2477
+ trace_dict.pop("error_count", None)
2478
+ insert_stmt = mysql.insert(table).values(trace_dict)
2479
+ sess.execute(insert_stmt)
2480
+
2481
+ except Exception as e:
2482
+ log_error(f"Error creating trace: {e}")
2483
+ # Don't raise - tracing should not break the main application flow
2484
+
2485
+ def get_trace(
2486
+ self,
2487
+ trace_id: Optional[str] = None,
2488
+ run_id: Optional[str] = None,
2489
+ ):
2490
+ """Get a single trace by trace_id or other filters.
2491
+
2492
+ Args:
2493
+ trace_id: The unique trace identifier.
2494
+ run_id: Filter by run ID (returns first match).
2495
+
2496
+ Returns:
2497
+ Optional[Trace]: The trace if found, None otherwise.
2498
+
2499
+ Note:
2500
+ If multiple filters are provided, trace_id takes precedence.
2501
+ For other filters, the most recent trace is returned.
2502
+ """
2503
+ try:
2504
+ from agno.tracing.schemas import Trace
2505
+
2506
+ table = self._get_table(table_type="traces")
2507
+ if table is None:
2508
+ return None
2509
+
2510
+ # Get spans table for JOIN
2511
+ spans_table = self._get_table(table_type="spans")
2512
+
2513
+ with self.Session() as sess:
2514
+ # Build query with aggregated span counts
2515
+ stmt = self._get_traces_base_query(table, spans_table)
2516
+
2517
+ if trace_id:
2518
+ stmt = stmt.where(table.c.trace_id == trace_id)
2519
+ elif run_id:
2520
+ stmt = stmt.where(table.c.run_id == run_id)
2521
+ else:
2522
+ log_debug("get_trace called without any filter parameters")
2523
+ return None
2524
+
2525
+ # Order by most recent and get first result
2526
+ stmt = stmt.order_by(table.c.start_time.desc()).limit(1)
2527
+ result = sess.execute(stmt).fetchone()
2528
+
2529
+ if result:
2530
+ return Trace.from_dict(dict(result._mapping))
2531
+ return None
2532
+
2533
+ except Exception as e:
2534
+ log_error(f"Error getting trace: {e}")
2535
+ return None
2536
+
2537
+ def get_traces(
2538
+ self,
2539
+ run_id: Optional[str] = None,
2540
+ session_id: Optional[str] = None,
2541
+ user_id: Optional[str] = None,
2542
+ agent_id: Optional[str] = None,
2543
+ team_id: Optional[str] = None,
2544
+ workflow_id: Optional[str] = None,
2545
+ status: Optional[str] = None,
2546
+ start_time: Optional[datetime] = None,
2547
+ end_time: Optional[datetime] = None,
2548
+ limit: Optional[int] = 20,
2549
+ page: Optional[int] = 1,
2550
+ ) -> tuple[List, int]:
2551
+ """Get traces matching the provided filters.
2552
+
2553
+ Args:
2554
+ run_id: Filter by run ID.
2555
+ session_id: Filter by session ID.
2556
+ user_id: Filter by user ID.
2557
+ agent_id: Filter by agent ID.
2558
+ team_id: Filter by team ID.
2559
+ workflow_id: Filter by workflow ID.
2560
+ status: Filter by status (OK, ERROR, UNSET).
2561
+ start_time: Filter traces starting after this datetime.
2562
+ end_time: Filter traces ending before this datetime.
2563
+ limit: Maximum number of traces to return per page.
2564
+ page: Page number (1-indexed).
2565
+
2566
+ Returns:
2567
+ tuple[List[Trace], int]: Tuple of (list of matching traces, total count).
2568
+ """
2569
+ try:
2570
+ from agno.tracing.schemas import Trace
2571
+
2572
+ log_debug(
2573
+ 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}"
2574
+ )
2575
+
2576
+ table = self._get_table(table_type="traces")
2577
+ if table is None:
2578
+ log_debug("Traces table not found")
2579
+ return [], 0
2580
+
2581
+ # Get spans table for JOIN
2582
+ spans_table = self._get_table(table_type="spans")
2583
+
2584
+ with self.Session() as sess:
2585
+ # Build base query with aggregated span counts
2586
+ base_stmt = self._get_traces_base_query(table, spans_table)
2587
+
2588
+ # Apply filters
2589
+ if run_id:
2590
+ base_stmt = base_stmt.where(table.c.run_id == run_id)
2591
+ if session_id:
2592
+ log_debug(f"Filtering by session_id={session_id}")
2593
+ base_stmt = base_stmt.where(table.c.session_id == session_id)
2594
+ if user_id:
2595
+ base_stmt = base_stmt.where(table.c.user_id == user_id)
2596
+ if agent_id:
2597
+ base_stmt = base_stmt.where(table.c.agent_id == agent_id)
2598
+ if team_id:
2599
+ base_stmt = base_stmt.where(table.c.team_id == team_id)
2600
+ if workflow_id:
2601
+ base_stmt = base_stmt.where(table.c.workflow_id == workflow_id)
2602
+ if status:
2603
+ base_stmt = base_stmt.where(table.c.status == status)
2604
+ if start_time:
2605
+ # Convert datetime to ISO string for comparison
2606
+ base_stmt = base_stmt.where(table.c.start_time >= start_time.isoformat())
2607
+ if end_time:
2608
+ # Convert datetime to ISO string for comparison
2609
+ base_stmt = base_stmt.where(table.c.end_time <= end_time.isoformat())
2610
+
2611
+ # Get total count
2612
+ count_stmt = select(func.count()).select_from(base_stmt.alias())
2613
+ total_count = sess.execute(count_stmt).scalar() or 0
2614
+ log_debug(f"Total matching traces: {total_count}")
2615
+
2616
+ # Apply pagination
2617
+ offset = (page - 1) * limit if page and limit else 0
2618
+ paginated_stmt = base_stmt.order_by(table.c.start_time.desc()).limit(limit).offset(offset)
2619
+
2620
+ results = sess.execute(paginated_stmt).fetchall()
2621
+ log_debug(f"Returning page {page} with {len(results)} traces")
2622
+
2623
+ traces = [Trace.from_dict(dict(row._mapping)) for row in results]
2624
+ return traces, total_count
2625
+
2626
+ except Exception as e:
2627
+ log_error(f"Error getting traces: {e}")
2628
+ return [], 0
2629
+
2630
+ def get_trace_stats(
2631
+ self,
2632
+ user_id: Optional[str] = None,
2633
+ agent_id: Optional[str] = None,
2634
+ team_id: Optional[str] = None,
2635
+ workflow_id: Optional[str] = None,
2636
+ start_time: Optional[datetime] = None,
2637
+ end_time: Optional[datetime] = None,
2638
+ limit: Optional[int] = 20,
2639
+ page: Optional[int] = 1,
2640
+ ) -> tuple[List[Dict[str, Any]], int]:
2641
+ """Get trace statistics grouped by session.
2642
+
2643
+ Args:
2644
+ user_id: Filter by user ID.
2645
+ agent_id: Filter by agent ID.
2646
+ team_id: Filter by team ID.
2647
+ workflow_id: Filter by workflow ID.
2648
+ start_time: Filter sessions with traces created after this datetime.
2649
+ end_time: Filter sessions with traces created before this datetime.
2650
+ limit: Maximum number of sessions to return per page.
2651
+ page: Page number (1-indexed).
2652
+
2653
+ Returns:
2654
+ tuple[List[Dict], int]: Tuple of (list of session stats dicts, total count).
2655
+ Each dict contains: session_id, user_id, agent_id, team_id, total_traces,
2656
+ first_trace_at, last_trace_at.
2657
+ """
2658
+ try:
2659
+ log_debug(
2660
+ f"get_trace_stats called with filters: user_id={user_id}, agent_id={agent_id}, "
2661
+ f"workflow_id={workflow_id}, team_id={team_id}, "
2662
+ f"start_time={start_time}, end_time={end_time}, page={page}, limit={limit}"
2663
+ )
2664
+
2665
+ table = self._get_table(table_type="traces")
2666
+ if table is None:
2667
+ log_debug("Traces table not found")
2668
+ return [], 0
2669
+
2670
+ with self.Session() as sess:
2671
+ # Build base query grouped by session_id
2672
+ base_stmt = (
2673
+ select(
2674
+ table.c.session_id,
2675
+ table.c.user_id,
2676
+ table.c.agent_id,
2677
+ table.c.team_id,
2678
+ table.c.workflow_id,
2679
+ func.count(table.c.trace_id).label("total_traces"),
2680
+ func.min(table.c.created_at).label("first_trace_at"),
2681
+ func.max(table.c.created_at).label("last_trace_at"),
2682
+ )
2683
+ .where(table.c.session_id.isnot(None)) # Only sessions with session_id
2684
+ .group_by(
2685
+ table.c.session_id, table.c.user_id, table.c.agent_id, table.c.team_id, table.c.workflow_id
2686
+ )
2687
+ )
2688
+
2689
+ # Apply filters
2690
+ if user_id:
2691
+ base_stmt = base_stmt.where(table.c.user_id == user_id)
2692
+ if workflow_id:
2693
+ base_stmt = base_stmt.where(table.c.workflow_id == workflow_id)
2694
+ if team_id:
2695
+ base_stmt = base_stmt.where(table.c.team_id == team_id)
2696
+ if agent_id:
2697
+ base_stmt = base_stmt.where(table.c.agent_id == agent_id)
2698
+ if start_time:
2699
+ # Convert datetime to ISO string for comparison
2700
+ base_stmt = base_stmt.where(table.c.created_at >= start_time.isoformat())
2701
+ if end_time:
2702
+ # Convert datetime to ISO string for comparison
2703
+ base_stmt = base_stmt.where(table.c.created_at <= end_time.isoformat())
2704
+
2705
+ # Get total count of sessions
2706
+ count_stmt = select(func.count()).select_from(base_stmt.alias())
2707
+ total_count = sess.execute(count_stmt).scalar() or 0
2708
+ log_debug(f"Total matching sessions: {total_count}")
2709
+
2710
+ # Apply pagination and ordering
2711
+ offset = (page - 1) * limit if page and limit else 0
2712
+ paginated_stmt = base_stmt.order_by(func.max(table.c.created_at).desc()).limit(limit).offset(offset)
2713
+
2714
+ results = sess.execute(paginated_stmt).fetchall()
2715
+ log_debug(f"Returning page {page} with {len(results)} session stats")
2716
+
2717
+ # Convert to list of dicts with datetime objects
2718
+ stats_list = []
2719
+ for row in results:
2720
+ # Convert ISO strings to datetime objects
2721
+ first_trace_at_str = row.first_trace_at
2722
+ last_trace_at_str = row.last_trace_at
2723
+
2724
+ # Parse ISO format strings to datetime objects (handle None values)
2725
+ first_trace_at = None
2726
+ last_trace_at = None
2727
+ if first_trace_at_str is not None:
2728
+ first_trace_at = datetime.fromisoformat(first_trace_at_str.replace("Z", "+00:00"))
2729
+ if last_trace_at_str is not None:
2730
+ last_trace_at = datetime.fromisoformat(last_trace_at_str.replace("Z", "+00:00"))
2731
+
2732
+ stats_list.append(
2733
+ {
2734
+ "session_id": row.session_id,
2735
+ "user_id": row.user_id,
2736
+ "agent_id": row.agent_id,
2737
+ "team_id": row.team_id,
2738
+ "workflow_id": row.workflow_id,
2739
+ "total_traces": row.total_traces,
2740
+ "first_trace_at": first_trace_at,
2741
+ "last_trace_at": last_trace_at,
2742
+ }
2743
+ )
2744
+
2745
+ return stats_list, total_count
2746
+
2747
+ except Exception as e:
2748
+ log_error(f"Error getting trace stats: {e}")
2749
+ return [], 0
2750
+
2751
+ # --- Spans ---
2752
+ def create_span(self, span: "Span") -> None:
2753
+ """Create a single span in the database.
2754
+
2755
+ Args:
2756
+ span: The Span object to store.
2757
+ """
2758
+ try:
2759
+ table = self._get_table(table_type="spans", create_table_if_not_found=True)
2760
+ if table is None:
2761
+ return
2762
+
2763
+ with self.Session() as sess, sess.begin():
2764
+ stmt = mysql.insert(table).values(span.to_dict())
2765
+ sess.execute(stmt)
2766
+
2767
+ except Exception as e:
2768
+ log_error(f"Error creating span: {e}")
2769
+
2770
+ def create_spans(self, spans: List) -> None:
2771
+ """Create multiple spans in the database as a batch.
2772
+
2773
+ Args:
2774
+ spans: List of Span objects to store.
2775
+ """
2776
+ if not spans:
2777
+ return
2778
+
2779
+ try:
2780
+ table = self._get_table(table_type="spans", create_table_if_not_found=True)
2781
+ if table is None:
2782
+ return
2783
+
2784
+ with self.Session() as sess, sess.begin():
2785
+ for span in spans:
2786
+ stmt = mysql.insert(table).values(span.to_dict())
2787
+ sess.execute(stmt)
2788
+
2789
+ except Exception as e:
2790
+ log_error(f"Error creating spans batch: {e}")
2791
+
2792
+ def get_span(self, span_id: str):
2793
+ """Get a single span by its span_id.
2794
+
2795
+ Args:
2796
+ span_id: The unique span identifier.
2797
+
2798
+ Returns:
2799
+ Optional[Span]: The span if found, None otherwise.
2800
+ """
2801
+ try:
2802
+ from agno.tracing.schemas import Span
2803
+
2804
+ table = self._get_table(table_type="spans")
2805
+ if table is None:
2806
+ return None
2807
+
2808
+ with self.Session() as sess:
2809
+ stmt = select(table).where(table.c.span_id == span_id)
2810
+ result = sess.execute(stmt).fetchone()
2811
+ if result:
2812
+ return Span.from_dict(dict(result._mapping))
2813
+ return None
2814
+
2815
+ except Exception as e:
2816
+ log_error(f"Error getting span: {e}")
2817
+ return None
2818
+
2819
+ def get_spans(
2820
+ self,
2821
+ trace_id: Optional[str] = None,
2822
+ parent_span_id: Optional[str] = None,
2823
+ limit: Optional[int] = 1000,
2824
+ ) -> List:
2825
+ """Get spans matching the provided filters.
2826
+
2827
+ Args:
2828
+ trace_id: Filter by trace ID.
2829
+ parent_span_id: Filter by parent span ID.
2830
+ limit: Maximum number of spans to return.
2831
+
2832
+ Returns:
2833
+ List[Span]: List of matching spans.
2834
+ """
2835
+ try:
2836
+ from agno.tracing.schemas import Span
2837
+
2838
+ table = self._get_table(table_type="spans")
2839
+ if table is None:
2840
+ return []
2841
+
2842
+ with self.Session() as sess:
2843
+ stmt = select(table)
2844
+
2845
+ # Apply filters
2846
+ if trace_id:
2847
+ stmt = stmt.where(table.c.trace_id == trace_id)
2848
+ if parent_span_id:
2849
+ stmt = stmt.where(table.c.parent_span_id == parent_span_id)
2850
+
2851
+ if limit:
2852
+ stmt = stmt.limit(limit)
2853
+
2854
+ results = sess.execute(stmt).fetchall()
2855
+ return [Span.from_dict(dict(row._mapping)) for row in results]
2856
+
2857
+ except Exception as e:
2858
+ log_error(f"Error getting spans: {e}")
2859
+ return []