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
@@ -1,8 +1,11 @@
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
+ if TYPE_CHECKING:
7
+ from agno.tracing.schemas import Span, Trace
8
+
6
9
  from agno.db.base import BaseDb, SessionType
7
10
  from agno.db.migrations.manager import MigrationManager
8
11
  from agno.db.postgres.schemas import get_table_schema_definition
@@ -27,9 +30,10 @@ from agno.utils.log import log_debug, log_error, log_info, log_warning
27
30
  from agno.utils.string import generate_id
28
31
 
29
32
  try:
30
- from sqlalchemy import Index, String, UniqueConstraint, func, select, update
33
+ from sqlalchemy import ForeignKey, Index, String, UniqueConstraint, func, select, update
31
34
  from sqlalchemy.dialects import postgresql
32
35
  from sqlalchemy.engine import Engine, create_engine
36
+ from sqlalchemy.exc import ProgrammingError
33
37
  from sqlalchemy.orm import scoped_session, sessionmaker
34
38
  from sqlalchemy.schema import Column, MetaData, Table
35
39
  from sqlalchemy.sql.expression import text
@@ -49,6 +53,8 @@ class PostgresDb(BaseDb):
49
53
  metrics_table: Optional[str] = None,
50
54
  eval_table: Optional[str] = None,
51
55
  knowledge_table: Optional[str] = None,
56
+ traces_table: Optional[str] = None,
57
+ spans_table: Optional[str] = None,
52
58
  versions_table: Optional[str] = None,
53
59
  id: Optional[str] = None,
54
60
  ):
@@ -70,6 +76,8 @@ class PostgresDb(BaseDb):
70
76
  eval_table (Optional[str]): Name of the table to store evaluation runs data.
71
77
  knowledge_table (Optional[str]): Name of the table to store knowledge content.
72
78
  culture_table (Optional[str]): Name of the table to store cultural knowledge.
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.
73
81
  versions_table (Optional[str]): Name of the table to store schema versions.
74
82
  id (Optional[str]): ID of the database.
75
83
 
@@ -100,6 +108,8 @@ class PostgresDb(BaseDb):
100
108
  eval_table=eval_table,
101
109
  knowledge_table=knowledge_table,
102
110
  culture_table=culture_table,
111
+ traces_table=traces_table,
112
+ spans_table=spans_table,
103
113
  versions_table=versions_table,
104
114
  )
105
115
 
@@ -168,6 +178,16 @@ class PostgresDb(BaseDb):
168
178
  if col_config.get("unique", False):
169
179
  column_kwargs["unique"] = True
170
180
  unique_constraints.append(col_name)
181
+
182
+ # Handle foreign key constraint
183
+ if "foreign_key" in col_config:
184
+ fk_ref = col_config["foreign_key"]
185
+ # For spans table, dynamically replace the traces table reference
186
+ # with the actual trace table name configured for this db instance
187
+ if table_type == "spans" and "trace_id" in fk_ref:
188
+ fk_ref = f"{self.db_schema}.{self.trace_table_name}.trace_id"
189
+ column_args.append(ForeignKey(fk_ref))
190
+
171
191
  columns.append(Column(*column_args, **column_kwargs)) # type: ignore
172
192
 
173
193
  # Create the table object
@@ -287,6 +307,26 @@ class PostgresDb(BaseDb):
287
307
  )
288
308
  return self.versions_table
289
309
 
310
+ if table_type == "traces":
311
+ self.traces_table = self._get_or_create_table(
312
+ table_name=self.trace_table_name,
313
+ table_type="traces",
314
+ create_table_if_not_found=create_table_if_not_found,
315
+ )
316
+ return self.traces_table
317
+
318
+ if table_type == "spans":
319
+ # Ensure traces table exists first (spans has FK to traces)
320
+ if create_table_if_not_found:
321
+ self._get_table(table_type="traces", create_table_if_not_found=True)
322
+
323
+ self.spans_table = self._get_or_create_table(
324
+ table_name=self.span_table_name,
325
+ table_type="spans",
326
+ create_table_if_not_found=create_table_if_not_found,
327
+ )
328
+ return self.spans_table
329
+
290
330
  raise ValueError(f"Unknown table type: {table_type}")
291
331
 
292
332
  def _get_or_create_table(
@@ -1043,9 +1083,14 @@ class PostgresDb(BaseDb):
1043
1083
  return []
1044
1084
 
1045
1085
  with self.Session() as sess, sess.begin():
1046
- stmt = select(func.json_array_elements_text(table.c.topics))
1047
-
1048
- result = sess.execute(stmt).fetchall()
1086
+ try:
1087
+ stmt = select(func.jsonb_array_elements_text(table.c.topics))
1088
+ result = sess.execute(stmt).fetchall()
1089
+ except ProgrammingError:
1090
+ # Retrying with json_array_elements_text. This works in older versions,
1091
+ # where the topics column was of type JSON instead of JSONB
1092
+ stmt = select(func.json_array_elements_text(table.c.topics))
1093
+ result = sess.execute(stmt).fetchall()
1049
1094
 
1050
1095
  return list(set([record[0] for record in result]))
1051
1096
 
@@ -2319,3 +2364,497 @@ class PostgresDb(BaseDb):
2319
2364
  for memory in memories:
2320
2365
  self.upsert_user_memory(memory)
2321
2366
  log_info(f"Migrated {len(memories)} memories to table: {self.memory_table}")
2367
+
2368
+ # --- Traces ---
2369
+ def _get_traces_base_query(self, table: Table, spans_table: Optional[Table] = None):
2370
+ """Build base query for traces with aggregated span counts.
2371
+
2372
+ Args:
2373
+ table: The traces table.
2374
+ spans_table: The spans table (optional).
2375
+
2376
+ Returns:
2377
+ SQLAlchemy select statement with total_spans and error_count calculated dynamically.
2378
+ """
2379
+ from sqlalchemy import case, literal
2380
+
2381
+ if spans_table is not None:
2382
+ # JOIN with spans table to calculate total_spans and error_count
2383
+ return (
2384
+ select(
2385
+ table,
2386
+ func.coalesce(func.count(spans_table.c.span_id), 0).label("total_spans"),
2387
+ func.coalesce(func.sum(case((spans_table.c.status_code == "ERROR", 1), else_=0)), 0).label(
2388
+ "error_count"
2389
+ ),
2390
+ )
2391
+ .select_from(table.outerjoin(spans_table, table.c.trace_id == spans_table.c.trace_id))
2392
+ .group_by(table.c.trace_id)
2393
+ )
2394
+ else:
2395
+ # Fallback if spans table doesn't exist
2396
+ return select(table, literal(0).label("total_spans"), literal(0).label("error_count"))
2397
+
2398
+ def create_trace(self, trace: "Trace") -> None:
2399
+ """Create a single trace record in the database.
2400
+
2401
+ Args:
2402
+ trace: The Trace object to store (one per trace_id).
2403
+ """
2404
+ try:
2405
+ table = self._get_table(table_type="traces", create_table_if_not_found=True)
2406
+ if table is None:
2407
+ return
2408
+
2409
+ with self.Session() as sess, sess.begin():
2410
+ # Check if trace exists
2411
+ existing = sess.execute(select(table).where(table.c.trace_id == trace.trace_id)).fetchone()
2412
+
2413
+ if existing:
2414
+ # workflow (level 3) > team (level 2) > agent (level 1) > child/unknown (level 0)
2415
+
2416
+ def get_component_level(workflow_id, team_id, agent_id, name):
2417
+ # Check if name indicates a root span
2418
+ is_root_name = ".run" in name or ".arun" in name
2419
+
2420
+ if not is_root_name:
2421
+ return 0 # Child span (not a root)
2422
+ elif workflow_id:
2423
+ return 3 # Workflow root
2424
+ elif team_id:
2425
+ return 2 # Team root
2426
+ elif agent_id:
2427
+ return 1 # Agent root
2428
+ else:
2429
+ return 0 # Unknown
2430
+
2431
+ existing_level = get_component_level(
2432
+ existing.workflow_id, existing.team_id, existing.agent_id, existing.name
2433
+ )
2434
+ new_level = get_component_level(trace.workflow_id, trace.team_id, trace.agent_id, trace.name)
2435
+
2436
+ # Only update name if new trace is from a higher or equal level
2437
+ should_update_name = new_level > existing_level
2438
+
2439
+ # Parse existing start_time to calculate correct duration
2440
+ existing_start_time_str = existing.start_time
2441
+ if isinstance(existing_start_time_str, str):
2442
+ existing_start_time = datetime.fromisoformat(existing_start_time_str.replace("Z", "+00:00"))
2443
+ else:
2444
+ existing_start_time = trace.start_time
2445
+
2446
+ recalculated_duration_ms = int((trace.end_time - existing_start_time).total_seconds() * 1000)
2447
+
2448
+ update_values = {
2449
+ "end_time": trace.end_time.isoformat(),
2450
+ "duration_ms": recalculated_duration_ms,
2451
+ "status": trace.status,
2452
+ "name": trace.name if should_update_name else existing.name,
2453
+ }
2454
+
2455
+ # Update context fields ONLY if new value is not None (preserve non-null values)
2456
+ if trace.run_id is not None:
2457
+ update_values["run_id"] = trace.run_id
2458
+ if trace.session_id is not None:
2459
+ update_values["session_id"] = trace.session_id
2460
+ if trace.user_id is not None:
2461
+ update_values["user_id"] = trace.user_id
2462
+ if trace.agent_id is not None:
2463
+ update_values["agent_id"] = trace.agent_id
2464
+ if trace.team_id is not None:
2465
+ update_values["team_id"] = trace.team_id
2466
+ if trace.workflow_id is not None:
2467
+ update_values["workflow_id"] = trace.workflow_id
2468
+
2469
+ log_debug(
2470
+ f" Updating trace with context: run_id={update_values.get('run_id', 'unchanged')}, "
2471
+ f"session_id={update_values.get('session_id', 'unchanged')}, "
2472
+ f"user_id={update_values.get('user_id', 'unchanged')}, "
2473
+ f"agent_id={update_values.get('agent_id', 'unchanged')}, "
2474
+ f"team_id={update_values.get('team_id', 'unchanged')}, "
2475
+ )
2476
+
2477
+ stmt = update(table).where(table.c.trace_id == trace.trace_id).values(**update_values)
2478
+ sess.execute(stmt)
2479
+ else:
2480
+ trace_dict = trace.to_dict()
2481
+ trace_dict.pop("total_spans", None)
2482
+ trace_dict.pop("error_count", None)
2483
+ stmt = postgresql.insert(table).values(trace_dict)
2484
+ sess.execute(stmt)
2485
+
2486
+ except Exception as e:
2487
+ log_error(f"Error creating trace: {e}")
2488
+ # Don't raise - tracing should not break the main application flow
2489
+
2490
+ def get_trace(
2491
+ self,
2492
+ trace_id: Optional[str] = None,
2493
+ run_id: Optional[str] = None,
2494
+ ):
2495
+ """Get a single trace by trace_id or other filters.
2496
+
2497
+ Args:
2498
+ trace_id: The unique trace identifier.
2499
+ run_id: Filter by run ID (returns first match).
2500
+
2501
+ Returns:
2502
+ Optional[Trace]: The trace if found, None otherwise.
2503
+
2504
+ Note:
2505
+ If multiple filters are provided, trace_id takes precedence.
2506
+ For other filters, the most recent trace is returned.
2507
+ """
2508
+ try:
2509
+ from agno.tracing.schemas import Trace
2510
+
2511
+ table = self._get_table(table_type="traces")
2512
+ if table is None:
2513
+ return None
2514
+
2515
+ # Get spans table for JOIN
2516
+ spans_table = self._get_table(table_type="spans")
2517
+
2518
+ with self.Session() as sess:
2519
+ # Build query with aggregated span counts
2520
+ stmt = self._get_traces_base_query(table, spans_table)
2521
+
2522
+ if trace_id:
2523
+ stmt = stmt.where(table.c.trace_id == trace_id)
2524
+ elif run_id:
2525
+ stmt = stmt.where(table.c.run_id == run_id)
2526
+ else:
2527
+ log_debug("get_trace called without any filter parameters")
2528
+ return None
2529
+
2530
+ # Order by most recent and get first result
2531
+ stmt = stmt.order_by(table.c.start_time.desc()).limit(1)
2532
+ result = sess.execute(stmt).fetchone()
2533
+
2534
+ if result:
2535
+ return Trace.from_dict(dict(result._mapping))
2536
+ return None
2537
+
2538
+ except Exception as e:
2539
+ log_error(f"Error getting trace: {e}")
2540
+ return None
2541
+
2542
+ def get_traces(
2543
+ self,
2544
+ run_id: Optional[str] = None,
2545
+ session_id: Optional[str] = None,
2546
+ user_id: Optional[str] = None,
2547
+ agent_id: Optional[str] = None,
2548
+ team_id: Optional[str] = None,
2549
+ workflow_id: Optional[str] = None,
2550
+ status: Optional[str] = None,
2551
+ start_time: Optional[datetime] = None,
2552
+ end_time: Optional[datetime] = None,
2553
+ limit: Optional[int] = 20,
2554
+ page: Optional[int] = 1,
2555
+ ) -> tuple[List, int]:
2556
+ """Get traces matching the provided filters with pagination.
2557
+
2558
+ Args:
2559
+ run_id: Filter by run ID.
2560
+ session_id: Filter by session ID.
2561
+ user_id: Filter by user ID.
2562
+ agent_id: Filter by agent ID.
2563
+ team_id: Filter by team ID.
2564
+ workflow_id: Filter by workflow ID.
2565
+ status: Filter by status (OK, ERROR, UNSET).
2566
+ start_time: Filter traces starting after this datetime.
2567
+ end_time: Filter traces ending before this datetime.
2568
+ limit: Maximum number of traces to return per page.
2569
+ page: Page number (1-indexed).
2570
+
2571
+ Returns:
2572
+ tuple[List[Trace], int]: Tuple of (list of matching traces, total count).
2573
+ """
2574
+ try:
2575
+ from agno.tracing.schemas import Trace
2576
+
2577
+ log_debug(
2578
+ 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}"
2579
+ )
2580
+
2581
+ table = self._get_table(table_type="traces")
2582
+ if table is None:
2583
+ log_debug("Traces table not found")
2584
+ return [], 0
2585
+
2586
+ # Get spans table for JOIN
2587
+ spans_table = self._get_table(table_type="spans")
2588
+
2589
+ with self.Session() as sess:
2590
+ # Build base query with aggregated span counts
2591
+ base_stmt = self._get_traces_base_query(table, spans_table)
2592
+
2593
+ # Apply filters
2594
+ if run_id:
2595
+ base_stmt = base_stmt.where(table.c.run_id == run_id)
2596
+ if session_id:
2597
+ log_debug(f"Filtering by session_id={session_id}")
2598
+ base_stmt = base_stmt.where(table.c.session_id == session_id)
2599
+ if user_id:
2600
+ base_stmt = base_stmt.where(table.c.user_id == user_id)
2601
+ if agent_id:
2602
+ base_stmt = base_stmt.where(table.c.agent_id == agent_id)
2603
+ if team_id:
2604
+ base_stmt = base_stmt.where(table.c.team_id == team_id)
2605
+ if workflow_id:
2606
+ base_stmt = base_stmt.where(table.c.workflow_id == workflow_id)
2607
+ if status:
2608
+ base_stmt = base_stmt.where(table.c.status == status)
2609
+ if start_time:
2610
+ # Convert datetime to ISO string for comparison
2611
+ base_stmt = base_stmt.where(table.c.start_time >= start_time.isoformat())
2612
+ if end_time:
2613
+ # Convert datetime to ISO string for comparison
2614
+ base_stmt = base_stmt.where(table.c.end_time <= end_time.isoformat())
2615
+
2616
+ # Get total count
2617
+ count_stmt = select(func.count()).select_from(base_stmt.alias())
2618
+ total_count = sess.execute(count_stmt).scalar() or 0
2619
+ log_debug(f"Total matching traces: {total_count}")
2620
+
2621
+ # Apply pagination
2622
+ offset = (page - 1) * limit if page and limit else 0
2623
+ paginated_stmt = base_stmt.order_by(table.c.start_time.desc()).limit(limit).offset(offset)
2624
+
2625
+ results = sess.execute(paginated_stmt).fetchall()
2626
+ log_debug(f"Returning page {page} with {len(results)} traces")
2627
+
2628
+ traces = [Trace.from_dict(dict(row._mapping)) for row in results]
2629
+ return traces, total_count
2630
+
2631
+ except Exception as e:
2632
+ log_error(f"Error getting traces: {e}")
2633
+ return [], 0
2634
+
2635
+ def get_trace_stats(
2636
+ self,
2637
+ user_id: Optional[str] = None,
2638
+ agent_id: Optional[str] = None,
2639
+ team_id: Optional[str] = None,
2640
+ workflow_id: Optional[str] = None,
2641
+ start_time: Optional[datetime] = None,
2642
+ end_time: Optional[datetime] = None,
2643
+ limit: Optional[int] = 20,
2644
+ page: Optional[int] = 1,
2645
+ ) -> tuple[List[Dict[str, Any]], int]:
2646
+ """Get trace statistics grouped by session.
2647
+
2648
+ Args:
2649
+ user_id: Filter by user ID.
2650
+ agent_id: Filter by agent ID.
2651
+ team_id: Filter by team ID.
2652
+ workflow_id: Filter by workflow ID.
2653
+ start_time: Filter sessions with traces created after this datetime.
2654
+ end_time: Filter sessions with traces created before this datetime.
2655
+ limit: Maximum number of sessions to return per page.
2656
+ page: Page number (1-indexed).
2657
+
2658
+ Returns:
2659
+ tuple[List[Dict], int]: Tuple of (list of session stats dicts, total count).
2660
+ Each dict contains: session_id, user_id, agent_id, team_id, total_traces,
2661
+ first_trace_at, last_trace_at.
2662
+ """
2663
+ try:
2664
+ log_debug(
2665
+ f"get_trace_stats called with filters: user_id={user_id}, agent_id={agent_id}, "
2666
+ f"workflow_id={workflow_id}, team_id={team_id}, "
2667
+ f"start_time={start_time}, end_time={end_time}, page={page}, limit={limit}"
2668
+ )
2669
+
2670
+ table = self._get_table(table_type="traces")
2671
+ if table is None:
2672
+ log_debug("Traces table not found")
2673
+ return [], 0
2674
+
2675
+ with self.Session() as sess:
2676
+ # Build base query grouped by session_id
2677
+ base_stmt = (
2678
+ select(
2679
+ table.c.session_id,
2680
+ table.c.user_id,
2681
+ table.c.agent_id,
2682
+ table.c.team_id,
2683
+ table.c.workflow_id,
2684
+ func.count(table.c.trace_id).label("total_traces"),
2685
+ func.min(table.c.created_at).label("first_trace_at"),
2686
+ func.max(table.c.created_at).label("last_trace_at"),
2687
+ )
2688
+ .where(table.c.session_id.isnot(None)) # Only sessions with session_id
2689
+ .group_by(
2690
+ table.c.session_id, table.c.user_id, table.c.agent_id, table.c.team_id, table.c.workflow_id
2691
+ )
2692
+ )
2693
+
2694
+ # Apply filters
2695
+ if user_id:
2696
+ base_stmt = base_stmt.where(table.c.user_id == user_id)
2697
+ if workflow_id:
2698
+ base_stmt = base_stmt.where(table.c.workflow_id == workflow_id)
2699
+ if team_id:
2700
+ base_stmt = base_stmt.where(table.c.team_id == team_id)
2701
+ if agent_id:
2702
+ base_stmt = base_stmt.where(table.c.agent_id == agent_id)
2703
+ if start_time:
2704
+ # Convert datetime to ISO string for comparison
2705
+ base_stmt = base_stmt.where(table.c.created_at >= start_time.isoformat())
2706
+ if end_time:
2707
+ # Convert datetime to ISO string for comparison
2708
+ base_stmt = base_stmt.where(table.c.created_at <= end_time.isoformat())
2709
+
2710
+ # Get total count of sessions
2711
+ count_stmt = select(func.count()).select_from(base_stmt.alias())
2712
+ total_count = sess.execute(count_stmt).scalar() or 0
2713
+ log_debug(f"Total matching sessions: {total_count}")
2714
+
2715
+ # Apply pagination and ordering
2716
+ offset = (page - 1) * limit if page and limit else 0
2717
+ paginated_stmt = base_stmt.order_by(func.max(table.c.created_at).desc()).limit(limit).offset(offset)
2718
+
2719
+ results = sess.execute(paginated_stmt).fetchall()
2720
+ log_debug(f"Returning page {page} with {len(results)} session stats")
2721
+
2722
+ # Convert to list of dicts with datetime objects
2723
+ stats_list = []
2724
+ for row in results:
2725
+ # Convert ISO strings to datetime objects
2726
+ first_trace_at_str = row.first_trace_at
2727
+ last_trace_at_str = row.last_trace_at
2728
+
2729
+ # Parse ISO format strings to datetime objects
2730
+ first_trace_at = datetime.fromisoformat(first_trace_at_str.replace("Z", "+00:00"))
2731
+ last_trace_at = datetime.fromisoformat(last_trace_at_str.replace("Z", "+00:00"))
2732
+
2733
+ stats_list.append(
2734
+ {
2735
+ "session_id": row.session_id,
2736
+ "user_id": row.user_id,
2737
+ "agent_id": row.agent_id,
2738
+ "team_id": row.team_id,
2739
+ "workflow_id": row.workflow_id,
2740
+ "total_traces": row.total_traces,
2741
+ "first_trace_at": first_trace_at,
2742
+ "last_trace_at": last_trace_at,
2743
+ }
2744
+ )
2745
+
2746
+ return stats_list, total_count
2747
+
2748
+ except Exception as e:
2749
+ log_error(f"Error getting trace stats: {e}")
2750
+ return [], 0
2751
+
2752
+ # --- Spans ---
2753
+ def create_span(self, span: "Span") -> None:
2754
+ """Create a single span in the database.
2755
+
2756
+ Args:
2757
+ span: The Span object to store.
2758
+ """
2759
+ try:
2760
+ table = self._get_table(table_type="spans", create_table_if_not_found=True)
2761
+ if table is None:
2762
+ return
2763
+
2764
+ with self.Session() as sess, sess.begin():
2765
+ stmt = postgresql.insert(table).values(span.to_dict())
2766
+ sess.execute(stmt)
2767
+
2768
+ except Exception as e:
2769
+ log_error(f"Error creating span: {e}")
2770
+
2771
+ def create_spans(self, spans: List) -> None:
2772
+ """Create multiple spans in the database as a batch.
2773
+
2774
+ Args:
2775
+ spans: List of Span objects to store.
2776
+ """
2777
+ if not spans:
2778
+ return
2779
+
2780
+ try:
2781
+ table = self._get_table(table_type="spans", create_table_if_not_found=True)
2782
+ if table is None:
2783
+ return
2784
+
2785
+ with self.Session() as sess, sess.begin():
2786
+ for span in spans:
2787
+ stmt = postgresql.insert(table).values(span.to_dict())
2788
+ sess.execute(stmt)
2789
+
2790
+ except Exception as e:
2791
+ log_error(f"Error creating spans batch: {e}")
2792
+
2793
+ def get_span(self, span_id: str):
2794
+ """Get a single span by its span_id.
2795
+
2796
+ Args:
2797
+ span_id: The unique span identifier.
2798
+
2799
+ Returns:
2800
+ Optional[Span]: The span if found, None otherwise.
2801
+ """
2802
+ try:
2803
+ from agno.tracing.schemas import Span
2804
+
2805
+ table = self._get_table(table_type="spans")
2806
+ if table is None:
2807
+ return None
2808
+
2809
+ with self.Session() as sess:
2810
+ stmt = select(table).where(table.c.span_id == span_id)
2811
+ result = sess.execute(stmt).fetchone()
2812
+ if result:
2813
+ return Span.from_dict(dict(result._mapping))
2814
+ return None
2815
+
2816
+ except Exception as e:
2817
+ log_error(f"Error getting span: {e}")
2818
+ return None
2819
+
2820
+ def get_spans(
2821
+ self,
2822
+ trace_id: Optional[str] = None,
2823
+ parent_span_id: Optional[str] = None,
2824
+ limit: Optional[int] = 1000,
2825
+ ) -> List:
2826
+ """Get spans matching the provided filters.
2827
+
2828
+ Args:
2829
+ trace_id: Filter by trace ID.
2830
+ parent_span_id: Filter by parent span ID.
2831
+ limit: Maximum number of spans to return.
2832
+
2833
+ Returns:
2834
+ List[Span]: List of matching spans.
2835
+ """
2836
+ try:
2837
+ from agno.tracing.schemas import Span
2838
+
2839
+ table = self._get_table(table_type="spans")
2840
+ if table is None:
2841
+ return []
2842
+
2843
+ with self.Session() as sess:
2844
+ stmt = select(table)
2845
+
2846
+ # Apply filters
2847
+ if trace_id:
2848
+ stmt = stmt.where(table.c.trace_id == trace_id)
2849
+ if parent_span_id:
2850
+ stmt = stmt.where(table.c.parent_span_id == parent_span_id)
2851
+
2852
+ if limit:
2853
+ stmt = stmt.limit(limit)
2854
+
2855
+ results = sess.execute(stmt).fetchall()
2856
+ return [Span.from_dict(dict(row._mapping)) for row in results]
2857
+
2858
+ except Exception as e:
2859
+ log_error(f"Error getting spans: {e}")
2860
+ return []
@@ -121,6 +121,42 @@ VERSIONS_TABLE_SCHEMA = {
121
121
  "updated_at": {"type": String, "nullable": True},
122
122
  }
123
123
 
124
+ TRACE_TABLE_SCHEMA = {
125
+ "trace_id": {"type": String, "primary_key": True, "nullable": False},
126
+ "name": {"type": String, "nullable": False},
127
+ "status": {"type": String, "nullable": False, "index": True},
128
+ "start_time": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
129
+ "end_time": {"type": String, "nullable": False}, # ISO 8601 datetime string
130
+ "duration_ms": {"type": BigInteger, "nullable": False},
131
+ "run_id": {"type": String, "nullable": True, "index": True},
132
+ "session_id": {"type": String, "nullable": True, "index": True},
133
+ "user_id": {"type": String, "nullable": True, "index": True},
134
+ "agent_id": {"type": String, "nullable": True, "index": True},
135
+ "team_id": {"type": String, "nullable": True, "index": True},
136
+ "workflow_id": {"type": String, "nullable": True, "index": True},
137
+ "created_at": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
138
+ }
139
+
140
+ SPAN_TABLE_SCHEMA = {
141
+ "span_id": {"type": String, "primary_key": True, "nullable": False},
142
+ "trace_id": {
143
+ "type": String,
144
+ "nullable": False,
145
+ "index": True,
146
+ "foreign_key": "agno_traces.trace_id", # Foreign key to traces table
147
+ },
148
+ "parent_span_id": {"type": String, "nullable": True, "index": True},
149
+ "name": {"type": String, "nullable": False},
150
+ "span_kind": {"type": String, "nullable": False},
151
+ "status_code": {"type": String, "nullable": False},
152
+ "status_message": {"type": Text, "nullable": True},
153
+ "start_time": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
154
+ "end_time": {"type": String, "nullable": False}, # ISO 8601 datetime string
155
+ "duration_ms": {"type": BigInteger, "nullable": False},
156
+ "attributes": {"type": JSONB, "nullable": True},
157
+ "created_at": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
158
+ }
159
+
124
160
 
125
161
  def get_table_schema_definition(table_type: str) -> dict[str, Any]:
126
162
  """
@@ -140,6 +176,8 @@ def get_table_schema_definition(table_type: str) -> dict[str, Any]:
140
176
  "knowledge": KNOWLEDGE_TABLE_SCHEMA,
141
177
  "culture": CULTURAL_KNOWLEDGE_TABLE_SCHEMA,
142
178
  "versions": VERSIONS_TABLE_SCHEMA,
179
+ "traces": TRACE_TABLE_SCHEMA,
180
+ "spans": SPAN_TABLE_SCHEMA,
143
181
  }
144
182
 
145
183
  schema = schemas.get(table_type, {})