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.
- genai_otel/__init__.py +132 -0
- genai_otel/__version__.py +34 -0
- genai_otel/auto_instrument.py +602 -0
- genai_otel/cli.py +92 -0
- genai_otel/config.py +333 -0
- genai_otel/cost_calculator.py +467 -0
- genai_otel/cost_enriching_exporter.py +207 -0
- genai_otel/cost_enrichment_processor.py +174 -0
- genai_otel/evaluation/__init__.py +76 -0
- genai_otel/evaluation/bias_detector.py +364 -0
- genai_otel/evaluation/config.py +261 -0
- genai_otel/evaluation/hallucination_detector.py +525 -0
- genai_otel/evaluation/pii_detector.py +356 -0
- genai_otel/evaluation/prompt_injection_detector.py +262 -0
- genai_otel/evaluation/restricted_topics_detector.py +316 -0
- genai_otel/evaluation/span_processor.py +962 -0
- genai_otel/evaluation/toxicity_detector.py +406 -0
- genai_otel/exceptions.py +17 -0
- genai_otel/gpu_metrics.py +516 -0
- genai_otel/instrumentors/__init__.py +71 -0
- genai_otel/instrumentors/anthropic_instrumentor.py +134 -0
- genai_otel/instrumentors/anyscale_instrumentor.py +27 -0
- genai_otel/instrumentors/autogen_instrumentor.py +394 -0
- genai_otel/instrumentors/aws_bedrock_instrumentor.py +94 -0
- genai_otel/instrumentors/azure_openai_instrumentor.py +69 -0
- genai_otel/instrumentors/base.py +919 -0
- genai_otel/instrumentors/bedrock_agents_instrumentor.py +398 -0
- genai_otel/instrumentors/cohere_instrumentor.py +140 -0
- genai_otel/instrumentors/crewai_instrumentor.py +311 -0
- genai_otel/instrumentors/dspy_instrumentor.py +661 -0
- genai_otel/instrumentors/google_ai_instrumentor.py +310 -0
- genai_otel/instrumentors/groq_instrumentor.py +106 -0
- genai_otel/instrumentors/guardrails_ai_instrumentor.py +510 -0
- genai_otel/instrumentors/haystack_instrumentor.py +503 -0
- genai_otel/instrumentors/huggingface_instrumentor.py +399 -0
- genai_otel/instrumentors/hyperbolic_instrumentor.py +236 -0
- genai_otel/instrumentors/instructor_instrumentor.py +425 -0
- genai_otel/instrumentors/langchain_instrumentor.py +340 -0
- genai_otel/instrumentors/langgraph_instrumentor.py +328 -0
- genai_otel/instrumentors/llamaindex_instrumentor.py +36 -0
- genai_otel/instrumentors/mistralai_instrumentor.py +315 -0
- genai_otel/instrumentors/ollama_instrumentor.py +197 -0
- genai_otel/instrumentors/ollama_server_metrics_poller.py +336 -0
- genai_otel/instrumentors/openai_agents_instrumentor.py +291 -0
- genai_otel/instrumentors/openai_instrumentor.py +260 -0
- genai_otel/instrumentors/pydantic_ai_instrumentor.py +362 -0
- genai_otel/instrumentors/replicate_instrumentor.py +87 -0
- genai_otel/instrumentors/sambanova_instrumentor.py +196 -0
- genai_otel/instrumentors/togetherai_instrumentor.py +146 -0
- genai_otel/instrumentors/vertexai_instrumentor.py +106 -0
- genai_otel/llm_pricing.json +1676 -0
- genai_otel/logging_config.py +45 -0
- genai_otel/mcp_instrumentors/__init__.py +14 -0
- genai_otel/mcp_instrumentors/api_instrumentor.py +144 -0
- genai_otel/mcp_instrumentors/base.py +105 -0
- genai_otel/mcp_instrumentors/database_instrumentor.py +336 -0
- genai_otel/mcp_instrumentors/kafka_instrumentor.py +31 -0
- genai_otel/mcp_instrumentors/manager.py +139 -0
- genai_otel/mcp_instrumentors/redis_instrumentor.py +31 -0
- genai_otel/mcp_instrumentors/vector_db_instrumentor.py +265 -0
- genai_otel/metrics.py +148 -0
- genai_otel/py.typed +2 -0
- genai_otel/server_metrics.py +197 -0
- genai_otel_instrument-0.1.24.dist-info/METADATA +1404 -0
- genai_otel_instrument-0.1.24.dist-info/RECORD +69 -0
- genai_otel_instrument-0.1.24.dist-info/WHEEL +5 -0
- genai_otel_instrument-0.1.24.dist-info/entry_points.txt +2 -0
- genai_otel_instrument-0.1.24.dist-info/licenses/LICENSE +680 -0
- 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
|