netra-sdk 0.1.24__py3-none-any.whl → 0.1.26__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 netra-sdk might be problematic. Click here for more details.
- netra/__init__.py +6 -0
- netra/instrumentation/__init__.py +20 -0
- netra/instrumentation/instruments.py +1 -0
- netra/instrumentation/pydantic_ai/__init__.py +200 -0
- netra/instrumentation/pydantic_ai/utils.py +385 -0
- netra/instrumentation/pydantic_ai/version.py +1 -0
- netra/instrumentation/pydantic_ai/wrappers.py +687 -0
- netra/version.py +1 -1
- {netra_sdk-0.1.24.dist-info → netra_sdk-0.1.26.dist-info}/METADATA +2 -1
- {netra_sdk-0.1.24.dist-info → netra_sdk-0.1.26.dist-info}/RECORD +12 -8
- {netra_sdk-0.1.24.dist-info → netra_sdk-0.1.26.dist-info}/LICENCE +0 -0
- {netra_sdk-0.1.24.dist-info → netra_sdk-0.1.26.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, AsyncIterator, Callable, Dict
|
|
4
|
+
|
|
5
|
+
from opentelemetry import context as context_api
|
|
6
|
+
from opentelemetry.semconv_ai import SpanAttributes
|
|
7
|
+
from opentelemetry.trace import SpanKind, Tracer
|
|
8
|
+
from opentelemetry.trace.status import Status, StatusCode
|
|
9
|
+
|
|
10
|
+
from netra.instrumentation.pydantic_ai.utils import (
|
|
11
|
+
MAX_ARGS_LENGTH,
|
|
12
|
+
MAX_CONTENT_LENGTH,
|
|
13
|
+
_handle_span_error,
|
|
14
|
+
_safe_get_attribute,
|
|
15
|
+
_safe_set_attribute,
|
|
16
|
+
_set_assistant_response_content,
|
|
17
|
+
_set_timing_attributes,
|
|
18
|
+
get_node_span_name,
|
|
19
|
+
set_node_attributes,
|
|
20
|
+
set_pydantic_request_attributes,
|
|
21
|
+
set_pydantic_response_attributes,
|
|
22
|
+
should_suppress_instrumentation,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InstrumentedAgentRun:
|
|
29
|
+
"""Wrapper for AgentRun that creates spans for each node iteration"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, agent_run: Any, tracer: Tracer, parent_span_name: str, parent_span: Any) -> None:
|
|
32
|
+
self._agent_run = agent_run
|
|
33
|
+
self._tracer = tracer
|
|
34
|
+
self._parent_span_name = parent_span_name
|
|
35
|
+
self._parent_span = parent_span # Keep reference to parent span
|
|
36
|
+
|
|
37
|
+
async def __aenter__(self) -> "InstrumentedAgentRun":
|
|
38
|
+
# Enter the original agent run context
|
|
39
|
+
await self._agent_run.__aenter__()
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> Any:
|
|
43
|
+
# Exit the original agent run context
|
|
44
|
+
return await self._agent_run.__aexit__(exc_type, exc_val, exc_tb)
|
|
45
|
+
|
|
46
|
+
def __aiter__(self) -> AsyncIterator[Any]:
|
|
47
|
+
return self._instrumented_iter()
|
|
48
|
+
|
|
49
|
+
async def _instrumented_iter(self) -> AsyncIterator[Any]:
|
|
50
|
+
"""Async iterator that creates spans for each node"""
|
|
51
|
+
async for node in self._agent_run:
|
|
52
|
+
# Create span for this node as child of parent span
|
|
53
|
+
span_name = get_node_span_name(node)
|
|
54
|
+
with self._tracer.start_as_current_span(
|
|
55
|
+
span_name,
|
|
56
|
+
kind=SpanKind.INTERNAL,
|
|
57
|
+
) as span:
|
|
58
|
+
try:
|
|
59
|
+
# Set node attributes
|
|
60
|
+
set_node_attributes(span, node)
|
|
61
|
+
|
|
62
|
+
# For End nodes, also set assistant message on parent span
|
|
63
|
+
if hasattr(node, "__class__") and "End" in node.__class__.__name__:
|
|
64
|
+
self._set_assistant_message_on_parent(node)
|
|
65
|
+
|
|
66
|
+
span.set_status(Status(StatusCode.OK))
|
|
67
|
+
yield node
|
|
68
|
+
except Exception as e:
|
|
69
|
+
_handle_span_error(span, e)
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
def _set_assistant_message_on_parent(self, node: Any) -> None:
|
|
73
|
+
"""Set assistant message from End node on the parent span"""
|
|
74
|
+
if not self._parent_span or not self._parent_span.is_recording():
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
# Extract the same data that _set_end_node_attributes uses
|
|
78
|
+
data = _safe_get_attribute(node, "data")
|
|
79
|
+
if not data:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
# Get the final output and set it on parent span
|
|
83
|
+
output = _safe_get_attribute(data, "output")
|
|
84
|
+
if output is not None:
|
|
85
|
+
_safe_set_attribute(self._parent_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.role", "assistant")
|
|
86
|
+
_safe_set_attribute(
|
|
87
|
+
self._parent_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.content", output, MAX_CONTENT_LENGTH
|
|
88
|
+
)
|
|
89
|
+
_safe_set_attribute(self._parent_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.finish_reason", "completed")
|
|
90
|
+
|
|
91
|
+
async def next(self, node: Any = None) -> Any:
|
|
92
|
+
"""Manual iteration with instrumentation"""
|
|
93
|
+
if hasattr(self._agent_run, "next"):
|
|
94
|
+
next_node = await self._agent_run.next(node)
|
|
95
|
+
# Create span for the returned node
|
|
96
|
+
if next_node:
|
|
97
|
+
span_name = get_node_span_name(next_node)
|
|
98
|
+
with self._tracer.start_as_current_span(
|
|
99
|
+
span_name,
|
|
100
|
+
kind=SpanKind.INTERNAL,
|
|
101
|
+
) as span:
|
|
102
|
+
set_node_attributes(span, next_node)
|
|
103
|
+
span.set_status(Status(StatusCode.OK))
|
|
104
|
+
return next_node
|
|
105
|
+
else:
|
|
106
|
+
raise AttributeError("AgentRun does not have a 'next' method")
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def result(self) -> Any:
|
|
110
|
+
"""Access to the final result"""
|
|
111
|
+
return getattr(self._agent_run, "result", None)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def ctx(self) -> Any:
|
|
115
|
+
"""Access to the context"""
|
|
116
|
+
return getattr(self._agent_run, "ctx", None)
|
|
117
|
+
|
|
118
|
+
def __getattr__(self, name: str) -> Any:
|
|
119
|
+
"""Delegate other attributes to the wrapped AgentRun"""
|
|
120
|
+
return getattr(self._agent_run, name)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def agent_run_wrapper(tracer: Tracer) -> Callable[..., Any]:
|
|
124
|
+
"""Wrapper for Agent.run method."""
|
|
125
|
+
|
|
126
|
+
def wrapper(wrapped: Callable, instance: Any, args: tuple, kwargs: dict) -> Any: # type: ignore[type-arg]
|
|
127
|
+
async def async_wrapper() -> Any:
|
|
128
|
+
if should_suppress_instrumentation():
|
|
129
|
+
return await wrapped(*args, **kwargs)
|
|
130
|
+
|
|
131
|
+
# Extract key parameters
|
|
132
|
+
user_prompt = args[0] if args else kwargs.get("user_prompt", "")
|
|
133
|
+
|
|
134
|
+
# Use start_as_current_span for async non-streaming operations
|
|
135
|
+
with tracer.start_as_current_span(
|
|
136
|
+
"pydantic_ai.agent.run", kind=SpanKind.CLIENT, attributes={"llm.request.type": "agent.run"}
|
|
137
|
+
) as span:
|
|
138
|
+
try:
|
|
139
|
+
# Set request attributes
|
|
140
|
+
set_pydantic_request_attributes(span, kwargs, "agent.run")
|
|
141
|
+
|
|
142
|
+
if user_prompt:
|
|
143
|
+
_safe_set_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.0.role", "user")
|
|
144
|
+
_safe_set_attribute(
|
|
145
|
+
span, f"{SpanAttributes.LLM_PROMPTS}.0.content", user_prompt, MAX_CONTENT_LENGTH
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Execute the original async method
|
|
149
|
+
start_time = time.time()
|
|
150
|
+
result = await wrapped(*args, **kwargs)
|
|
151
|
+
end_time = time.time()
|
|
152
|
+
|
|
153
|
+
# Set response attributes
|
|
154
|
+
_set_timing_attributes(span, start_time, end_time)
|
|
155
|
+
set_pydantic_response_attributes(span, result)
|
|
156
|
+
|
|
157
|
+
# Set assistant response content
|
|
158
|
+
_set_assistant_response_content(span, result, "completed")
|
|
159
|
+
|
|
160
|
+
span.set_status(Status(StatusCode.OK))
|
|
161
|
+
|
|
162
|
+
# Return instrumented AgentRun that will capture child nodes
|
|
163
|
+
return InstrumentedAgentRun(result, tracer, "pydantic_ai.agent.run", span)
|
|
164
|
+
|
|
165
|
+
except Exception as e:
|
|
166
|
+
_handle_span_error(span, e)
|
|
167
|
+
raise
|
|
168
|
+
|
|
169
|
+
return async_wrapper()
|
|
170
|
+
|
|
171
|
+
return wrapper
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def agent_run_sync_wrapper(tracer: Tracer) -> Callable[..., Any]:
|
|
175
|
+
"""Wrapper for Agent.run_sync method."""
|
|
176
|
+
|
|
177
|
+
def wrapper(wrapped: Callable, instance: Any, args: tuple, kwargs: dict) -> Any: # type: ignore[type-arg]
|
|
178
|
+
if should_suppress_instrumentation():
|
|
179
|
+
return wrapped(*args, **kwargs)
|
|
180
|
+
|
|
181
|
+
# Extract key parameters
|
|
182
|
+
user_prompt = args[0] if args else kwargs.get("user_prompt", "")
|
|
183
|
+
|
|
184
|
+
# Create parent span for the entire run_sync operation
|
|
185
|
+
with tracer.start_as_current_span(
|
|
186
|
+
"pydantic_ai.agent.run_sync",
|
|
187
|
+
kind=SpanKind.CLIENT,
|
|
188
|
+
) as span:
|
|
189
|
+
try:
|
|
190
|
+
# Set request attributes
|
|
191
|
+
set_pydantic_request_attributes(span, kwargs, "agent.run_sync")
|
|
192
|
+
|
|
193
|
+
if user_prompt:
|
|
194
|
+
_safe_set_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.0.role", "user")
|
|
195
|
+
_safe_set_attribute(
|
|
196
|
+
span, f"{SpanAttributes.LLM_PROMPTS}.0.content", user_prompt, MAX_CONTENT_LENGTH
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
start_time = time.time()
|
|
200
|
+
|
|
201
|
+
# Use iter method to capture all nodes, then get final result
|
|
202
|
+
import asyncio
|
|
203
|
+
|
|
204
|
+
async def _run_with_instrumentation() -> Any:
|
|
205
|
+
# Use the iter method directly without suppressing instrumentation
|
|
206
|
+
# This will create the proper span hierarchy: run_sync -> iter -> nodes
|
|
207
|
+
async with instance.iter(*args, **kwargs) as agent_run:
|
|
208
|
+
async for node in agent_run:
|
|
209
|
+
pass # Just iterate through to capture all nodes
|
|
210
|
+
return agent_run.result
|
|
211
|
+
|
|
212
|
+
# Run the async instrumentation in sync context
|
|
213
|
+
loop = asyncio.new_event_loop()
|
|
214
|
+
asyncio.set_event_loop(loop)
|
|
215
|
+
try:
|
|
216
|
+
result = loop.run_until_complete(_run_with_instrumentation())
|
|
217
|
+
finally:
|
|
218
|
+
loop.close()
|
|
219
|
+
|
|
220
|
+
end_time = time.time()
|
|
221
|
+
|
|
222
|
+
# Set response attributes
|
|
223
|
+
_set_timing_attributes(span, start_time, end_time)
|
|
224
|
+
set_pydantic_response_attributes(span, result)
|
|
225
|
+
|
|
226
|
+
# Set assistant response content in OpenAI wrapper format
|
|
227
|
+
_set_assistant_response_content(span, result, "completed")
|
|
228
|
+
|
|
229
|
+
span.set_status(Status(StatusCode.OK))
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
except Exception as e:
|
|
233
|
+
_handle_span_error(span, e)
|
|
234
|
+
raise
|
|
235
|
+
|
|
236
|
+
return wrapper
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class InstrumentedAgentRunContext:
|
|
240
|
+
"""Context manager that keeps the parent span active during iteration"""
|
|
241
|
+
|
|
242
|
+
def __init__(
|
|
243
|
+
self, agent_run: Any, tracer: Tracer, span: Any, user_prompt: str, model_name: str, kwargs: Dict[str, Any]
|
|
244
|
+
) -> None:
|
|
245
|
+
self._agent_run = agent_run
|
|
246
|
+
self._tracer = tracer
|
|
247
|
+
self._span = span
|
|
248
|
+
self._user_prompt = user_prompt
|
|
249
|
+
self._model_name = model_name
|
|
250
|
+
self._kwargs = kwargs
|
|
251
|
+
self._context_token = None
|
|
252
|
+
|
|
253
|
+
async def __aenter__(self) -> "InstrumentedAgentRunContext":
|
|
254
|
+
# Enter the original agent run context
|
|
255
|
+
result = await self._agent_run.__aenter__()
|
|
256
|
+
|
|
257
|
+
# Set the parent span as the current active span context using OpenTelemetry's trace context
|
|
258
|
+
# This ensures that child spans created by InstrumentedAgentRun will be children of this span
|
|
259
|
+
from opentelemetry import trace
|
|
260
|
+
|
|
261
|
+
span_context = trace.set_span_in_context(self._span)
|
|
262
|
+
self._context_token = context_api.attach(span_context)
|
|
263
|
+
|
|
264
|
+
# Set request attributes now that we're in the context
|
|
265
|
+
set_pydantic_request_attributes(self._span, self._kwargs, "agent.iter")
|
|
266
|
+
|
|
267
|
+
if self._user_prompt:
|
|
268
|
+
_safe_set_attribute(self._span, f"{SpanAttributes.LLM_PROMPTS}.0.role", "user")
|
|
269
|
+
_safe_set_attribute(
|
|
270
|
+
self._span, f"{SpanAttributes.LLM_PROMPTS}.0.content", self._user_prompt, MAX_CONTENT_LENGTH
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return InstrumentedAgentRun(result, self._tracer, "pydantic_ai.agent.iter", self._span) # type: ignore[return-value]
|
|
274
|
+
|
|
275
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> Any:
|
|
276
|
+
try:
|
|
277
|
+
# Exit the original agent run context
|
|
278
|
+
result = await self._agent_run.__aexit__(exc_type, exc_val, exc_tb)
|
|
279
|
+
|
|
280
|
+
if exc_type is None:
|
|
281
|
+
_safe_set_attribute(self._span, f"{SpanAttributes.LLM_COMPLETIONS}.0.finish_reason", "streaming")
|
|
282
|
+
self._span.set_status(Status(StatusCode.OK))
|
|
283
|
+
else:
|
|
284
|
+
_handle_span_error(self._span, exc_val)
|
|
285
|
+
|
|
286
|
+
return result
|
|
287
|
+
finally:
|
|
288
|
+
# Detach the context token to restore previous context
|
|
289
|
+
if self._context_token is not None:
|
|
290
|
+
context_api.detach(self._context_token)
|
|
291
|
+
# End the parent span
|
|
292
|
+
self._span.end()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def agent_iter_wrapper(tracer: Tracer) -> Callable[..., Any]:
|
|
296
|
+
"""Wrapper for Agent.iter method."""
|
|
297
|
+
|
|
298
|
+
def wrapper(wrapped: Callable, instance: Any, args: tuple, kwargs: dict) -> Any: # type: ignore[type-arg]
|
|
299
|
+
if should_suppress_instrumentation():
|
|
300
|
+
return wrapped(*args, **kwargs)
|
|
301
|
+
|
|
302
|
+
# Extract key parameters
|
|
303
|
+
user_prompt = args[0] if args else kwargs.get("user_prompt", "")
|
|
304
|
+
model_name = kwargs.get("model", getattr(instance, "model", None))
|
|
305
|
+
|
|
306
|
+
# Execute the original method to get AgentRun
|
|
307
|
+
start_time = time.time()
|
|
308
|
+
agent_run = wrapped(*args, **kwargs)
|
|
309
|
+
end_time = time.time()
|
|
310
|
+
|
|
311
|
+
# Create parent span that will stay active during iteration
|
|
312
|
+
# Use start_span (not start_as_current_span) because we need to manage it manually
|
|
313
|
+
# The InstrumentedAgentRunContext will set it as current when needed
|
|
314
|
+
span = tracer.start_span(
|
|
315
|
+
"pydantic_ai.agent.iter",
|
|
316
|
+
kind=SpanKind.CLIENT,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Set initial timing
|
|
320
|
+
_set_timing_attributes(span, start_time, end_time)
|
|
321
|
+
|
|
322
|
+
# Return context manager that will manage the span lifecycle and hierarchy
|
|
323
|
+
return InstrumentedAgentRunContext(agent_run, tracer, span, user_prompt, model_name, kwargs) # type: ignore[arg-type]
|
|
324
|
+
|
|
325
|
+
return wrapper
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class InstrumentedAgentRunFromStream:
|
|
329
|
+
"""Wrapper for AgentRun from stream that creates spans for each node iteration"""
|
|
330
|
+
|
|
331
|
+
def __init__(self, agent_run: Any, tracer: Tracer, parent_span: Any) -> None:
|
|
332
|
+
self._agent_run = agent_run
|
|
333
|
+
self._tracer = tracer
|
|
334
|
+
self._parent_span = parent_span
|
|
335
|
+
|
|
336
|
+
def __aiter__(self) -> AsyncIterator[Any]:
|
|
337
|
+
return self._instrumented_iter()
|
|
338
|
+
|
|
339
|
+
async def _instrumented_iter(self) -> AsyncIterator[Any]:
|
|
340
|
+
"""Async iterator that creates spans for each node"""
|
|
341
|
+
try:
|
|
342
|
+
async for node in self._agent_run:
|
|
343
|
+
# Create span for this node as child of parent span
|
|
344
|
+
span_name = get_node_span_name(node)
|
|
345
|
+
|
|
346
|
+
# Set parent context explicitly to ensure proper parent-child relationship
|
|
347
|
+
parent_context = None
|
|
348
|
+
if self._parent_span:
|
|
349
|
+
parent_context = context_api.set_value("current_span", self._parent_span)
|
|
350
|
+
|
|
351
|
+
with self._tracer.start_as_current_span(
|
|
352
|
+
span_name,
|
|
353
|
+
kind=SpanKind.INTERNAL,
|
|
354
|
+
context=parent_context,
|
|
355
|
+
) as span:
|
|
356
|
+
try:
|
|
357
|
+
# Set node attributes
|
|
358
|
+
set_node_attributes(span, node)
|
|
359
|
+
|
|
360
|
+
# For End nodes, also set assistant message on both current span and parent span
|
|
361
|
+
if hasattr(node, "__class__") and "End" in node.__class__.__name__:
|
|
362
|
+
self._set_assistant_content_on_spans(span, node)
|
|
363
|
+
|
|
364
|
+
span.set_status(Status(StatusCode.OK))
|
|
365
|
+
|
|
366
|
+
# Yield the node to the user
|
|
367
|
+
yield node
|
|
368
|
+
except Exception as e:
|
|
369
|
+
_handle_span_error(span, e)
|
|
370
|
+
# Still yield the node even if span creation failed
|
|
371
|
+
yield node
|
|
372
|
+
except Exception as e:
|
|
373
|
+
# Handle iteration errors
|
|
374
|
+
logger.error(f"Error during node iteration: {e}")
|
|
375
|
+
raise
|
|
376
|
+
|
|
377
|
+
def _set_assistant_content_on_spans(self, current_span: Any, node: Any) -> None:
|
|
378
|
+
"""Set assistant message from End node on both current span and parent span"""
|
|
379
|
+
# Extract the same data that _set_end_node_attributes uses
|
|
380
|
+
data = _safe_get_attribute(node, "data")
|
|
381
|
+
if not data:
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
# Get the final output
|
|
385
|
+
output = _safe_get_attribute(data, "output")
|
|
386
|
+
if output is None:
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
# Set assistant content on current iter span
|
|
390
|
+
if current_span and current_span.is_recording():
|
|
391
|
+
_safe_set_attribute(current_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.role", "assistant")
|
|
392
|
+
_safe_set_attribute(current_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.content", output, MAX_CONTENT_LENGTH)
|
|
393
|
+
_safe_set_attribute(current_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.finish_reason", "completed")
|
|
394
|
+
|
|
395
|
+
# Set assistant content on parent run_stream span
|
|
396
|
+
if self._parent_span and self._parent_span.is_recording():
|
|
397
|
+
_safe_set_attribute(self._parent_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.role", "assistant")
|
|
398
|
+
_safe_set_attribute(
|
|
399
|
+
self._parent_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.content", output, MAX_CONTENT_LENGTH
|
|
400
|
+
)
|
|
401
|
+
_safe_set_attribute(self._parent_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.finish_reason", "completed")
|
|
402
|
+
|
|
403
|
+
def __getattr__(self, name: str) -> Any:
|
|
404
|
+
"""Delegate other attributes to the wrapped AgentRun"""
|
|
405
|
+
return getattr(self._agent_run, name)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class InstrumentedStreamedRunResultIterable:
|
|
409
|
+
"""Iterable wrapper for StreamedRunResult that provides node iteration capability"""
|
|
410
|
+
|
|
411
|
+
def __init__(self, streamed_run_result: Any, tracer: Tracer, parent_span: Any) -> None:
|
|
412
|
+
self._streamed_run_result = streamed_run_result
|
|
413
|
+
self._tracer = tracer
|
|
414
|
+
self._parent_span = parent_span
|
|
415
|
+
# Extract the agent instance to create an iterable AgentRun
|
|
416
|
+
# We need to get the agent from the streamed result to create an iter() call
|
|
417
|
+
self._agent = None
|
|
418
|
+
self._user_prompt = None
|
|
419
|
+
self._deps = None
|
|
420
|
+
|
|
421
|
+
# Try to extract agent and prompt from the streamed result
|
|
422
|
+
if hasattr(streamed_run_result, "_agent"):
|
|
423
|
+
self._agent = streamed_run_result._agent
|
|
424
|
+
if hasattr(streamed_run_result, "_user_prompt"):
|
|
425
|
+
self._user_prompt = streamed_run_result._user_prompt
|
|
426
|
+
if hasattr(streamed_run_result, "_deps"):
|
|
427
|
+
self._deps = streamed_run_result._deps
|
|
428
|
+
|
|
429
|
+
def __aiter__(self) -> Any:
|
|
430
|
+
return self._instrumented_iter()
|
|
431
|
+
|
|
432
|
+
async def _instrumented_iter(self) -> Any:
|
|
433
|
+
"""Create an AgentRun using agent.iter() and iterate over nodes with instrumentation"""
|
|
434
|
+
if not self._agent or not self._user_prompt:
|
|
435
|
+
logger.error("Cannot iterate: missing agent or user_prompt from StreamedRunResult")
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
# Create an AgentRun using agent.iter() with the same prompt and deps
|
|
440
|
+
iter_kwargs = {}
|
|
441
|
+
if self._deps is not None:
|
|
442
|
+
iter_kwargs["deps"] = self._deps
|
|
443
|
+
|
|
444
|
+
async with self._agent.iter(self._user_prompt, **iter_kwargs) as agent_run:
|
|
445
|
+
async for node in agent_run:
|
|
446
|
+
# Create span for this node as child of parent span
|
|
447
|
+
span_name = get_node_span_name(node)
|
|
448
|
+
|
|
449
|
+
# Create span as child of parent span using proper OpenTelemetry context
|
|
450
|
+
if self._parent_span:
|
|
451
|
+
# Create a context with the parent span as the current span
|
|
452
|
+
parent_context = context_api.set_value(
|
|
453
|
+
context_api.get_current(), "current_span", self._parent_span
|
|
454
|
+
)
|
|
455
|
+
# Start span with parent context
|
|
456
|
+
span = self._tracer.start_span(span_name, kind=SpanKind.INTERNAL, context=parent_context)
|
|
457
|
+
else:
|
|
458
|
+
# Fallback to current span if no parent
|
|
459
|
+
span = self._tracer.start_span(span_name, kind=SpanKind.INTERNAL)
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
# Set node attributes
|
|
463
|
+
set_node_attributes(span, node)
|
|
464
|
+
|
|
465
|
+
# For End nodes, also set assistant message on both current span and parent span
|
|
466
|
+
if hasattr(node, "__class__") and "End" in node.__class__.__name__:
|
|
467
|
+
self._set_assistant_content_on_spans(span, node)
|
|
468
|
+
|
|
469
|
+
span.set_status(Status(StatusCode.OK))
|
|
470
|
+
|
|
471
|
+
# Yield the node to the user
|
|
472
|
+
yield node
|
|
473
|
+
except Exception as e:
|
|
474
|
+
_handle_span_error(span, e)
|
|
475
|
+
# Still yield the node even if span creation failed
|
|
476
|
+
yield node
|
|
477
|
+
finally:
|
|
478
|
+
# Always end the span
|
|
479
|
+
span.end()
|
|
480
|
+
except Exception as e:
|
|
481
|
+
# Handle iteration errors
|
|
482
|
+
logger.error(f"Error during node iteration: {e}")
|
|
483
|
+
raise
|
|
484
|
+
|
|
485
|
+
def _set_assistant_content_on_spans(self, current_span, node) -> None: # type: ignore[no-untyped-def]
|
|
486
|
+
"""Set assistant message from End node on both current span and parent span"""
|
|
487
|
+
# Extract the same data that _set_end_node_attributes uses
|
|
488
|
+
data = _safe_get_attribute(node, "data")
|
|
489
|
+
if not data:
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
# Get the final output
|
|
493
|
+
output = _safe_get_attribute(data, "output")
|
|
494
|
+
if output is None:
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
# Set assistant content on current iter span
|
|
498
|
+
if current_span and current_span.is_recording():
|
|
499
|
+
_safe_set_attribute(current_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.role", "assistant")
|
|
500
|
+
_safe_set_attribute(current_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.content", output, MAX_CONTENT_LENGTH)
|
|
501
|
+
_safe_set_attribute(current_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.finish_reason", "completed")
|
|
502
|
+
|
|
503
|
+
# Set assistant content on parent run_stream span
|
|
504
|
+
if self._parent_span and self._parent_span.is_recording():
|
|
505
|
+
_safe_set_attribute(self._parent_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.role", "assistant")
|
|
506
|
+
_safe_set_attribute(
|
|
507
|
+
self._parent_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.content", output, MAX_CONTENT_LENGTH
|
|
508
|
+
)
|
|
509
|
+
_safe_set_attribute(self._parent_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.finish_reason", "completed")
|
|
510
|
+
|
|
511
|
+
def __getattr__(self, name) -> Any: # type: ignore[no-untyped-def]
|
|
512
|
+
"""Delegate other attributes to the wrapped StreamedRunResult"""
|
|
513
|
+
return getattr(self._streamed_run_result, name)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class InstrumentedStreamedRunResult:
|
|
517
|
+
"""Wrapper for StreamedRunResult that creates spans during streaming"""
|
|
518
|
+
|
|
519
|
+
def __init__(
|
|
520
|
+
self, streamed_result: Any, tracer: Tracer, span: Any, start_time: float, request_kwargs: Dict[str, Any]
|
|
521
|
+
) -> None:
|
|
522
|
+
self._streamed_result = streamed_result
|
|
523
|
+
self._tracer = tracer
|
|
524
|
+
self._span = span
|
|
525
|
+
self._start_time = start_time
|
|
526
|
+
self._request_kwargs = request_kwargs
|
|
527
|
+
self._parent_span = span # Keep for backward compatibility
|
|
528
|
+
self._agent_run = None
|
|
529
|
+
|
|
530
|
+
async def __aenter__(self) -> Any:
|
|
531
|
+
# Enter the original streamed result and store it
|
|
532
|
+
self._streamed_run_result = await self._streamed_result.__aenter__()
|
|
533
|
+
|
|
534
|
+
# StreamedRunResult is not iterable, but users expect to iterate over nodes
|
|
535
|
+
# We need to create an iterable wrapper that provides node iteration capability
|
|
536
|
+
return InstrumentedStreamedRunResultIterable(self._streamed_run_result, self._tracer, self._span)
|
|
537
|
+
|
|
538
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> Any: # type: ignore[no-untyped-def]
|
|
539
|
+
try:
|
|
540
|
+
result = await self._streamed_result.__aexit__(exc_type, exc_val, exc_tb)
|
|
541
|
+
# Finalize the parent span when streaming is complete
|
|
542
|
+
self._finalize_parent_span()
|
|
543
|
+
return result
|
|
544
|
+
except Exception as e:
|
|
545
|
+
# Handle any errors and still finalize the span
|
|
546
|
+
if self._span and self._span.is_recording():
|
|
547
|
+
self._span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
548
|
+
self._span.record_exception(e)
|
|
549
|
+
self._span.end()
|
|
550
|
+
raise
|
|
551
|
+
|
|
552
|
+
def _set_assistant_message_on_parent_span(self, node) -> None: # type: ignore[no-untyped-def]
|
|
553
|
+
"""Set assistant message from End node on the parent span"""
|
|
554
|
+
if not self._parent_span or not self._parent_span.is_recording():
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
# Extract the same data that _set_end_node_attributes uses
|
|
558
|
+
data = _safe_get_attribute(node, "data")
|
|
559
|
+
if not data:
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
# Get the final output and set it on parent span
|
|
563
|
+
output = _safe_get_attribute(data, "output")
|
|
564
|
+
if output is not None:
|
|
565
|
+
_safe_set_attribute(self._parent_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.role", "assistant")
|
|
566
|
+
_safe_set_attribute(
|
|
567
|
+
self._parent_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.content", output, MAX_CONTENT_LENGTH
|
|
568
|
+
)
|
|
569
|
+
_safe_set_attribute(self._parent_span, f"{SpanAttributes.LLM_COMPLETIONS}.0.finish_reason", "completed")
|
|
570
|
+
|
|
571
|
+
def _finalize_parent_span(self) -> None:
|
|
572
|
+
"""Finalize parent span when streaming is complete"""
|
|
573
|
+
if not self._span or not self._span.is_recording():
|
|
574
|
+
return
|
|
575
|
+
|
|
576
|
+
# Calculate duration
|
|
577
|
+
end_time = time.time()
|
|
578
|
+
end_time - self._start_time
|
|
579
|
+
|
|
580
|
+
# Set timing attributes
|
|
581
|
+
_set_timing_attributes(self._span, self._start_time, end_time)
|
|
582
|
+
|
|
583
|
+
# Set response attributes if we have access to the final result
|
|
584
|
+
try:
|
|
585
|
+
if hasattr(self._streamed_result, "result"):
|
|
586
|
+
final_result = self._streamed_result.result()
|
|
587
|
+
set_pydantic_response_attributes(self._span, final_result)
|
|
588
|
+
_set_assistant_response_content(self._span, final_result, "streaming")
|
|
589
|
+
except Exception:
|
|
590
|
+
# Ignore errors when trying to get final result
|
|
591
|
+
pass
|
|
592
|
+
|
|
593
|
+
# Mark span as successful and end it
|
|
594
|
+
self._span.set_status(Status(StatusCode.OK))
|
|
595
|
+
self._span.end()
|
|
596
|
+
|
|
597
|
+
def __getattr__(self, name: str) -> Any:
|
|
598
|
+
"""Delegate other attributes to the wrapped StreamedRunResult"""
|
|
599
|
+
return getattr(self._streamed_result, name)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def agent_run_stream_wrapper(tracer: Tracer) -> Callable: # type: ignore[type-arg]
|
|
603
|
+
"""Wrapper for Agent.run_stream method."""
|
|
604
|
+
|
|
605
|
+
def wrapper(wrapped: Callable, instance: Any, args: tuple, kwargs: dict) -> Any: # type: ignore[type-arg]
|
|
606
|
+
if should_suppress_instrumentation():
|
|
607
|
+
return wrapped(*args, **kwargs)
|
|
608
|
+
|
|
609
|
+
# Extract key parameters
|
|
610
|
+
user_prompt = args[0] if args else kwargs.get("user_prompt", "")
|
|
611
|
+
|
|
612
|
+
# Use start_span for streaming operations - returns span directly (not context manager)
|
|
613
|
+
span = tracer.start_span(
|
|
614
|
+
"pydantic_ai.agent.run_stream", kind=SpanKind.CLIENT, attributes={"llm.request.type": "agent.run_stream"}
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
try:
|
|
618
|
+
# Set request attributes
|
|
619
|
+
set_pydantic_request_attributes(span, kwargs, "agent.run_stream")
|
|
620
|
+
|
|
621
|
+
if user_prompt:
|
|
622
|
+
_safe_set_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.0.role", "user")
|
|
623
|
+
_safe_set_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.0.content", user_prompt, MAX_CONTENT_LENGTH)
|
|
624
|
+
|
|
625
|
+
# Execute the original method to get the async context manager
|
|
626
|
+
start_time = time.time()
|
|
627
|
+
original_result = wrapped(*args, **kwargs)
|
|
628
|
+
|
|
629
|
+
# Return instrumented StreamedRunResult that will manage the span lifecycle
|
|
630
|
+
return InstrumentedStreamedRunResult(original_result, tracer, span, start_time, kwargs)
|
|
631
|
+
|
|
632
|
+
except Exception as e:
|
|
633
|
+
# Handle error and end span manually since we're not using context manager
|
|
634
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
635
|
+
span.record_exception(e)
|
|
636
|
+
span.end()
|
|
637
|
+
raise
|
|
638
|
+
|
|
639
|
+
return wrapper
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def tool_function_wrapper(tracer: Tracer) -> Callable: # type: ignore[type-arg]
|
|
643
|
+
"""Wrapper for tool function calls."""
|
|
644
|
+
|
|
645
|
+
def wrapper(wrapped: Callable, instance: Any, args: tuple, kwargs: dict) -> Any: # type: ignore[type-arg]
|
|
646
|
+
if should_suppress_instrumentation():
|
|
647
|
+
return wrapped(*args, **kwargs)
|
|
648
|
+
|
|
649
|
+
function_name = getattr(wrapped, "__name__", "unknown_tool")
|
|
650
|
+
|
|
651
|
+
# Create span for tool execution
|
|
652
|
+
with tracer.start_as_current_span(
|
|
653
|
+
f"pydantic_ai.tool.{function_name}",
|
|
654
|
+
kind=SpanKind.INTERNAL,
|
|
655
|
+
) as span:
|
|
656
|
+
try:
|
|
657
|
+
# Set span attributes
|
|
658
|
+
_safe_set_attribute(span, f"{SpanAttributes.LLM_REQUEST_TYPE}", "tool.call")
|
|
659
|
+
_safe_set_attribute(span, "tool.name", function_name)
|
|
660
|
+
|
|
661
|
+
# Add function arguments (be careful with sensitive data)
|
|
662
|
+
if args:
|
|
663
|
+
_safe_set_attribute(span, "tool.args", str(args), MAX_ARGS_LENGTH)
|
|
664
|
+
if kwargs:
|
|
665
|
+
_safe_set_attribute(span, "tool.kwargs", str(kwargs), MAX_ARGS_LENGTH)
|
|
666
|
+
|
|
667
|
+
# Execute the original method
|
|
668
|
+
start_time = time.time()
|
|
669
|
+
result = wrapped(*args, **kwargs)
|
|
670
|
+
end_time = time.time()
|
|
671
|
+
|
|
672
|
+
# Set result attributes
|
|
673
|
+
_safe_set_attribute(span, "tool.result", str(result), MAX_ARGS_LENGTH)
|
|
674
|
+
_set_timing_attributes(span, start_time, end_time)
|
|
675
|
+
|
|
676
|
+
# Set comprehensive response attributes if result has pydantic_ai structure
|
|
677
|
+
if hasattr(result, "usage") or hasattr(result, "output"):
|
|
678
|
+
set_pydantic_response_attributes(span, result)
|
|
679
|
+
|
|
680
|
+
span.set_status(Status(StatusCode.OK))
|
|
681
|
+
return result
|
|
682
|
+
|
|
683
|
+
except Exception as e:
|
|
684
|
+
_handle_span_error(span, e)
|
|
685
|
+
raise
|
|
686
|
+
|
|
687
|
+
return wrapper
|
netra/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.
|
|
1
|
+
__version__ = "0.1.26"
|