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.

@@ -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.24"
1
+ __version__ = "0.1.26"