kubiya-control-plane-api 0.1.0__py3-none-any.whl → 0.3.4__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.

Potentially problematic release.


This version of kubiya-control-plane-api might be problematic. Click here for more details.

Files changed (185) hide show
  1. control_plane_api/README.md +266 -0
  2. control_plane_api/__init__.py +0 -0
  3. control_plane_api/__version__.py +1 -0
  4. control_plane_api/alembic/README +1 -0
  5. control_plane_api/alembic/env.py +98 -0
  6. control_plane_api/alembic/script.py.mako +28 -0
  7. control_plane_api/alembic/versions/1382bec74309_initial_migration_with_all_models.py +251 -0
  8. control_plane_api/alembic/versions/1f54bc2a37e3_add_analytics_tables.py +162 -0
  9. control_plane_api/alembic/versions/2e4cb136dc10_rename_toolset_ids_to_skill_ids_in_teams.py +30 -0
  10. control_plane_api/alembic/versions/31cd69a644ce_add_skill_templates_table.py +28 -0
  11. control_plane_api/alembic/versions/89e127caa47d_add_jobs_and_job_executions_tables.py +161 -0
  12. control_plane_api/alembic/versions/add_llm_models_table.py +51 -0
  13. control_plane_api/alembic/versions/b0e10697f212_add_runtime_column_to_teams_simple.py +42 -0
  14. control_plane_api/alembic/versions/ce43b24b63bf_add_execution_trigger_source_and_fix_.py +155 -0
  15. control_plane_api/alembic/versions/d4eaf16e3f8d_rename_toolsets_to_skills.py +84 -0
  16. control_plane_api/alembic/versions/efa2dc427da1_rename_metadata_to_custom_metadata.py +32 -0
  17. control_plane_api/alembic/versions/f973b431d1ce_add_workflow_executor_to_skill_types.py +44 -0
  18. control_plane_api/alembic.ini +148 -0
  19. control_plane_api/api/index.py +12 -0
  20. control_plane_api/app/__init__.py +11 -0
  21. control_plane_api/app/activities/__init__.py +20 -0
  22. control_plane_api/app/activities/agent_activities.py +379 -0
  23. control_plane_api/app/activities/team_activities.py +410 -0
  24. control_plane_api/app/activities/temporal_cloud_activities.py +577 -0
  25. control_plane_api/app/config/__init__.py +35 -0
  26. control_plane_api/app/config/api_config.py +354 -0
  27. control_plane_api/app/config/model_pricing.py +318 -0
  28. control_plane_api/app/config.py +95 -0
  29. control_plane_api/app/database.py +135 -0
  30. control_plane_api/app/exceptions.py +408 -0
  31. control_plane_api/app/lib/__init__.py +11 -0
  32. control_plane_api/app/lib/job_executor.py +312 -0
  33. control_plane_api/app/lib/kubiya_client.py +235 -0
  34. control_plane_api/app/lib/litellm_pricing.py +166 -0
  35. control_plane_api/app/lib/planning_tools/__init__.py +22 -0
  36. control_plane_api/app/lib/planning_tools/agents.py +155 -0
  37. control_plane_api/app/lib/planning_tools/base.py +189 -0
  38. control_plane_api/app/lib/planning_tools/environments.py +214 -0
  39. control_plane_api/app/lib/planning_tools/resources.py +240 -0
  40. control_plane_api/app/lib/planning_tools/teams.py +198 -0
  41. control_plane_api/app/lib/policy_enforcer_client.py +939 -0
  42. control_plane_api/app/lib/redis_client.py +436 -0
  43. control_plane_api/app/lib/supabase.py +71 -0
  44. control_plane_api/app/lib/temporal_client.py +138 -0
  45. control_plane_api/app/lib/validation/__init__.py +20 -0
  46. control_plane_api/app/lib/validation/runtime_validation.py +287 -0
  47. control_plane_api/app/main.py +128 -0
  48. control_plane_api/app/middleware/__init__.py +8 -0
  49. control_plane_api/app/middleware/auth.py +513 -0
  50. control_plane_api/app/middleware/exception_handler.py +267 -0
  51. control_plane_api/app/middleware/rate_limiting.py +384 -0
  52. control_plane_api/app/middleware/request_id.py +202 -0
  53. control_plane_api/app/models/__init__.py +27 -0
  54. control_plane_api/app/models/agent.py +79 -0
  55. control_plane_api/app/models/analytics.py +206 -0
  56. control_plane_api/app/models/associations.py +81 -0
  57. control_plane_api/app/models/environment.py +63 -0
  58. control_plane_api/app/models/execution.py +93 -0
  59. control_plane_api/app/models/job.py +179 -0
  60. control_plane_api/app/models/llm_model.py +75 -0
  61. control_plane_api/app/models/presence.py +49 -0
  62. control_plane_api/app/models/project.py +47 -0
  63. control_plane_api/app/models/session.py +38 -0
  64. control_plane_api/app/models/team.py +66 -0
  65. control_plane_api/app/models/workflow.py +55 -0
  66. control_plane_api/app/policies/README.md +121 -0
  67. control_plane_api/app/policies/approved_users.rego +62 -0
  68. control_plane_api/app/policies/business_hours.rego +51 -0
  69. control_plane_api/app/policies/rate_limiting.rego +100 -0
  70. control_plane_api/app/policies/tool_restrictions.rego +86 -0
  71. control_plane_api/app/routers/__init__.py +4 -0
  72. control_plane_api/app/routers/agents.py +364 -0
  73. control_plane_api/app/routers/agents_v2.py +1260 -0
  74. control_plane_api/app/routers/analytics.py +1014 -0
  75. control_plane_api/app/routers/context_manager.py +562 -0
  76. control_plane_api/app/routers/environment_context.py +270 -0
  77. control_plane_api/app/routers/environments.py +715 -0
  78. control_plane_api/app/routers/execution_environment.py +517 -0
  79. control_plane_api/app/routers/executions.py +1911 -0
  80. control_plane_api/app/routers/health.py +92 -0
  81. control_plane_api/app/routers/health_v2.py +326 -0
  82. control_plane_api/app/routers/integrations.py +274 -0
  83. control_plane_api/app/routers/jobs.py +1344 -0
  84. control_plane_api/app/routers/models.py +82 -0
  85. control_plane_api/app/routers/models_v2.py +361 -0
  86. control_plane_api/app/routers/policies.py +639 -0
  87. control_plane_api/app/routers/presence.py +234 -0
  88. control_plane_api/app/routers/projects.py +902 -0
  89. control_plane_api/app/routers/runners.py +379 -0
  90. control_plane_api/app/routers/runtimes.py +172 -0
  91. control_plane_api/app/routers/secrets.py +155 -0
  92. control_plane_api/app/routers/skills.py +1001 -0
  93. control_plane_api/app/routers/skills_definitions.py +140 -0
  94. control_plane_api/app/routers/task_planning.py +1256 -0
  95. control_plane_api/app/routers/task_queues.py +654 -0
  96. control_plane_api/app/routers/team_context.py +270 -0
  97. control_plane_api/app/routers/teams.py +1400 -0
  98. control_plane_api/app/routers/worker_queues.py +1545 -0
  99. control_plane_api/app/routers/workers.py +935 -0
  100. control_plane_api/app/routers/workflows.py +204 -0
  101. control_plane_api/app/runtimes/__init__.py +6 -0
  102. control_plane_api/app/runtimes/validation.py +344 -0
  103. control_plane_api/app/schemas/job_schemas.py +295 -0
  104. control_plane_api/app/services/__init__.py +1 -0
  105. control_plane_api/app/services/agno_service.py +619 -0
  106. control_plane_api/app/services/litellm_service.py +190 -0
  107. control_plane_api/app/services/policy_service.py +525 -0
  108. control_plane_api/app/services/temporal_cloud_provisioning.py +150 -0
  109. control_plane_api/app/skills/__init__.py +44 -0
  110. control_plane_api/app/skills/base.py +229 -0
  111. control_plane_api/app/skills/business_intelligence.py +189 -0
  112. control_plane_api/app/skills/data_visualization.py +154 -0
  113. control_plane_api/app/skills/docker.py +104 -0
  114. control_plane_api/app/skills/file_generation.py +94 -0
  115. control_plane_api/app/skills/file_system.py +110 -0
  116. control_plane_api/app/skills/python.py +92 -0
  117. control_plane_api/app/skills/registry.py +65 -0
  118. control_plane_api/app/skills/shell.py +102 -0
  119. control_plane_api/app/skills/workflow_executor.py +469 -0
  120. control_plane_api/app/utils/workflow_executor.py +354 -0
  121. control_plane_api/app/workflows/__init__.py +11 -0
  122. control_plane_api/app/workflows/agent_execution.py +507 -0
  123. control_plane_api/app/workflows/agent_execution_with_skills.py +222 -0
  124. control_plane_api/app/workflows/namespace_provisioning.py +326 -0
  125. control_plane_api/app/workflows/team_execution.py +399 -0
  126. control_plane_api/scripts/seed_models.py +239 -0
  127. control_plane_api/worker/__init__.py +0 -0
  128. control_plane_api/worker/activities/__init__.py +0 -0
  129. control_plane_api/worker/activities/agent_activities.py +1241 -0
  130. control_plane_api/worker/activities/approval_activities.py +234 -0
  131. control_plane_api/worker/activities/runtime_activities.py +388 -0
  132. control_plane_api/worker/activities/skill_activities.py +267 -0
  133. control_plane_api/worker/activities/team_activities.py +1217 -0
  134. control_plane_api/worker/config/__init__.py +31 -0
  135. control_plane_api/worker/config/worker_config.py +275 -0
  136. control_plane_api/worker/control_plane_client.py +529 -0
  137. control_plane_api/worker/examples/analytics_integration_example.py +362 -0
  138. control_plane_api/worker/models/__init__.py +1 -0
  139. control_plane_api/worker/models/inputs.py +89 -0
  140. control_plane_api/worker/runtimes/__init__.py +31 -0
  141. control_plane_api/worker/runtimes/base.py +789 -0
  142. control_plane_api/worker/runtimes/claude_code_runtime.py +1443 -0
  143. control_plane_api/worker/runtimes/default_runtime.py +617 -0
  144. control_plane_api/worker/runtimes/factory.py +173 -0
  145. control_plane_api/worker/runtimes/validation.py +93 -0
  146. control_plane_api/worker/services/__init__.py +1 -0
  147. control_plane_api/worker/services/agent_executor.py +422 -0
  148. control_plane_api/worker/services/agent_executor_v2.py +383 -0
  149. control_plane_api/worker/services/analytics_collector.py +457 -0
  150. control_plane_api/worker/services/analytics_service.py +464 -0
  151. control_plane_api/worker/services/approval_tools.py +310 -0
  152. control_plane_api/worker/services/approval_tools_agno.py +207 -0
  153. control_plane_api/worker/services/cancellation_manager.py +177 -0
  154. control_plane_api/worker/services/data_visualization.py +827 -0
  155. control_plane_api/worker/services/jira_tools.py +257 -0
  156. control_plane_api/worker/services/runtime_analytics.py +328 -0
  157. control_plane_api/worker/services/session_service.py +194 -0
  158. control_plane_api/worker/services/skill_factory.py +175 -0
  159. control_plane_api/worker/services/team_executor.py +574 -0
  160. control_plane_api/worker/services/team_executor_v2.py +465 -0
  161. control_plane_api/worker/services/workflow_executor_tools.py +1418 -0
  162. control_plane_api/worker/tests/__init__.py +1 -0
  163. control_plane_api/worker/tests/e2e/__init__.py +0 -0
  164. control_plane_api/worker/tests/e2e/test_execution_flow.py +571 -0
  165. control_plane_api/worker/tests/integration/__init__.py +0 -0
  166. control_plane_api/worker/tests/integration/test_control_plane_integration.py +308 -0
  167. control_plane_api/worker/tests/unit/__init__.py +0 -0
  168. control_plane_api/worker/tests/unit/test_control_plane_client.py +401 -0
  169. control_plane_api/worker/utils/__init__.py +1 -0
  170. control_plane_api/worker/utils/chunk_batcher.py +305 -0
  171. control_plane_api/worker/utils/retry_utils.py +60 -0
  172. control_plane_api/worker/utils/streaming_utils.py +373 -0
  173. control_plane_api/worker/worker.py +753 -0
  174. control_plane_api/worker/workflows/__init__.py +0 -0
  175. control_plane_api/worker/workflows/agent_execution.py +589 -0
  176. control_plane_api/worker/workflows/team_execution.py +429 -0
  177. kubiya_control_plane_api-0.3.4.dist-info/METADATA +229 -0
  178. kubiya_control_plane_api-0.3.4.dist-info/RECORD +182 -0
  179. kubiya_control_plane_api-0.3.4.dist-info/entry_points.txt +2 -0
  180. kubiya_control_plane_api-0.3.4.dist-info/top_level.txt +1 -0
  181. kubiya_control_plane_api-0.1.0.dist-info/METADATA +0 -66
  182. kubiya_control_plane_api-0.1.0.dist-info/RECORD +0 -5
  183. kubiya_control_plane_api-0.1.0.dist-info/top_level.txt +0 -1
  184. {kubiya_control_plane_api-0.1.0.dist-info/licenses → control_plane_api}/LICENSE +0 -0
  185. {kubiya_control_plane_api-0.1.0.dist-info → kubiya_control_plane_api-0.3.4.dist-info}/WHEEL +0 -0
@@ -0,0 +1,457 @@
1
+ """
2
+ Analytics collector that integrates with runtime execution to track metrics.
3
+
4
+ This module provides hooks into the execution lifecycle to automatically
5
+ collect and submit analytics data without blocking execution.
6
+
7
+ Key features:
8
+ - Leverages LiteLLM and Agno native usage tracking
9
+ - Async fire-and-forget submission (doesn't block execution)
10
+ - Comprehensive error handling (failures don't break execution)
11
+ - Per-turn, tool call, and task tracking
12
+ - Automatic cost calculation
13
+ """
14
+
15
+ from typing import Dict, Any, Optional, List
16
+ from datetime import datetime, timezone
17
+ import structlog
18
+ import asyncio
19
+ import time
20
+ from dataclasses import dataclass
21
+
22
+ from control_plane_api.worker.services.analytics_service import (
23
+ AnalyticsService,
24
+ TurnMetrics,
25
+ ToolCallMetrics,
26
+ TaskMetrics,
27
+ )
28
+
29
+ logger = structlog.get_logger()
30
+
31
+
32
+ @dataclass
33
+ class ExecutionContext:
34
+ """Context for tracking execution metrics"""
35
+ execution_id: str
36
+ organization_id: str
37
+ turn_number: int = 0
38
+ turn_start_time: Optional[float] = None
39
+ current_turn_id: Optional[str] = None
40
+ tools_in_turn: List[str] = None
41
+
42
+ def __post_init__(self):
43
+ if self.tools_in_turn is None:
44
+ self.tools_in_turn = []
45
+
46
+
47
+ class AnalyticsCollector:
48
+ """
49
+ Collects and submits analytics during execution.
50
+
51
+ This collector integrates with the runtime to automatically track:
52
+ - LLM turns with token usage and costs
53
+ - Tool executions with timing and results
54
+ - Task progress
55
+
56
+ All submissions are async and failures are logged but don't break execution.
57
+ """
58
+
59
+ def __init__(self, analytics_service: AnalyticsService):
60
+ self.analytics = analytics_service
61
+ self._submission_tasks: List[asyncio.Task] = []
62
+
63
+ def start_turn(self, ctx: ExecutionContext) -> ExecutionContext:
64
+ """
65
+ Mark the start of a new LLM turn.
66
+
67
+ Args:
68
+ ctx: Execution context
69
+
70
+ Returns:
71
+ Updated context with turn tracking
72
+ """
73
+ ctx.turn_number += 1
74
+ ctx.turn_start_time = time.time()
75
+ ctx.tools_in_turn = []
76
+
77
+ logger.debug(
78
+ "turn_started",
79
+ execution_id=ctx.execution_id[:8],
80
+ turn_number=ctx.turn_number,
81
+ )
82
+
83
+ return ctx
84
+
85
+ def record_turn_from_litellm(
86
+ self,
87
+ ctx: ExecutionContext,
88
+ response: Any,
89
+ model: str,
90
+ finish_reason: str = "stop",
91
+ error_message: Optional[str] = None,
92
+ ):
93
+ """
94
+ Record turn metrics from LiteLLM response.
95
+
96
+ LiteLLM responses have `usage` attribute with token counts.
97
+
98
+ Args:
99
+ ctx: Execution context
100
+ response: LiteLLM completion response
101
+ model: Model identifier
102
+ finish_reason: Why the turn finished
103
+ error_message: Error if turn failed
104
+ """
105
+ if not ctx.turn_start_time:
106
+ logger.warning("record_turn_called_without_start", execution_id=ctx.execution_id[:8])
107
+ return
108
+
109
+ turn_end_time = time.time()
110
+ duration_ms = int((turn_end_time - ctx.turn_start_time) * 1000)
111
+
112
+ # Extract usage from LiteLLM response
113
+ usage = getattr(response, "usage", None)
114
+ if not usage:
115
+ logger.warning("litellm_response_missing_usage", execution_id=ctx.execution_id[:8])
116
+ return
117
+
118
+ input_tokens = getattr(usage, "prompt_tokens", 0)
119
+ output_tokens = getattr(usage, "completion_tokens", 0)
120
+ total_tokens = getattr(usage, "total_tokens", 0)
121
+
122
+ # Anthropic-specific: cache tokens
123
+ cache_read_tokens = getattr(usage, "prompt_tokens_details", {}).get("cached_tokens", 0) if hasattr(usage, "prompt_tokens_details") else 0
124
+ cache_creation_tokens = 0 # LiteLLM doesn't expose this directly
125
+
126
+ # Extract response content
127
+ response_content = ""
128
+ if hasattr(response, "choices") and response.choices:
129
+ message = response.choices[0].message
130
+ response_content = getattr(message, "content", "")
131
+
132
+ # Calculate costs
133
+ costs = AnalyticsService.calculate_token_cost(
134
+ input_tokens=input_tokens,
135
+ output_tokens=output_tokens,
136
+ cache_read_tokens=cache_read_tokens,
137
+ cache_creation_tokens=cache_creation_tokens,
138
+ model=model,
139
+ )
140
+
141
+ # Determine model provider from model string
142
+ model_provider = self._extract_provider(model)
143
+
144
+ turn = TurnMetrics(
145
+ execution_id=ctx.execution_id,
146
+ turn_number=ctx.turn_number,
147
+ model=model,
148
+ model_provider=model_provider,
149
+ started_at=datetime.fromtimestamp(ctx.turn_start_time, timezone.utc).isoformat(),
150
+ completed_at=datetime.fromtimestamp(turn_end_time, timezone.utc).isoformat(),
151
+ duration_ms=duration_ms,
152
+ input_tokens=input_tokens,
153
+ output_tokens=output_tokens,
154
+ cache_read_tokens=cache_read_tokens,
155
+ cache_creation_tokens=cache_creation_tokens,
156
+ total_tokens=total_tokens,
157
+ input_cost=costs["input_cost"],
158
+ output_cost=costs["output_cost"],
159
+ cache_read_cost=costs["cache_read_cost"],
160
+ cache_creation_cost=costs["cache_creation_cost"],
161
+ total_cost=costs["total_cost"],
162
+ finish_reason=finish_reason,
163
+ response_preview=response_content[:500] if response_content else None,
164
+ tools_called_count=len(ctx.tools_in_turn),
165
+ tools_called_names=list(set(ctx.tools_in_turn)), # Unique tool names
166
+ error_message=error_message,
167
+ )
168
+
169
+ # Submit async (fire-and-forget)
170
+ self._submit_async(turn, "turn")
171
+
172
+ # Reset turn context
173
+ ctx.turn_start_time = None
174
+ ctx.tools_in_turn = []
175
+
176
+ def record_turn_from_agno(
177
+ self,
178
+ ctx: ExecutionContext,
179
+ result: Any,
180
+ model: str,
181
+ finish_reason: str = "stop",
182
+ error_message: Optional[str] = None,
183
+ ):
184
+ """
185
+ Record turn metrics from Agno/phidata result.
186
+
187
+ Agno results have `metrics` attribute with token counts.
188
+
189
+ Args:
190
+ ctx: Execution context
191
+ result: Agno RunResponse
192
+ model: Model identifier
193
+ finish_reason: Why the turn finished
194
+ error_message: Error if turn failed
195
+ """
196
+ if not ctx.turn_start_time:
197
+ logger.warning("record_turn_called_without_start", execution_id=ctx.execution_id[:8])
198
+ return
199
+
200
+ turn_end_time = time.time()
201
+ duration_ms = int((turn_end_time - ctx.turn_start_time) * 1000)
202
+
203
+ # Extract usage from Agno result
204
+ metrics = getattr(result, "metrics", None)
205
+ if not metrics:
206
+ logger.warning("agno_result_missing_metrics", execution_id=ctx.execution_id[:8])
207
+ return
208
+
209
+ input_tokens = getattr(metrics, "input_tokens", 0)
210
+ output_tokens = getattr(metrics, "output_tokens", 0)
211
+ total_tokens = getattr(metrics, "total_tokens", 0)
212
+
213
+ # Anthropic-specific: Agno exposes cache tokens
214
+ cache_read_tokens = getattr(metrics, "input_token_details", {}).get("cache_read", 0) if hasattr(metrics, "input_token_details") else 0
215
+ cache_creation_tokens = getattr(metrics, "input_token_details", {}).get("cache_creation", 0) if hasattr(metrics, "input_token_details") else 0
216
+
217
+ # Extract response content
218
+ response_content = getattr(result, "content", "")
219
+
220
+ # Calculate costs
221
+ costs = AnalyticsService.calculate_token_cost(
222
+ input_tokens=input_tokens,
223
+ output_tokens=output_tokens,
224
+ cache_read_tokens=cache_read_tokens,
225
+ cache_creation_tokens=cache_creation_tokens,
226
+ model=model,
227
+ )
228
+
229
+ # Determine model provider
230
+ model_provider = self._extract_provider(model)
231
+
232
+ turn = TurnMetrics(
233
+ execution_id=ctx.execution_id,
234
+ turn_number=ctx.turn_number,
235
+ model=model,
236
+ model_provider=model_provider,
237
+ started_at=datetime.fromtimestamp(ctx.turn_start_time, timezone.utc).isoformat(),
238
+ completed_at=datetime.fromtimestamp(turn_end_time, timezone.utc).isoformat(),
239
+ duration_ms=duration_ms,
240
+ input_tokens=input_tokens,
241
+ output_tokens=output_tokens,
242
+ cache_read_tokens=cache_read_tokens,
243
+ cache_creation_tokens=cache_creation_tokens,
244
+ total_tokens=total_tokens,
245
+ input_cost=costs["input_cost"],
246
+ output_cost=costs["output_cost"],
247
+ cache_read_cost=costs["cache_read_cost"],
248
+ cache_creation_cost=costs["cache_creation_cost"],
249
+ total_cost=costs["total_cost"],
250
+ finish_reason=finish_reason,
251
+ response_preview=response_content[:500] if response_content else None,
252
+ tools_called_count=len(ctx.tools_in_turn),
253
+ tools_called_names=list(set(ctx.tools_in_turn)),
254
+ error_message=error_message,
255
+ )
256
+
257
+ # Submit async (fire-and-forget)
258
+ self._submit_async(turn, "turn")
259
+
260
+ # Reset turn context
261
+ ctx.turn_start_time = None
262
+ ctx.tools_in_turn = []
263
+
264
+ def record_tool_call(
265
+ self,
266
+ ctx: ExecutionContext,
267
+ tool_name: str,
268
+ tool_input: Optional[Dict[str, Any]],
269
+ tool_output: Optional[str],
270
+ start_time: float,
271
+ end_time: float,
272
+ success: bool = True,
273
+ error_message: Optional[str] = None,
274
+ error_type: Optional[str] = None,
275
+ ):
276
+ """
277
+ Record a tool call.
278
+
279
+ Args:
280
+ ctx: Execution context
281
+ tool_name: Name of the tool
282
+ tool_input: Tool parameters
283
+ tool_output: Tool result
284
+ start_time: Start timestamp
285
+ end_time: End timestamp
286
+ success: Whether tool call succeeded
287
+ error_message: Error if failed
288
+ error_type: Type of error
289
+ """
290
+ # Track tool in current turn
291
+ if ctx.tools_in_turn is not None:
292
+ ctx.tools_in_turn.append(tool_name)
293
+
294
+ duration_ms = int((end_time - start_time) * 1000)
295
+
296
+ tool_call = ToolCallMetrics(
297
+ execution_id=ctx.execution_id,
298
+ turn_id=ctx.current_turn_id,
299
+ tool_name=tool_name,
300
+ started_at=datetime.fromtimestamp(start_time, timezone.utc).isoformat(),
301
+ completed_at=datetime.fromtimestamp(end_time, timezone.utc).isoformat(),
302
+ duration_ms=duration_ms,
303
+ tool_input=tool_input,
304
+ tool_output=tool_output,
305
+ tool_output_size=len(tool_output) if tool_output else 0,
306
+ success=success,
307
+ error_message=error_message,
308
+ error_type=error_type,
309
+ )
310
+
311
+ # Submit async (fire-and-forget)
312
+ self._submit_async(tool_call, "tool_call")
313
+
314
+ def record_task(
315
+ self,
316
+ ctx: ExecutionContext,
317
+ task_description: str,
318
+ task_number: Optional[int] = None,
319
+ task_type: Optional[str] = None,
320
+ status: str = "pending",
321
+ started_at: Optional[str] = None,
322
+ ) -> str:
323
+ """
324
+ Record a task creation.
325
+
326
+ Args:
327
+ ctx: Execution context
328
+ task_description: Task description
329
+ task_number: Sequential task number
330
+ task_type: Type of task
331
+ status: Initial status
332
+ started_at: Start timestamp
333
+
334
+ Returns:
335
+ Task ID for later updates
336
+ """
337
+ task = TaskMetrics(
338
+ execution_id=ctx.execution_id,
339
+ task_number=task_number,
340
+ task_description=task_description,
341
+ task_type=task_type,
342
+ status=status,
343
+ started_at=started_at or datetime.now(timezone.utc).isoformat(),
344
+ )
345
+
346
+ # Submit async (fire-and-forget)
347
+ self._submit_async(task, "task")
348
+
349
+ # Return a synthetic task ID (in practice, you'd get this from the API response)
350
+ return f"{ctx.execution_id}:{task_number or 0}"
351
+
352
+ def _submit_async(self, metric: Any, metric_type: str):
353
+ """
354
+ Submit metric asynchronously without blocking execution.
355
+
356
+ This uses fire-and-forget pattern - failures are logged but don't
357
+ affect the execution flow.
358
+
359
+ Args:
360
+ metric: Metric data (TurnMetrics, ToolCallMetrics, or TaskMetrics)
361
+ metric_type: Type of metric for logging
362
+ """
363
+ async def submit():
364
+ try:
365
+ if isinstance(metric, TurnMetrics):
366
+ await self.analytics.record_turn(metric)
367
+ elif isinstance(metric, ToolCallMetrics):
368
+ await self.analytics.record_tool_call(metric)
369
+ elif isinstance(metric, TaskMetrics):
370
+ await self.analytics.record_task(metric)
371
+ else:
372
+ logger.error("unknown_metric_type", type=type(metric).__name__)
373
+ except Exception as e:
374
+ # Log error but don't re-raise - analytics failures shouldn't break execution
375
+ logger.error(
376
+ "analytics_submission_failed",
377
+ metric_type=metric_type,
378
+ error=str(e),
379
+ execution_id=getattr(metric, "execution_id", "unknown")[:8],
380
+ )
381
+
382
+ # Create task and track it (for cleanup)
383
+ task = asyncio.create_task(submit())
384
+ self._submission_tasks.append(task)
385
+
386
+ # Clean up completed tasks
387
+ self._submission_tasks = [t for t in self._submission_tasks if not t.done()]
388
+
389
+ async def wait_for_submissions(self, timeout: float = 5.0):
390
+ """
391
+ Wait for all pending analytics submissions to complete.
392
+
393
+ Call this at the end of execution to ensure all analytics are submitted
394
+ before the worker shuts down.
395
+
396
+ Args:
397
+ timeout: Maximum time to wait in seconds
398
+ """
399
+ if not self._submission_tasks:
400
+ return
401
+
402
+ try:
403
+ await asyncio.wait_for(
404
+ asyncio.gather(*self._submission_tasks, return_exceptions=True),
405
+ timeout=timeout
406
+ )
407
+ logger.info(
408
+ "analytics_submissions_completed",
409
+ count=len(self._submission_tasks)
410
+ )
411
+ except asyncio.TimeoutError:
412
+ logger.warning(
413
+ "analytics_submissions_timeout",
414
+ pending=len([t for t in self._submission_tasks if not t.done()])
415
+ )
416
+ except Exception as e:
417
+ logger.error("analytics_wait_error", error=str(e))
418
+ finally:
419
+ self._submission_tasks.clear()
420
+
421
+ def _extract_provider(self, model: str) -> str:
422
+ """
423
+ Extract provider from model identifier.
424
+
425
+ Args:
426
+ model: Model string like "claude-sonnet-4", "gpt-4", "openai/gpt-4"
427
+
428
+ Returns:
429
+ Provider name
430
+ """
431
+ model_lower = model.lower()
432
+
433
+ if "claude" in model_lower or "anthropic" in model_lower:
434
+ return "anthropic"
435
+ elif "gpt" in model_lower or "openai" in model_lower:
436
+ return "openai"
437
+ elif "gemini" in model_lower or "google" in model_lower:
438
+ return "google"
439
+ elif "llama" in model_lower or "meta" in model_lower:
440
+ return "meta"
441
+ else:
442
+ return "unknown"
443
+
444
+
445
+ def create_analytics_collector(control_plane_url: str, api_key: str) -> AnalyticsCollector:
446
+ """
447
+ Create an analytics collector instance.
448
+
449
+ Args:
450
+ control_plane_url: Control Plane API URL
451
+ api_key: Kubiya API key
452
+
453
+ Returns:
454
+ Analytics collector instance
455
+ """
456
+ analytics_service = AnalyticsService(control_plane_url, api_key)
457
+ return AnalyticsCollector(analytics_service)