genai-otel-instrument 0.1.24__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 (69) hide show
  1. genai_otel/__init__.py +132 -0
  2. genai_otel/__version__.py +34 -0
  3. genai_otel/auto_instrument.py +602 -0
  4. genai_otel/cli.py +92 -0
  5. genai_otel/config.py +333 -0
  6. genai_otel/cost_calculator.py +467 -0
  7. genai_otel/cost_enriching_exporter.py +207 -0
  8. genai_otel/cost_enrichment_processor.py +174 -0
  9. genai_otel/evaluation/__init__.py +76 -0
  10. genai_otel/evaluation/bias_detector.py +364 -0
  11. genai_otel/evaluation/config.py +261 -0
  12. genai_otel/evaluation/hallucination_detector.py +525 -0
  13. genai_otel/evaluation/pii_detector.py +356 -0
  14. genai_otel/evaluation/prompt_injection_detector.py +262 -0
  15. genai_otel/evaluation/restricted_topics_detector.py +316 -0
  16. genai_otel/evaluation/span_processor.py +962 -0
  17. genai_otel/evaluation/toxicity_detector.py +406 -0
  18. genai_otel/exceptions.py +17 -0
  19. genai_otel/gpu_metrics.py +516 -0
  20. genai_otel/instrumentors/__init__.py +71 -0
  21. genai_otel/instrumentors/anthropic_instrumentor.py +134 -0
  22. genai_otel/instrumentors/anyscale_instrumentor.py +27 -0
  23. genai_otel/instrumentors/autogen_instrumentor.py +394 -0
  24. genai_otel/instrumentors/aws_bedrock_instrumentor.py +94 -0
  25. genai_otel/instrumentors/azure_openai_instrumentor.py +69 -0
  26. genai_otel/instrumentors/base.py +919 -0
  27. genai_otel/instrumentors/bedrock_agents_instrumentor.py +398 -0
  28. genai_otel/instrumentors/cohere_instrumentor.py +140 -0
  29. genai_otel/instrumentors/crewai_instrumentor.py +311 -0
  30. genai_otel/instrumentors/dspy_instrumentor.py +661 -0
  31. genai_otel/instrumentors/google_ai_instrumentor.py +310 -0
  32. genai_otel/instrumentors/groq_instrumentor.py +106 -0
  33. genai_otel/instrumentors/guardrails_ai_instrumentor.py +510 -0
  34. genai_otel/instrumentors/haystack_instrumentor.py +503 -0
  35. genai_otel/instrumentors/huggingface_instrumentor.py +399 -0
  36. genai_otel/instrumentors/hyperbolic_instrumentor.py +236 -0
  37. genai_otel/instrumentors/instructor_instrumentor.py +425 -0
  38. genai_otel/instrumentors/langchain_instrumentor.py +340 -0
  39. genai_otel/instrumentors/langgraph_instrumentor.py +328 -0
  40. genai_otel/instrumentors/llamaindex_instrumentor.py +36 -0
  41. genai_otel/instrumentors/mistralai_instrumentor.py +315 -0
  42. genai_otel/instrumentors/ollama_instrumentor.py +197 -0
  43. genai_otel/instrumentors/ollama_server_metrics_poller.py +336 -0
  44. genai_otel/instrumentors/openai_agents_instrumentor.py +291 -0
  45. genai_otel/instrumentors/openai_instrumentor.py +260 -0
  46. genai_otel/instrumentors/pydantic_ai_instrumentor.py +362 -0
  47. genai_otel/instrumentors/replicate_instrumentor.py +87 -0
  48. genai_otel/instrumentors/sambanova_instrumentor.py +196 -0
  49. genai_otel/instrumentors/togetherai_instrumentor.py +146 -0
  50. genai_otel/instrumentors/vertexai_instrumentor.py +106 -0
  51. genai_otel/llm_pricing.json +1676 -0
  52. genai_otel/logging_config.py +45 -0
  53. genai_otel/mcp_instrumentors/__init__.py +14 -0
  54. genai_otel/mcp_instrumentors/api_instrumentor.py +144 -0
  55. genai_otel/mcp_instrumentors/base.py +105 -0
  56. genai_otel/mcp_instrumentors/database_instrumentor.py +336 -0
  57. genai_otel/mcp_instrumentors/kafka_instrumentor.py +31 -0
  58. genai_otel/mcp_instrumentors/manager.py +139 -0
  59. genai_otel/mcp_instrumentors/redis_instrumentor.py +31 -0
  60. genai_otel/mcp_instrumentors/vector_db_instrumentor.py +265 -0
  61. genai_otel/metrics.py +148 -0
  62. genai_otel/py.typed +2 -0
  63. genai_otel/server_metrics.py +197 -0
  64. genai_otel_instrument-0.1.24.dist-info/METADATA +1404 -0
  65. genai_otel_instrument-0.1.24.dist-info/RECORD +69 -0
  66. genai_otel_instrument-0.1.24.dist-info/WHEEL +5 -0
  67. genai_otel_instrument-0.1.24.dist-info/entry_points.txt +2 -0
  68. genai_otel_instrument-0.1.24.dist-info/licenses/LICENSE +680 -0
  69. genai_otel_instrument-0.1.24.dist-info/top_level.txt +1 -0
@@ -0,0 +1,260 @@
1
+ """OpenTelemetry instrumentor for the OpenAI Python SDK.
2
+
3
+ This instrumentor automatically traces chat completion calls made using the
4
+ OpenAI SDK, capturing relevant attributes such as the model name, message count,
5
+ and token usage.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from typing import Any, Dict, Optional
11
+
12
+ from ..config import OTelConfig
13
+ from .base import BaseInstrumentor
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class OpenAIInstrumentor(BaseInstrumentor):
19
+ """Instrumentor for OpenAI SDK"""
20
+
21
+ def __init__(self):
22
+ """Initialize the instrumentor."""
23
+ super().__init__()
24
+ self._openai_available = False
25
+ self._check_availability()
26
+
27
+ def _check_availability(self):
28
+ """Check if OpenAI library is available."""
29
+ try:
30
+ import openai
31
+
32
+ self._openai_available = True
33
+ logger.debug("OpenAI library detected and available for instrumentation")
34
+ except ImportError:
35
+ logger.debug("OpenAI library not installed, instrumentation will be skipped")
36
+ self._openai_available = False
37
+
38
+ def instrument(self, config: OTelConfig):
39
+ """Instrument OpenAI SDK if available.
40
+
41
+ Args:
42
+ config (OTelConfig): The OpenTelemetry configuration object.
43
+ """
44
+ if not self._openai_available:
45
+ logger.debug("Skipping OpenAI instrumentation - library not available")
46
+ return
47
+
48
+ self.config = config
49
+
50
+ try:
51
+ import openai
52
+ import wrapt
53
+
54
+ # Instrument OpenAI client initialization
55
+ if hasattr(openai, "OpenAI"):
56
+ original_init = openai.OpenAI.__init__
57
+
58
+ def wrapped_init(wrapped, instance, args, kwargs):
59
+ result = wrapped(*args, **kwargs)
60
+ self._instrument_client(instance)
61
+ return result
62
+
63
+ openai.OpenAI.__init__ = wrapt.FunctionWrapper(original_init, wrapped_init)
64
+ self._instrumented = True
65
+ logger.info("OpenAI instrumentation enabled")
66
+
67
+ except Exception as e:
68
+ logger.error("Failed to instrument OpenAI: %s", e, exc_info=True)
69
+ if config.fail_on_error:
70
+ raise
71
+
72
+ def _instrument_client(self, client):
73
+ """Instrument OpenAI client methods.
74
+
75
+ Args:
76
+ client: The OpenAI client instance to instrument.
77
+ """
78
+ if (
79
+ hasattr(client, "chat")
80
+ and hasattr(client.chat, "completions")
81
+ and hasattr(client.chat.completions, "create")
82
+ ):
83
+ original_create = client.chat.completions.create
84
+ instrumented_create_method = self.create_span_wrapper(
85
+ span_name="openai.chat.completion",
86
+ extract_attributes=self._extract_openai_attributes,
87
+ )(original_create)
88
+ client.chat.completions.create = instrumented_create_method
89
+
90
+ def _extract_openai_attributes(self, instance: Any, args: Any, kwargs: Any) -> Dict[str, Any]:
91
+ """Extract attributes from OpenAI API call.
92
+
93
+ Args:
94
+ instance: The client instance.
95
+ args: Positional arguments.
96
+ kwargs: Keyword arguments.
97
+
98
+ Returns:
99
+ Dict[str, Any]: Dictionary of attributes to set on the span.
100
+ """
101
+ attrs = {}
102
+ model = kwargs.get("model", "unknown")
103
+ messages = kwargs.get("messages", [])
104
+
105
+ # Core attributes
106
+ attrs["gen_ai.system"] = "openai"
107
+ attrs["gen_ai.request.model"] = model
108
+ attrs["gen_ai.operation.name"] = "chat" # NEW: operation name
109
+ attrs["gen_ai.request.message_count"] = len(messages)
110
+
111
+ # Request parameters (NEW)
112
+ if "temperature" in kwargs:
113
+ attrs["gen_ai.request.temperature"] = kwargs["temperature"]
114
+ if "top_p" in kwargs:
115
+ attrs["gen_ai.request.top_p"] = kwargs["top_p"]
116
+ if "max_tokens" in kwargs:
117
+ attrs["gen_ai.request.max_tokens"] = kwargs["max_tokens"]
118
+ if "frequency_penalty" in kwargs:
119
+ attrs["gen_ai.request.frequency_penalty"] = kwargs["frequency_penalty"]
120
+ if "presence_penalty" in kwargs:
121
+ attrs["gen_ai.request.presence_penalty"] = kwargs["presence_penalty"]
122
+
123
+ # Tool/function definitions (Phase 3.1)
124
+ if "tools" in kwargs:
125
+ try:
126
+ attrs["llm.tools"] = json.dumps(kwargs["tools"])
127
+ except (TypeError, ValueError) as e:
128
+ logger.debug("Failed to serialize tools: %s", e)
129
+
130
+ if messages:
131
+ # Only capture first 200 chars to avoid sensitive data and span size issues
132
+ first_message = str(messages[0])[:200]
133
+ attrs["gen_ai.request.first_message"] = first_message
134
+
135
+ return attrs
136
+
137
+ def _extract_usage(self, result) -> Optional[Dict[str, int]]:
138
+ """Extract token usage from OpenAI response.
139
+
140
+ Args:
141
+ result: The API response object.
142
+
143
+ Returns:
144
+ Optional[Dict[str, int]]: Dictionary with token counts or None.
145
+ """
146
+ if hasattr(result, "usage") and result.usage:
147
+ usage = result.usage
148
+ usage_dict = {
149
+ "prompt_tokens": getattr(usage, "prompt_tokens", 0),
150
+ "completion_tokens": getattr(usage, "completion_tokens", 0),
151
+ "total_tokens": getattr(usage, "total_tokens", 0),
152
+ }
153
+
154
+ # Extract reasoning tokens for o1 models (Phase 3.2)
155
+ if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details:
156
+ details = usage.completion_tokens_details
157
+ usage_dict["completion_tokens_details"] = {
158
+ "reasoning_tokens": getattr(details, "reasoning_tokens", 0)
159
+ }
160
+
161
+ return usage_dict
162
+ return None
163
+
164
+ def _extract_response_attributes(self, result) -> Dict[str, Any]:
165
+ """Extract response attributes from OpenAI response.
166
+
167
+ Args:
168
+ result: The API response object.
169
+
170
+ Returns:
171
+ Dict[str, Any]: Dictionary of response attributes.
172
+ """
173
+ attrs = {}
174
+
175
+ # Response ID
176
+ if hasattr(result, "id"):
177
+ attrs["gen_ai.response.id"] = result.id
178
+
179
+ # Response model (actual model used, may differ from request)
180
+ if hasattr(result, "model"):
181
+ attrs["gen_ai.response.model"] = result.model
182
+
183
+ # Finish reasons
184
+ if hasattr(result, "choices") and result.choices:
185
+ finish_reasons = [
186
+ choice.finish_reason
187
+ for choice in result.choices
188
+ if hasattr(choice, "finish_reason")
189
+ ]
190
+ if finish_reasons:
191
+ attrs["gen_ai.response.finish_reasons"] = finish_reasons
192
+
193
+ # Tool calls extraction (Phase 3.1)
194
+ for choice_idx, choice in enumerate(result.choices):
195
+ message = getattr(choice, "message", None)
196
+ if message and hasattr(message, "tool_calls") and message.tool_calls:
197
+ for tc_idx, tool_call in enumerate(message.tool_calls):
198
+ prefix = f"llm.output_messages.{choice_idx}.message.tool_calls.{tc_idx}"
199
+ if hasattr(tool_call, "id"):
200
+ attrs[f"{prefix}.tool_call.id"] = tool_call.id
201
+ if hasattr(tool_call, "function"):
202
+ if hasattr(tool_call.function, "name"):
203
+ attrs[f"{prefix}.tool_call.function.name"] = tool_call.function.name
204
+ if hasattr(tool_call.function, "arguments"):
205
+ attrs[f"{prefix}.tool_call.function.arguments"] = (
206
+ tool_call.function.arguments
207
+ )
208
+
209
+ return attrs
210
+
211
+ def _add_content_events(self, span, result, request_kwargs: dict):
212
+ """Add prompt and completion content as span events.
213
+
214
+ Args:
215
+ span: The OpenTelemetry span.
216
+ result: The API response object.
217
+ request_kwargs: The original request kwargs.
218
+ """
219
+ # Add prompt content events
220
+ messages = request_kwargs.get("messages", [])
221
+ for idx, message in enumerate(messages):
222
+ if isinstance(message, dict):
223
+ role = message.get("role", "unknown")
224
+ content = message.get("content", "")
225
+ span.add_event(
226
+ f"gen_ai.prompt.{idx}",
227
+ attributes={"gen_ai.prompt.role": role, "gen_ai.prompt.content": str(content)},
228
+ )
229
+
230
+ # Add completion content events
231
+ if hasattr(result, "choices") and result.choices:
232
+ for idx, choice in enumerate(result.choices):
233
+ if hasattr(choice, "message") and hasattr(choice.message, "content"):
234
+ content = choice.message.content
235
+ span.add_event(
236
+ f"gen_ai.completion.{idx}",
237
+ attributes={
238
+ "gen_ai.completion.role": "assistant",
239
+ "gen_ai.completion.content": str(content),
240
+ },
241
+ )
242
+
243
+ def _extract_finish_reason(self, result) -> Optional[str]:
244
+ """Extract finish reason from OpenAI response.
245
+
246
+ Args:
247
+ result: The OpenAI API response object.
248
+
249
+ Returns:
250
+ Optional[str]: The finish reason string or None if not available.
251
+ """
252
+ try:
253
+ if hasattr(result, "choices") and result.choices:
254
+ # Get the first finish_reason from the first choice
255
+ first_choice = result.choices[0]
256
+ if hasattr(first_choice, "finish_reason"):
257
+ return first_choice.finish_reason
258
+ except Exception as e:
259
+ logger.debug("Failed to extract finish_reason: %s", e)
260
+ return None
@@ -0,0 +1,362 @@
1
+ """OpenTelemetry instrumentor for Pydantic AI framework.
2
+
3
+ This instrumentor automatically traces agent execution, tool calls, and model
4
+ interactions using the Pydantic AI type-safe agent framework.
5
+
6
+ Pydantic AI is a new framework (Dec 2024) by the Pydantic team that provides
7
+ type-safe agent development with full Pydantic validation and multi-provider support.
8
+
9
+ Requirements:
10
+ pip install pydantic-ai
11
+ """
12
+
13
+ import json
14
+ import logging
15
+ from typing import Any, Dict, Optional
16
+
17
+ from ..config import OTelConfig
18
+ from .base import BaseInstrumentor
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class PydanticAIInstrumentor(BaseInstrumentor):
24
+ """Instrumentor for Pydantic AI agent framework"""
25
+
26
+ def __init__(self):
27
+ """Initialize the instrumentor."""
28
+ super().__init__()
29
+ self._pydantic_ai_available = False
30
+ self._check_availability()
31
+
32
+ def _check_availability(self):
33
+ """Check if Pydantic AI library is available."""
34
+ try:
35
+ import pydantic_ai
36
+
37
+ self._pydantic_ai_available = True
38
+ logger.debug("Pydantic AI library detected and available for instrumentation")
39
+ except ImportError:
40
+ logger.debug("Pydantic AI library not installed, instrumentation will be skipped")
41
+ self._pydantic_ai_available = False
42
+
43
+ def instrument(self, config: OTelConfig):
44
+ """Instrument Pydantic AI framework if available.
45
+
46
+ Args:
47
+ config (OTelConfig): The OpenTelemetry configuration object.
48
+ """
49
+ if not self._pydantic_ai_available:
50
+ logger.debug("Skipping Pydantic AI instrumentation - library not available")
51
+ return
52
+
53
+ self.config = config
54
+
55
+ try:
56
+ import wrapt
57
+ from pydantic_ai import Agent
58
+
59
+ # Instrument Agent.run (synchronous execution)
60
+ if hasattr(Agent, "run"):
61
+ original_run = Agent.run
62
+ Agent.run = wrapt.FunctionWrapper(original_run, self._wrap_agent_run)
63
+
64
+ # Instrument Agent.run_sync (explicit synchronous execution)
65
+ if hasattr(Agent, "run_sync"):
66
+ original_run_sync = Agent.run_sync
67
+ Agent.run_sync = wrapt.FunctionWrapper(original_run_sync, self._wrap_agent_run_sync)
68
+
69
+ # Instrument Agent.run_stream (streaming execution)
70
+ if hasattr(Agent, "run_stream"):
71
+ original_run_stream = Agent.run_stream
72
+ Agent.run_stream = wrapt.FunctionWrapper(
73
+ original_run_stream, self._wrap_agent_run_stream
74
+ )
75
+
76
+ self._instrumented = True
77
+ logger.info("Pydantic AI instrumentation enabled")
78
+
79
+ except Exception as e:
80
+ logger.error("Failed to instrument Pydantic AI: %s", e, exc_info=True)
81
+ if config.fail_on_error:
82
+ raise
83
+
84
+ def _wrap_agent_run(self, wrapped, instance, args, kwargs):
85
+ """Wrap Agent.run method with span.
86
+
87
+ Args:
88
+ wrapped: The original method.
89
+ instance: The Agent instance.
90
+ args: Positional arguments.
91
+ kwargs: Keyword arguments.
92
+ """
93
+ return self.create_span_wrapper(
94
+ span_name="pydantic_ai.agent.run",
95
+ extract_attributes=self._extract_agent_attributes,
96
+ )(wrapped)(instance, *args, **kwargs)
97
+
98
+ def _wrap_agent_run_sync(self, wrapped, instance, args, kwargs):
99
+ """Wrap Agent.run_sync method with span.
100
+
101
+ Args:
102
+ wrapped: The original method.
103
+ instance: The Agent instance.
104
+ args: Positional arguments.
105
+ kwargs: Keyword arguments.
106
+ """
107
+ return self.create_span_wrapper(
108
+ span_name="pydantic_ai.agent.run_sync",
109
+ extract_attributes=self._extract_agent_attributes,
110
+ )(wrapped)(instance, *args, **kwargs)
111
+
112
+ def _wrap_agent_run_stream(self, wrapped, instance, args, kwargs):
113
+ """Wrap Agent.run_stream method with span.
114
+
115
+ Args:
116
+ wrapped: The original method.
117
+ instance: The Agent instance.
118
+ args: Positional arguments.
119
+ kwargs: Keyword arguments.
120
+ """
121
+ return self.create_span_wrapper(
122
+ span_name="pydantic_ai.agent.run_stream",
123
+ extract_attributes=self._extract_agent_attributes,
124
+ )(wrapped)(instance, *args, **kwargs)
125
+
126
+ def _extract_agent_attributes(self, instance: Any, args: Any, kwargs: Any) -> Dict[str, Any]:
127
+ """Extract attributes from Agent.run call.
128
+
129
+ Args:
130
+ instance: The Agent instance.
131
+ args: Positional arguments (user prompt, etc.).
132
+ kwargs: Keyword arguments.
133
+
134
+ Returns:
135
+ Dict[str, Any]: Dictionary of attributes to set on the span.
136
+ """
137
+ attrs = {}
138
+
139
+ # Core attributes
140
+ attrs["gen_ai.system"] = "pydantic_ai"
141
+ attrs["gen_ai.operation.name"] = "agent.run"
142
+
143
+ # Extract agent name if available
144
+ if hasattr(instance, "name") and instance.name:
145
+ attrs["pydantic_ai.agent.name"] = instance.name
146
+
147
+ # Extract model information
148
+ if hasattr(instance, "model") and instance.model:
149
+ model = instance.model
150
+ # Get model name/provider
151
+ if hasattr(model, "name"):
152
+ attrs["gen_ai.request.model"] = model.name
153
+ attrs["pydantic_ai.model.name"] = model.name
154
+ elif hasattr(model, "model_name"):
155
+ attrs["gen_ai.request.model"] = model.model_name
156
+ attrs["pydantic_ai.model.name"] = model.model_name
157
+
158
+ # Extract provider if available
159
+ if hasattr(model, "__class__"):
160
+ provider = model.__class__.__name__
161
+ attrs["pydantic_ai.model.provider"] = provider
162
+
163
+ # Extract system prompts
164
+ if hasattr(instance, "_system_prompts") and instance._system_prompts:
165
+ try:
166
+ # System prompts can be list of strings or functions
167
+ prompts = []
168
+ for prompt in instance._system_prompts:
169
+ if callable(prompt):
170
+ prompts.append("<function>")
171
+ else:
172
+ prompts.append(str(prompt)[:200]) # Truncate
173
+ attrs["pydantic_ai.system_prompts"] = prompts[:5] # Limit to 5
174
+ except Exception as e:
175
+ logger.debug("Failed to extract system prompts: %s", e)
176
+
177
+ # Extract tools/functions if available
178
+ if hasattr(instance, "_function_tools") and instance._function_tools:
179
+ try:
180
+ tool_names = []
181
+ for tool_name in instance._function_tools.keys():
182
+ tool_names.append(tool_name)
183
+ attrs["pydantic_ai.tools"] = tool_names[:10] # Limit to 10
184
+ attrs["pydantic_ai.tools.count"] = len(tool_names)
185
+ except Exception as e:
186
+ logger.debug("Failed to extract tools: %s", e)
187
+
188
+ # Extract result type if specified
189
+ if hasattr(instance, "_result_type"):
190
+ try:
191
+ result_type = instance._result_type
192
+ if result_type:
193
+ attrs["pydantic_ai.result_type"] = str(result_type)
194
+ except Exception as e:
195
+ logger.debug("Failed to extract result type: %s", e)
196
+
197
+ # Extract user prompt (first positional argument)
198
+ user_prompt = None
199
+ if len(args) > 0:
200
+ user_prompt = args[0]
201
+ elif "user_prompt" in kwargs:
202
+ user_prompt = kwargs["user_prompt"]
203
+ elif "prompt" in kwargs:
204
+ user_prompt = kwargs["prompt"]
205
+
206
+ if user_prompt:
207
+ if isinstance(user_prompt, str):
208
+ attrs["pydantic_ai.user_prompt"] = user_prompt[:500] # Truncate
209
+ else:
210
+ attrs["pydantic_ai.user_prompt"] = str(user_prompt)[:500]
211
+
212
+ # Extract message history if provided
213
+ if "message_history" in kwargs:
214
+ try:
215
+ history = kwargs["message_history"]
216
+ if history and hasattr(history, "__len__"):
217
+ attrs["pydantic_ai.message_history.count"] = len(history)
218
+ except Exception as e:
219
+ logger.debug("Failed to extract message history: %s", e)
220
+
221
+ # Extract model settings if provided
222
+ if "model_settings" in kwargs:
223
+ try:
224
+ settings = kwargs["model_settings"]
225
+ if isinstance(settings, dict):
226
+ if "temperature" in settings:
227
+ attrs["gen_ai.request.temperature"] = settings["temperature"]
228
+ if "max_tokens" in settings:
229
+ attrs["gen_ai.request.max_tokens"] = settings["max_tokens"]
230
+ if "top_p" in settings:
231
+ attrs["gen_ai.request.top_p"] = settings["top_p"]
232
+ except Exception as e:
233
+ logger.debug("Failed to extract model settings: %s", e)
234
+
235
+ return attrs
236
+
237
+ def _extract_usage(self, result) -> Optional[Dict[str, int]]:
238
+ """Extract token usage from agent result.
239
+
240
+ Args:
241
+ result: The agent execution result.
242
+
243
+ Returns:
244
+ Optional[Dict[str, int]]: Dictionary with token counts or None.
245
+ """
246
+ # Try to extract usage from result
247
+ try:
248
+ # Pydantic AI results typically have usage information
249
+ if hasattr(result, "usage") and result.usage:
250
+ usage = result.usage
251
+ return {
252
+ "prompt_tokens": getattr(usage, "request_tokens", 0),
253
+ "completion_tokens": getattr(usage, "response_tokens", 0),
254
+ "total_tokens": getattr(usage, "total_tokens", 0),
255
+ }
256
+
257
+ # Try alternative attribute names
258
+ if hasattr(result, "_usage"):
259
+ usage = result._usage
260
+ return {
261
+ "prompt_tokens": getattr(usage, "request_tokens", 0),
262
+ "completion_tokens": getattr(usage, "response_tokens", 0),
263
+ "total_tokens": getattr(usage, "total_tokens", 0),
264
+ }
265
+
266
+ except Exception as e:
267
+ logger.debug("Failed to extract usage: %s", e)
268
+
269
+ # Token usage is also captured by underlying provider instrumentors
270
+ return None
271
+
272
+ def _extract_response_attributes(self, result) -> Dict[str, Any]:
273
+ """Extract response attributes from agent result.
274
+
275
+ Args:
276
+ result: The agent execution result.
277
+
278
+ Returns:
279
+ Dict[str, Any]: Dictionary of response attributes.
280
+ """
281
+ attrs = {}
282
+
283
+ try:
284
+ # Extract result data
285
+ if hasattr(result, "data"):
286
+ try:
287
+ data = result.data
288
+ # If data is a Pydantic model, convert to dict
289
+ if hasattr(data, "model_dump"):
290
+ data_dict = data.model_dump()
291
+ attrs["pydantic_ai.result.data"] = str(data_dict)[:500]
292
+ else:
293
+ attrs["pydantic_ai.result.data"] = str(data)[:500]
294
+ except Exception as e:
295
+ logger.debug("Failed to extract result data: %s", e)
296
+
297
+ # Extract messages from result
298
+ if hasattr(result, "messages") and result.messages:
299
+ try:
300
+ attrs["pydantic_ai.result.messages.count"] = len(result.messages)
301
+
302
+ # Extract last message content
303
+ if result.messages:
304
+ last_msg = result.messages[-1]
305
+ if hasattr(last_msg, "content"):
306
+ attrs["pydantic_ai.result.last_message"] = str(last_msg.content)[:500]
307
+ if hasattr(last_msg, "role"):
308
+ attrs["pydantic_ai.result.last_role"] = last_msg.role
309
+ except Exception as e:
310
+ logger.debug("Failed to extract messages: %s", e)
311
+
312
+ # Extract timestamp if available
313
+ if hasattr(result, "timestamp"):
314
+ try:
315
+ attrs["pydantic_ai.result.timestamp"] = str(result.timestamp)
316
+ except Exception as e:
317
+ logger.debug("Failed to extract timestamp: %s", e)
318
+
319
+ # Extract cost if available
320
+ if hasattr(result, "cost") and result.cost:
321
+ try:
322
+ attrs["pydantic_ai.result.cost"] = float(result.cost)
323
+ except Exception as e:
324
+ logger.debug("Failed to extract cost: %s", e)
325
+
326
+ # Extract model name from result if available
327
+ if hasattr(result, "model"):
328
+ attrs["gen_ai.response.model"] = str(result.model)
329
+
330
+ except Exception as e:
331
+ logger.debug("Failed to extract response attributes: %s", e)
332
+
333
+ return attrs
334
+
335
+ def _extract_finish_reason(self, result) -> Optional[str]:
336
+ """Extract finish reason from agent result.
337
+
338
+ Args:
339
+ result: The agent execution result.
340
+
341
+ Returns:
342
+ Optional[str]: The finish reason string or None if not available.
343
+ """
344
+ try:
345
+ # Check if result has finish_reason
346
+ if hasattr(result, "finish_reason"):
347
+ return str(result.finish_reason)
348
+
349
+ # Check messages for finish reason
350
+ if hasattr(result, "messages") and result.messages:
351
+ last_msg = result.messages[-1]
352
+ if hasattr(last_msg, "finish_reason"):
353
+ return str(last_msg.finish_reason)
354
+
355
+ # If we have data, assume completion
356
+ if hasattr(result, "data"):
357
+ return "completed"
358
+
359
+ except Exception as e:
360
+ logger.debug("Failed to extract finish reason: %s", e)
361
+
362
+ return None