netra-sdk 0.1.29__py3-none-any.whl → 0.1.31__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 CHANGED
@@ -255,5 +255,30 @@ class Netra:
255
255
  """
256
256
  return SpanWrapper(name, attributes, module_name)
257
257
 
258
+ @classmethod
259
+ def set_input(cls, value: Any, span_name: Optional[str] = None) -> None:
260
+ """
261
+ Set custom attribute `netra.span.input` on a target span.
262
+
263
+ Args:
264
+ value: Input payload to record (string or JSON-serializable object)
265
+ span_name: Optional. When provided, sets the attribute on the span registered
266
+ with this name. Otherwise sets on the active span.
267
+ """
268
+ SessionManager.set_attribute_on_target_span(f"{Config.LIBRARY_NAME}.span.input", value, span_name)
269
+
270
+ @classmethod
271
+ def set_output(cls, value: Any, span_name: Optional[str] = None) -> None:
272
+ """
273
+ Set custom attribute `netra.span.output` on a target span.
274
+
275
+ Args:
276
+ value: Output payload to record (string or JSON-serializable object)
277
+ span_name: Optional. When provided, sets the attribute on the span registered
278
+ with this name. Otherwise sets on the active span.
279
+ """
280
+ if value:
281
+ SessionManager.set_attribute_on_target_span(f"{Config.LIBRARY_NAME}.span.output", value, span_name)
282
+
258
283
 
259
284
  __all__ = ["Netra", "UsageModel", "ActionModel"]
netra/config.py CHANGED
@@ -25,6 +25,8 @@ class Config:
25
25
  SDK_NAME = "netra"
26
26
  LIBRARY_NAME = "netra"
27
27
  LIBRARY_VERSION = __version__
28
+ # Maximum length for any attribute value (strings and bytes). Processors should honor this.
29
+ ATTRIBUTE_MAX_LEN = 1000
28
30
 
29
31
  def __init__(
30
32
  self,
netra/decorators.py CHANGED
@@ -7,6 +7,7 @@ Decorators can be applied to both functions and classes.
7
7
  import functools
8
8
  import inspect
9
9
  import json
10
+ import logging
10
11
  from typing import Any, Awaitable, Callable, Dict, Optional, ParamSpec, Tuple, TypeVar, Union, cast
11
12
 
12
13
  from opentelemetry import trace
@@ -14,6 +15,8 @@ from opentelemetry import trace
14
15
  from .config import Config
15
16
  from .session_manager import SessionManager
16
17
 
18
+ logger = logging.getLogger(__name__)
19
+
17
20
  P = ParamSpec("P")
18
21
  R = TypeVar("R")
19
22
 
@@ -84,6 +87,13 @@ def _create_function_wrapper(func: Callable[P, R], entity_type: str, name: Optio
84
87
 
85
88
  tracer = trace.get_tracer(module_name)
86
89
  with tracer.start_as_current_span(span_name) as span:
90
+ # Register the span by name for cross-context attribute setting
91
+ try:
92
+ SessionManager.register_span(span_name, span)
93
+ SessionManager.set_current_span(span)
94
+ except Exception:
95
+ logger.exception("Failed to register span '%s' with SessionManager", span_name)
96
+
87
97
  _add_span_attributes(span, func, args, kwargs, entity_type)
88
98
  try:
89
99
  result = await cast(Awaitable[Any], func(*args, **kwargs))
@@ -93,7 +103,11 @@ def _create_function_wrapper(func: Callable[P, R], entity_type: str, name: Optio
93
103
  span.set_attribute(f"{Config.LIBRARY_NAME}.entity.error", str(e))
94
104
  raise
95
105
  finally:
96
- # Pop entity from stack after function call is done
106
+ # Unregister and pop entity from stack after function call is done
107
+ try:
108
+ SessionManager.unregister_span(span_name, span)
109
+ except Exception:
110
+ logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
97
111
  SessionManager.pop_entity(entity_type)
98
112
 
99
113
  return cast(Callable[P, R], async_wrapper)
@@ -107,6 +121,13 @@ def _create_function_wrapper(func: Callable[P, R], entity_type: str, name: Optio
107
121
 
108
122
  tracer = trace.get_tracer(module_name)
109
123
  with tracer.start_as_current_span(span_name) as span:
124
+ # Register the span by name for cross-context attribute setting
125
+ try:
126
+ SessionManager.register_span(span_name, span)
127
+ SessionManager.set_current_span(span)
128
+ except Exception:
129
+ logger.exception("Failed to register span '%s' with SessionManager", span_name)
130
+
110
131
  _add_span_attributes(span, func, args, kwargs, entity_type)
111
132
  try:
112
133
  result = func(*args, **kwargs)
@@ -116,7 +137,11 @@ def _create_function_wrapper(func: Callable[P, R], entity_type: str, name: Optio
116
137
  span.set_attribute(f"{Config.LIBRARY_NAME}.entity.error", str(e))
117
138
  raise
118
139
  finally:
119
- # Pop entity from stack after function call is done
140
+ # Unregister and pop entity from stack after function call is done
141
+ try:
142
+ SessionManager.unregister_span(span_name, span)
143
+ except Exception:
144
+ logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
120
145
  SessionManager.pop_entity(entity_type)
121
146
 
122
147
  return cast(Callable[P, R], sync_wrapper)
@@ -93,6 +93,10 @@ def init_instrumentations(
93
93
  if CustomInstruments.MISTRALAI in netra_custom_instruments:
94
94
  init_mistral_instrumentor()
95
95
 
96
+ # Initialize LiteLLM instrumentation.
97
+ if CustomInstruments.LITELLM in netra_custom_instruments:
98
+ init_litellm_instrumentation()
99
+
96
100
  # Initialize OpenAI instrumentation.
97
101
  if CustomInstruments.OPENAI in netra_custom_instruments:
98
102
  init_openai_instrumentation()
@@ -435,6 +439,26 @@ def init_mistral_instrumentor() -> bool:
435
439
  return False
436
440
 
437
441
 
442
+ def init_litellm_instrumentation() -> bool:
443
+ """Initialize LiteLLM instrumentation.
444
+
445
+ Returns:
446
+ bool: True if initialization was successful, False otherwise.
447
+ """
448
+ try:
449
+ if is_package_installed("litellm"):
450
+ from netra.instrumentation.litellm import LiteLLMInstrumentor
451
+
452
+ instrumentor = LiteLLMInstrumentor()
453
+ if not instrumentor.is_instrumented_by_opentelemetry:
454
+ instrumentor.instrument()
455
+ return True
456
+ except Exception as e:
457
+ logging.error(f"Error initializing LiteLLM instrumentor: {e}")
458
+ Telemetry().log_exception(e)
459
+ return False
460
+
461
+
438
462
  def init_openai_instrumentation() -> bool:
439
463
  """Initialize OpenAI instrumentation.
440
464
 
@@ -8,6 +8,7 @@ class CustomInstruments(Enum):
8
8
  AIOHTTP = "aiohttp"
9
9
  COHEREAI = "cohere_ai"
10
10
  HTTPX = "httpx"
11
+ LITELLM = "litellm"
11
12
  MISTRALAI = "mistral_ai"
12
13
  OPENAI = "openai"
13
14
  PYDANTIC_AI = "pydantic_ai"
@@ -127,6 +128,7 @@ class InstrumentSet(Enum):
127
128
  KAFKA_PYTHON = "kafka_python"
128
129
  LANCEDB = "lancedb"
129
130
  LANGCHAIN = "langchain"
131
+ LITELLM = "litellm"
130
132
  LLAMA_INDEX = "llama_index"
131
133
  LOGGING = "logging"
132
134
  MARQO = "marqo"
@@ -0,0 +1,161 @@
1
+ import logging
2
+ import time
3
+ from typing import Any, Collection, Dict, Optional
4
+
5
+ from opentelemetry import context as context_api
6
+ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
7
+ from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY, unwrap
8
+ from opentelemetry.trace import SpanKind, Tracer, get_tracer
9
+ from opentelemetry.trace.status import Status, StatusCode
10
+ from wrapt import wrap_function_wrapper
11
+
12
+ from netra.instrumentation.litellm.version import __version__
13
+ from netra.instrumentation.litellm.wrappers import (
14
+ acompletion_wrapper,
15
+ aembedding_wrapper,
16
+ aimage_generation_wrapper,
17
+ completion_wrapper,
18
+ embedding_wrapper,
19
+ image_generation_wrapper,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _instruments = ("litellm >= 1.0.0",)
25
+
26
+
27
+ class LiteLLMInstrumentor(BaseInstrumentor): # type: ignore[misc]
28
+ """
29
+ Custom LiteLLM instrumentor for Netra SDK with enhanced support for:
30
+ - completion() and acompletion() methods
31
+ - embedding() and aembedding() methods
32
+ - image_generation() and aimage_generation() methods
33
+ - Proper streaming/non-streaming span handling
34
+ - Integration with Netra tracing
35
+ """
36
+
37
+ def instrumentation_dependencies(self) -> Collection[str]:
38
+ return _instruments
39
+
40
+ def _instrument(self, **kwargs): # type: ignore[no-untyped-def]
41
+ """Instrument LiteLLM methods"""
42
+ tracer_provider = kwargs.get("tracer_provider")
43
+ tracer = get_tracer(__name__, __version__, tracer_provider)
44
+
45
+ logger.debug("Starting LiteLLM instrumentation...")
46
+
47
+ # Force import litellm to ensure it's available for wrapping
48
+ try:
49
+ import litellm
50
+ except ImportError as e:
51
+ logger.error(f"Failed to import litellm: {e}")
52
+ return
53
+
54
+ # Store original functions for uninstrumentation
55
+ self._original_completion = getattr(litellm, "completion", None)
56
+ self._original_acompletion = getattr(litellm, "acompletion", None)
57
+ self._original_embedding = getattr(litellm, "embedding", None)
58
+ self._original_aembedding = getattr(litellm, "aembedding", None)
59
+ self._original_image_generation = getattr(litellm, "image_generation", None)
60
+ self._original_aimage_generation = getattr(litellm, "aimage_generation", None)
61
+
62
+ # Chat completions - use direct monkey patching with proper function wrapping
63
+ if self._original_completion:
64
+ try:
65
+
66
+ def instrumented_completion(*args, **kwargs): # type: ignore[no-untyped-def]
67
+ wrapper = completion_wrapper(tracer)
68
+ return wrapper(self._original_completion, None, args, kwargs)
69
+
70
+ litellm.completion = instrumented_completion
71
+ except Exception as e:
72
+ logger.error(f"Failed to monkey-patch litellm.completion: {e}")
73
+
74
+ if self._original_acompletion:
75
+ try:
76
+
77
+ async def instrumented_acompletion(*args, **kwargs): # type: ignore[no-untyped-def]
78
+ wrapper = acompletion_wrapper(tracer)
79
+ return await wrapper(self._original_acompletion, None, args, kwargs)
80
+
81
+ litellm.acompletion = instrumented_acompletion
82
+ except Exception as e:
83
+ logger.error(f"Failed to monkey-patch litellm.acompletion: {e}")
84
+
85
+ # Embeddings
86
+ if self._original_embedding:
87
+ try:
88
+
89
+ def instrumented_embedding(*args, **kwargs): # type: ignore[no-untyped-def]
90
+ wrapper = embedding_wrapper(tracer)
91
+ return wrapper(self._original_embedding, None, args, kwargs)
92
+
93
+ litellm.embedding = instrumented_embedding
94
+ except Exception as e:
95
+ logger.error(f"Failed to monkey-patch litellm.embedding: {e}")
96
+
97
+ if self._original_aembedding:
98
+ try:
99
+
100
+ async def instrumented_aembedding(*args, **kwargs): # type: ignore[no-untyped-def]
101
+ wrapper = aembedding_wrapper(tracer)
102
+ return await wrapper(self._original_aembedding, None, args, kwargs)
103
+
104
+ litellm.aembedding = instrumented_aembedding
105
+ except Exception as e:
106
+ logger.error(f"Failed to monkey-patch litellm.aembedding: {e}")
107
+
108
+ # Image generation
109
+ if self._original_image_generation:
110
+ try:
111
+
112
+ def instrumented_image_generation(*args, **kwargs): # type: ignore[no-untyped-def]
113
+ wrapper = image_generation_wrapper(tracer)
114
+ return wrapper(self._original_image_generation, None, args, kwargs)
115
+
116
+ litellm.image_generation = instrumented_image_generation
117
+ except Exception as e:
118
+ logger.error(f"Failed to monkey-patch litellm.image_generation: {e}")
119
+
120
+ if self._original_aimage_generation:
121
+ try:
122
+
123
+ async def instrumented_aimage_generation(*args, **kwargs): # type: ignore[no-untyped-def]
124
+ wrapper = aimage_generation_wrapper(tracer)
125
+ return await wrapper(self._original_aimage_generation, None, args, kwargs)
126
+
127
+ litellm.aimage_generation = instrumented_aimage_generation
128
+ except Exception as e:
129
+ logger.error(f"Failed to monkey-patch litellm.aimage_generation: {e}")
130
+
131
+ def _uninstrument(self, **kwargs): # type: ignore[no-untyped-def]
132
+ """Uninstrument LiteLLM methods"""
133
+ try:
134
+ import litellm
135
+
136
+ # Restore original functions
137
+ if hasattr(self, "_original_completion") and self._original_completion:
138
+ litellm.completion = self._original_completion
139
+
140
+ if hasattr(self, "_original_acompletion") and self._original_acompletion:
141
+ litellm.acompletion = self._original_acompletion
142
+
143
+ if hasattr(self, "_original_embedding") and self._original_embedding:
144
+ litellm.embedding = self._original_embedding
145
+
146
+ if hasattr(self, "_original_aembedding") and self._original_aembedding:
147
+ litellm.aembedding = self._original_aembedding
148
+
149
+ if hasattr(self, "_original_image_generation") and self._original_image_generation:
150
+ litellm.image_generation = self._original_image_generation
151
+
152
+ if hasattr(self, "_original_aimage_generation") and self._original_aimage_generation:
153
+ litellm.aimage_generation = self._original_aimage_generation
154
+
155
+ except ImportError:
156
+ pass
157
+
158
+
159
+ def should_suppress_instrumentation() -> bool:
160
+ """Check if instrumentation should be suppressed"""
161
+ return context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) is True
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,557 @@
1
+ import logging
2
+ import time
3
+ from collections.abc import Awaitable
4
+ from typing import Any, AsyncIterator, Callable, Dict, Iterator, Tuple
5
+
6
+ from opentelemetry import context as context_api
7
+ from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY
8
+ from opentelemetry.semconv_ai import (
9
+ SpanAttributes,
10
+ )
11
+ from opentelemetry.trace import Span, SpanKind, Tracer
12
+ from opentelemetry.trace.status import Status, StatusCode
13
+ from wrapt import ObjectProxy
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ COMPLETION_SPAN_NAME = "litellm.completion"
18
+ EMBEDDING_SPAN_NAME = "litellm.embedding"
19
+ IMAGE_GENERATION_SPAN_NAME = "litellm.image_generation"
20
+
21
+
22
+ def should_suppress_instrumentation() -> bool:
23
+ """Check if instrumentation should be suppressed"""
24
+ return context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) is True
25
+
26
+
27
+ def is_streaming_response(response: Any) -> bool:
28
+ """Check if response is a streaming response"""
29
+ return hasattr(response, "__iter__") and not isinstance(response, (str, bytes, dict))
30
+
31
+
32
+ def model_as_dict(obj: Any) -> Dict[str, Any]:
33
+ """Convert LiteLLM model object to dictionary"""
34
+ if hasattr(obj, "model_dump"):
35
+ result = obj.model_dump()
36
+ return result if isinstance(result, dict) else {}
37
+ elif hasattr(obj, "to_dict"):
38
+ result = obj.to_dict()
39
+ return result if isinstance(result, dict) else {}
40
+ elif isinstance(obj, dict):
41
+ return obj
42
+ else:
43
+ return {}
44
+
45
+
46
+ def set_request_attributes(span: Span, kwargs: Dict[str, Any], operation_type: str) -> None:
47
+ """Set request attributes on span"""
48
+ if not span.is_recording():
49
+ return
50
+
51
+ # Set operation type
52
+ span.set_attribute(f"{SpanAttributes.LLM_REQUEST_TYPE}", operation_type)
53
+ span.set_attribute(f"{SpanAttributes.LLM_SYSTEM}", "LiteLLM")
54
+
55
+ # Common attributes
56
+ if kwargs.get("model"):
57
+ span.set_attribute(f"{SpanAttributes.LLM_REQUEST_MODEL}", kwargs["model"])
58
+
59
+ if kwargs.get("temperature") is not None:
60
+ span.set_attribute(f"{SpanAttributes.LLM_REQUEST_TEMPERATURE}", kwargs["temperature"])
61
+
62
+ if kwargs.get("max_tokens") is not None:
63
+ span.set_attribute(f"{SpanAttributes.LLM_REQUEST_MAX_TOKENS}", kwargs["max_tokens"])
64
+
65
+ if kwargs.get("stream") is not None:
66
+ span.set_attribute("gen_ai.stream", kwargs["stream"])
67
+
68
+ # Chat completion specific attributes
69
+ if operation_type == "chat" and kwargs.get("messages"):
70
+ messages = kwargs["messages"]
71
+ if isinstance(messages, list) and len(messages) > 0:
72
+ for index, message in enumerate(messages):
73
+ if isinstance(message, dict):
74
+ span.set_attribute(f"{SpanAttributes.LLM_PROMPTS}.{index}.role", message.get("role", "user"))
75
+ span.set_attribute(f"{SpanAttributes.LLM_PROMPTS}.{index}.content", str(message.get("content", "")))
76
+
77
+ # Embedding specific attributes
78
+ if operation_type == "embedding" and kwargs.get("input"):
79
+ input_data = kwargs["input"]
80
+ if isinstance(input_data, str):
81
+ span.set_attribute(f"{SpanAttributes.LLM_PROMPTS}.0.content", input_data)
82
+ elif isinstance(input_data, list):
83
+ for index, text in enumerate(input_data):
84
+ if isinstance(text, str):
85
+ span.set_attribute(f"{SpanAttributes.LLM_PROMPTS}.{index}.content", text)
86
+
87
+ # Image generation specific attributes
88
+ if operation_type == "image_generation":
89
+ if kwargs.get("prompt"):
90
+ span.set_attribute("gen_ai.prompt", kwargs["prompt"])
91
+ if kwargs.get("n"):
92
+ span.set_attribute("gen_ai.request.n", kwargs["n"])
93
+ if kwargs.get("size"):
94
+ span.set_attribute("gen_ai.request.size", kwargs["size"])
95
+ if kwargs.get("quality"):
96
+ span.set_attribute("gen_ai.request.quality", kwargs["quality"])
97
+ if kwargs.get("style"):
98
+ span.set_attribute("gen_ai.request.style", kwargs["style"])
99
+
100
+
101
+ def set_response_attributes(span: Span, response_dict: Dict[str, Any], operation_type: str) -> None:
102
+ """Set response attributes on span"""
103
+ if not span.is_recording():
104
+ return
105
+
106
+ if response_dict.get("model"):
107
+ span.set_attribute(f"{SpanAttributes.LLM_RESPONSE_MODEL}", response_dict["model"])
108
+
109
+ if response_dict.get("id"):
110
+ span.set_attribute("gen_ai.response.id", response_dict["id"])
111
+
112
+ # Usage information
113
+ usage = response_dict.get("usage", {})
114
+ if usage:
115
+ if usage.get("prompt_tokens"):
116
+ span.set_attribute(f"{SpanAttributes.LLM_USAGE_PROMPT_TOKENS}", usage["prompt_tokens"])
117
+ if usage.get("completion_tokens"):
118
+ span.set_attribute(f"{SpanAttributes.LLM_USAGE_COMPLETION_TOKENS}", usage["completion_tokens"])
119
+ if usage.get("cache_read_input_tokens"):
120
+ span.set_attribute(f"{SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS}", usage["cache_read_input_tokens"])
121
+ if usage.get("cache_creation_input_tokens"):
122
+ span.set_attribute("gen_ai.usage.cache_creation_input_tokens", usage["cache_creation_input_tokens"])
123
+ if usage.get("total_tokens"):
124
+ span.set_attribute(f"{SpanAttributes.LLM_USAGE_TOTAL_TOKENS}", usage["total_tokens"])
125
+
126
+ # Chat completion response content
127
+ if operation_type == "chat":
128
+ choices = response_dict.get("choices", [])
129
+ for index, choice in enumerate(choices):
130
+ if choice.get("message", {}).get("role"):
131
+ span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{index}.role", choice["message"]["role"])
132
+ if choice.get("message", {}).get("content"):
133
+ span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{index}.content", choice["message"]["content"])
134
+ if choice.get("finish_reason"):
135
+ span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{index}.finish_reason", choice["finish_reason"])
136
+
137
+ # Embedding response content
138
+ elif operation_type == "embedding":
139
+ data = response_dict.get("data", [])
140
+ for index, embedding_data in enumerate(data):
141
+ if embedding_data.get("index") is not None:
142
+ span.set_attribute(f"gen_ai.response.embeddings.{index}.index", embedding_data["index"])
143
+ if embedding_data.get("embedding"):
144
+ # Don't log the actual embedding vector, just its dimensions
145
+ embedding_vector = embedding_data["embedding"]
146
+ if isinstance(embedding_vector, list):
147
+ span.set_attribute(f"gen_ai.response.embeddings.{index}.dimensions", len(embedding_vector))
148
+
149
+ # Image generation response content
150
+ elif operation_type == "image_generation":
151
+ data = response_dict.get("data", [])
152
+ for index, image_data in enumerate(data):
153
+ if image_data.get("url"):
154
+ span.set_attribute(f"gen_ai.response.images.{index}.url", image_data["url"])
155
+ if image_data.get("b64_json"):
156
+ span.set_attribute(f"gen_ai.response.images.{index}.has_b64_json", True)
157
+ if image_data.get("revised_prompt"):
158
+ span.set_attribute(f"gen_ai.response.images.{index}.revised_prompt", image_data["revised_prompt"])
159
+
160
+
161
+ def completion_wrapper(tracer: Tracer) -> Callable[..., Any]:
162
+ """Wrapper for LiteLLM completion function"""
163
+
164
+ def wrapper(wrapped: Callable[..., Any], instance: Any, args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> Any:
165
+ logger.debug(f"LiteLLM completion wrapper called with model: {kwargs.get('model')}")
166
+
167
+ if should_suppress_instrumentation():
168
+ logger.debug("LiteLLM instrumentation suppressed")
169
+ return wrapped(*args, **kwargs)
170
+
171
+ # Check if streaming
172
+ is_streaming = kwargs.get("stream", False)
173
+
174
+ if is_streaming:
175
+ # Use start_span for streaming - returns span directly
176
+ span = tracer.start_span(
177
+ COMPLETION_SPAN_NAME, kind=SpanKind.CLIENT, attributes={"llm.request.type": "chat"}
178
+ )
179
+
180
+ set_request_attributes(span, kwargs, "chat")
181
+
182
+ try:
183
+ start_time = time.time()
184
+ response = wrapped(*args, **kwargs)
185
+
186
+ return StreamingWrapper(span=span, response=response, start_time=start_time, request_kwargs=kwargs)
187
+ except Exception as e:
188
+ span.set_status(Status(StatusCode.ERROR, str(e)))
189
+ span.record_exception(e)
190
+ span.end()
191
+ raise
192
+ else:
193
+ # Use start_as_current_span for non-streaming - returns context manager
194
+ with tracer.start_as_current_span(
195
+ COMPLETION_SPAN_NAME, kind=SpanKind.CLIENT, attributes={"llm.request.type": "chat"}
196
+ ) as span:
197
+ set_request_attributes(span, kwargs, "chat")
198
+
199
+ try:
200
+ start_time = time.time()
201
+ response = wrapped(*args, **kwargs)
202
+ end_time = time.time()
203
+
204
+ response_dict = model_as_dict(response)
205
+ set_response_attributes(span, response_dict, "chat")
206
+
207
+ span.set_attribute("llm.response.duration", end_time - start_time)
208
+ span.set_status(Status(StatusCode.OK))
209
+
210
+ return response
211
+ except Exception as e:
212
+ span.set_status(Status(StatusCode.ERROR, str(e)))
213
+ raise
214
+
215
+ return wrapper
216
+
217
+
218
+ def acompletion_wrapper(tracer: Tracer) -> Callable[..., Awaitable[Any]]:
219
+ """Async wrapper for LiteLLM acompletion function"""
220
+
221
+ async def wrapper(
222
+ wrapped: Callable[..., Awaitable[Any]], instance: Any, args: Tuple[Any, ...], kwargs: Dict[str, Any]
223
+ ) -> Any:
224
+ if should_suppress_instrumentation():
225
+ return await wrapped(*args, **kwargs)
226
+
227
+ # Check if streaming
228
+ is_streaming = kwargs.get("stream", False)
229
+
230
+ if is_streaming:
231
+ # Use start_span for streaming - returns span directly
232
+ span = tracer.start_span(
233
+ COMPLETION_SPAN_NAME, kind=SpanKind.CLIENT, attributes={"llm.request.type": "chat"}
234
+ )
235
+
236
+ set_request_attributes(span, kwargs, "chat")
237
+
238
+ try:
239
+ start_time = time.time()
240
+ response = await wrapped(*args, **kwargs)
241
+
242
+ return AsyncStreamingWrapper(span=span, response=response, start_time=start_time, request_kwargs=kwargs)
243
+ except Exception as e:
244
+ span.set_status(Status(StatusCode.ERROR, str(e)))
245
+ span.record_exception(e)
246
+ span.end()
247
+ raise
248
+ else:
249
+ # Use start_as_current_span for non-streaming - returns context manager
250
+ with tracer.start_as_current_span(
251
+ COMPLETION_SPAN_NAME, kind=SpanKind.CLIENT, attributes={"llm.request.type": "chat"}
252
+ ) as span:
253
+ set_request_attributes(span, kwargs, "chat")
254
+
255
+ try:
256
+ start_time = time.time()
257
+ response = await wrapped(*args, **kwargs)
258
+ end_time = time.time()
259
+
260
+ response_dict = model_as_dict(response)
261
+ set_response_attributes(span, response_dict, "chat")
262
+
263
+ span.set_attribute("llm.response.duration", end_time - start_time)
264
+ span.set_status(Status(StatusCode.OK))
265
+
266
+ return response
267
+ except Exception as e:
268
+ span.set_status(Status(StatusCode.ERROR, str(e)))
269
+ raise
270
+
271
+ return wrapper
272
+
273
+
274
+ def embedding_wrapper(tracer: Tracer) -> Callable[..., Any]:
275
+ """Wrapper for LiteLLM embedding function"""
276
+
277
+ def wrapper(wrapped: Callable[..., Any], instance: Any, args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> Any:
278
+ if should_suppress_instrumentation():
279
+ return wrapped(*args, **kwargs)
280
+
281
+ # Embeddings are never streaming, always use start_as_current_span
282
+ with tracer.start_as_current_span(
283
+ EMBEDDING_SPAN_NAME, kind=SpanKind.CLIENT, attributes={"llm.request.type": "embedding"}
284
+ ) as span:
285
+ set_request_attributes(span, kwargs, "embedding")
286
+
287
+ try:
288
+ start_time = time.time()
289
+ response = wrapped(*args, **kwargs)
290
+ end_time = time.time()
291
+
292
+ response_dict = model_as_dict(response)
293
+ set_response_attributes(span, response_dict, "embedding")
294
+
295
+ span.set_attribute("llm.response.duration", end_time - start_time)
296
+ span.set_status(Status(StatusCode.OK))
297
+
298
+ return response
299
+ except Exception as e:
300
+ span.set_status(Status(StatusCode.ERROR, str(e)))
301
+ raise
302
+
303
+ return wrapper
304
+
305
+
306
+ def aembedding_wrapper(tracer: Tracer) -> Callable[..., Awaitable[Any]]:
307
+ """Async wrapper for LiteLLM aembedding function"""
308
+
309
+ async def wrapper(
310
+ wrapped: Callable[..., Awaitable[Any]], instance: Any, args: Tuple[Any, ...], kwargs: Dict[str, Any]
311
+ ) -> Any:
312
+ if should_suppress_instrumentation():
313
+ return await wrapped(*args, **kwargs)
314
+
315
+ # Embeddings are never streaming, always use start_as_current_span
316
+ with tracer.start_as_current_span(
317
+ EMBEDDING_SPAN_NAME, kind=SpanKind.CLIENT, attributes={"llm.request.type": "embedding"}
318
+ ) as span:
319
+ set_request_attributes(span, kwargs, "embedding")
320
+
321
+ try:
322
+ start_time = time.time()
323
+ response = await wrapped(*args, **kwargs)
324
+ end_time = time.time()
325
+
326
+ response_dict = model_as_dict(response)
327
+ set_response_attributes(span, response_dict, "embedding")
328
+
329
+ span.set_attribute("llm.response.duration", end_time - start_time)
330
+ span.set_status(Status(StatusCode.OK))
331
+
332
+ return response
333
+ except Exception as e:
334
+ span.set_status(Status(StatusCode.ERROR, str(e)))
335
+ raise
336
+
337
+ return wrapper
338
+
339
+
340
+ def image_generation_wrapper(tracer: Tracer) -> Callable[..., Any]:
341
+ """Wrapper for LiteLLM image_generation function"""
342
+
343
+ def wrapper(wrapped: Callable[..., Any], instance: Any, args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> Any:
344
+ if should_suppress_instrumentation():
345
+ return wrapped(*args, **kwargs)
346
+
347
+ # Image generation is never streaming, always use start_as_current_span
348
+ with tracer.start_as_current_span(
349
+ IMAGE_GENERATION_SPAN_NAME, kind=SpanKind.CLIENT, attributes={"llm.request.type": "image_generation"}
350
+ ) as span:
351
+ set_request_attributes(span, kwargs, "image_generation")
352
+
353
+ try:
354
+ start_time = time.time()
355
+ response = wrapped(*args, **kwargs)
356
+ end_time = time.time()
357
+
358
+ response_dict = model_as_dict(response)
359
+ set_response_attributes(span, response_dict, "image_generation")
360
+
361
+ span.set_attribute("llm.response.duration", end_time - start_time)
362
+ span.set_status(Status(StatusCode.OK))
363
+
364
+ return response
365
+ except Exception as e:
366
+ span.set_status(Status(StatusCode.ERROR, str(e)))
367
+ raise
368
+
369
+ return wrapper
370
+
371
+
372
+ def aimage_generation_wrapper(tracer: Tracer) -> Callable[..., Awaitable[Any]]:
373
+ """Async wrapper for LiteLLM aimage_generation function"""
374
+
375
+ async def wrapper(
376
+ wrapped: Callable[..., Awaitable[Any]], instance: Any, args: Tuple[Any, ...], kwargs: Dict[str, Any]
377
+ ) -> Any:
378
+ if should_suppress_instrumentation():
379
+ return await wrapped(*args, **kwargs)
380
+
381
+ # Image generation is never streaming, always use start_as_current_span
382
+ with tracer.start_as_current_span(
383
+ IMAGE_GENERATION_SPAN_NAME, kind=SpanKind.CLIENT, attributes={"llm.request.type": "image_generation"}
384
+ ) as span:
385
+ set_request_attributes(span, kwargs, "image_generation")
386
+
387
+ try:
388
+ start_time = time.time()
389
+ response = await wrapped(*args, **kwargs)
390
+ end_time = time.time()
391
+
392
+ response_dict = model_as_dict(response)
393
+ set_response_attributes(span, response_dict, "image_generation")
394
+
395
+ span.set_attribute("llm.response.duration", end_time - start_time)
396
+ span.set_status(Status(StatusCode.OK))
397
+
398
+ return response
399
+ except Exception as e:
400
+ span.set_status(Status(StatusCode.ERROR, str(e)))
401
+ raise
402
+
403
+ return wrapper
404
+
405
+
406
+ class StreamingWrapper(ObjectProxy): # type: ignore[misc]
407
+ """Wrapper for streaming responses"""
408
+
409
+ def __init__(self, span: Span, response: Iterator[Any], start_time: float, request_kwargs: Dict[str, Any]) -> None:
410
+ super().__init__(response)
411
+ self._span = span
412
+ self._start_time = start_time
413
+ self._request_kwargs = request_kwargs
414
+ self._complete_response: Dict[str, Any] = {"choices": [], "model": ""}
415
+ self._content_parts: list[str] = []
416
+
417
+ def __iter__(self) -> Iterator[Any]:
418
+ return self
419
+
420
+ def __next__(self) -> Any:
421
+ try:
422
+ chunk = self.__wrapped__.__next__()
423
+ self._process_chunk(chunk)
424
+ return chunk
425
+ except StopIteration:
426
+ self._finalize_span()
427
+ raise
428
+
429
+ def _process_chunk(self, chunk: Any) -> None:
430
+ """Process streaming chunk"""
431
+ chunk_dict = model_as_dict(chunk)
432
+
433
+ # Accumulate response data
434
+ if chunk_dict.get("model"):
435
+ self._complete_response["model"] = chunk_dict["model"]
436
+
437
+ # Accumulate usage information from chunks
438
+ if chunk_dict.get("usage"):
439
+ self._complete_response["usage"] = chunk_dict["usage"]
440
+
441
+ # Collect content from delta
442
+ choices = chunk_dict.get("choices", [])
443
+ for choice in choices:
444
+ delta = choice.get("delta", {})
445
+ if delta.get("content"):
446
+ self._content_parts.append(delta["content"])
447
+
448
+ # Collect finish_reason from choices
449
+ if choice.get("finish_reason"):
450
+ if "choices" not in self._complete_response:
451
+ self._complete_response["choices"] = []
452
+ # Ensure we have enough choice entries
453
+ while len(self._complete_response["choices"]) <= len(choices) - 1:
454
+ self._complete_response["choices"].append(
455
+ {"message": {"role": "assistant", "content": ""}, "finish_reason": None}
456
+ )
457
+
458
+ choice_index = choice.get("index", 0)
459
+ if choice_index < len(self._complete_response["choices"]):
460
+ self._complete_response["choices"][choice_index]["finish_reason"] = choice["finish_reason"]
461
+
462
+ # Add chunk event
463
+ self._span.add_event("llm.content.completion.chunk")
464
+
465
+ def _finalize_span(self) -> None:
466
+ """Finalize span when streaming is complete"""
467
+ end_time = time.time()
468
+ duration = end_time - self._start_time
469
+
470
+ # Set accumulated content
471
+ if self._content_parts:
472
+ full_content = "".join(self._content_parts)
473
+ self._span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.0.content", full_content)
474
+ self._span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.0.role", "assistant")
475
+
476
+ set_response_attributes(self._span, self._complete_response, "chat")
477
+ self._span.set_attribute("llm.response.duration", duration)
478
+ self._span.set_status(Status(StatusCode.OK))
479
+ self._span.end()
480
+
481
+
482
+ class AsyncStreamingWrapper(ObjectProxy): # type: ignore[misc]
483
+ """Async wrapper for streaming responses"""
484
+
485
+ def __init__(
486
+ self, span: Span, response: AsyncIterator[Any], start_time: float, request_kwargs: Dict[str, Any]
487
+ ) -> None:
488
+ super().__init__(response)
489
+ self._span = span
490
+ self._start_time = start_time
491
+ self._request_kwargs = request_kwargs
492
+ self._complete_response: Dict[str, Any] = {"choices": [], "model": ""}
493
+ self._content_parts: list[str] = []
494
+
495
+ def __aiter__(self) -> AsyncIterator[Any]:
496
+ return self
497
+
498
+ async def __anext__(self) -> Any:
499
+ try:
500
+ chunk = await self.__wrapped__.__anext__()
501
+ self._process_chunk(chunk)
502
+ return chunk
503
+ except StopAsyncIteration:
504
+ self._finalize_span()
505
+ raise
506
+
507
+ def _process_chunk(self, chunk: Any) -> None:
508
+ """Process streaming chunk"""
509
+ chunk_dict = model_as_dict(chunk)
510
+
511
+ # Accumulate response data
512
+ if chunk_dict.get("model"):
513
+ self._complete_response["model"] = chunk_dict["model"]
514
+
515
+ # Accumulate usage information from chunks
516
+ if chunk_dict.get("usage"):
517
+ self._complete_response["usage"] = chunk_dict["usage"]
518
+
519
+ # Collect content from delta
520
+ choices = chunk_dict.get("choices", [])
521
+ for choice in choices:
522
+ delta = choice.get("delta", {})
523
+ if delta.get("content"):
524
+ self._content_parts.append(delta["content"])
525
+
526
+ # Collect finish_reason from choices
527
+ if choice.get("finish_reason"):
528
+ if "choices" not in self._complete_response:
529
+ self._complete_response["choices"] = []
530
+ # Ensure we have enough choice entries
531
+ while len(self._complete_response["choices"]) <= len(choices) - 1:
532
+ self._complete_response["choices"].append(
533
+ {"message": {"role": "assistant", "content": ""}, "finish_reason": None}
534
+ )
535
+
536
+ choice_index = choice.get("index", 0)
537
+ if choice_index < len(self._complete_response["choices"]):
538
+ self._complete_response["choices"][choice_index]["finish_reason"] = choice["finish_reason"]
539
+
540
+ # Add chunk event
541
+ self._span.add_event("llm.content.completion.chunk")
542
+
543
+ def _finalize_span(self) -> None:
544
+ """Finalize span when streaming is complete"""
545
+ end_time = time.time()
546
+ duration = end_time - self._start_time
547
+
548
+ # Set accumulated content
549
+ if self._content_parts:
550
+ full_content = "".join(self._content_parts)
551
+ self._span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.0.content", full_content)
552
+ self._span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.0.role", "assistant")
553
+
554
+ set_response_attributes(self._span, self._complete_response, "chat")
555
+ self._span.set_attribute("llm.response.duration", duration)
556
+ self._span.set_status(Status(StatusCode.OK))
557
+ self._span.end()
@@ -1,3 +1,4 @@
1
+ from netra.processors.instrumentation_span_processor import InstrumentationSpanProcessor
1
2
  from netra.processors.session_span_processor import SessionSpanProcessor
2
3
 
3
- __all__ = ["SessionSpanProcessor"]
4
+ __all__ = ["SessionSpanProcessor", "InstrumentationSpanProcessor"]
@@ -0,0 +1,102 @@
1
+ import logging
2
+ from typing import Any, Callable, Optional
3
+
4
+ from opentelemetry import context as otel_context
5
+ from opentelemetry import trace
6
+ from opentelemetry.sdk.trace import SpanProcessor
7
+
8
+ from netra.config import Config
9
+ from netra.instrumentation.instruments import InstrumentSet
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ ALLOWED_INSTRUMENTATION_NAMES = {member.value for member in InstrumentSet} # type: ignore[attr-defined]
15
+
16
+
17
+ class InstrumentationSpanProcessor(SpanProcessor): # type: ignore[misc]
18
+ """Span processor to record this span's instrumentation name and wrap set_attribute.
19
+
20
+ - Records raw instrumentation scope name for the span
21
+ - Wraps span.set_attribute to truncate string values to max 1000 chars (also inside simple lists/dicts of strings)
22
+ """
23
+
24
+ def __init__(self) -> None:
25
+ super().__init__()
26
+
27
+ def _detect_raw_instrumentation_name(self, span: trace.Span) -> Optional[str]:
28
+ """Detect the raw instrumentation name for the span."""
29
+ scope = getattr(span, "instrumentation_info", None)
30
+ if scope is not None:
31
+ name = getattr(scope, "name", None)
32
+ if isinstance(name, str) and name:
33
+ # Normalize common pattern like 'opentelemetry.instrumentation.httpx' -> 'httpx'
34
+ try:
35
+ base = name.rsplit(".", 1)[-1].strip()
36
+ if base:
37
+ return base
38
+ except Exception:
39
+ pass
40
+ return name
41
+ return None
42
+
43
+ def _truncate_value(self, value: Any) -> Any:
44
+ """Truncate string values to max 1000 chars (also inside simple lists/dicts of strings)."""
45
+ try:
46
+ if isinstance(value, str):
47
+ return value if len(value) <= Config.ATTRIBUTE_MAX_LEN else value[: Config.ATTRIBUTE_MAX_LEN]
48
+ if isinstance(value, (bytes, bytearray)):
49
+ return value[: Config.ATTRIBUTE_MAX_LEN]
50
+ if isinstance(value, list):
51
+ # Shallow truncate strings inside lists
52
+ return [self._truncate_value(v) if isinstance(v, (str, bytes, bytearray)) else v for v in value]
53
+ if isinstance(value, dict):
54
+ # Shallow truncate strings inside dicts
55
+ return {
56
+ k: self._truncate_value(v) if isinstance(v, (str, bytes, bytearray)) else v
57
+ for k, v in value.items()
58
+ }
59
+ except Exception:
60
+ return value
61
+ return value
62
+
63
+ def on_start(self, span: trace.Span, parent_context: Optional[otel_context.Context] = None) -> None:
64
+ """Start span and wrap set_attribute."""
65
+ try:
66
+ # Wrap set_attribute first so subsequent sets are also processed
67
+ original_set_attribute: Callable[[str, Any], None] = span.set_attribute
68
+
69
+ def wrapped_set_attribute(key: str, value: Any) -> None:
70
+ try:
71
+ # Truncate value(s)
72
+ truncated = self._truncate_value(value)
73
+ # Forward to original
74
+ original_set_attribute(key, truncated)
75
+ # Special rule: if model key set, mark span as llm
76
+ if key == "gen_ai.request.model":
77
+ original_set_attribute(f"{Config.LIBRARY_NAME}.span.type", "llm")
78
+ except Exception:
79
+ # Best-effort; never break span
80
+ try:
81
+ original_set_attribute(key, value)
82
+ except Exception:
83
+ pass
84
+
85
+ # Monkey patch for this span's lifetime
86
+ setattr(span, "set_attribute", wrapped_set_attribute)
87
+
88
+ # Set this span's instrumentation name
89
+ name = self._detect_raw_instrumentation_name(span)
90
+ if name and any(allowed in name for allowed in ALLOWED_INSTRUMENTATION_NAMES):
91
+ span.set_attribute(f"{Config.LIBRARY_NAME}.instrumentation.name", name)
92
+ except Exception:
93
+ pass
94
+
95
+ def on_end(self, span: trace.Span) -> None:
96
+ """End span."""
97
+
98
+ def force_flush(self, timeout_millis: int = 30000) -> None:
99
+ """Force flush span."""
100
+
101
+ def shutdown(self) -> None:
102
+ pass
netra/session_manager.py CHANGED
@@ -28,6 +28,9 @@ class SessionManager:
28
28
  _agent_stack: List[str] = []
29
29
  _span_stack: List[str] = []
30
30
 
31
+ # Span registry: name -> stack of spans (most-recent last)
32
+ _spans_by_name: Dict[str, List[trace.Span]] = {}
33
+
31
34
  @classmethod
32
35
  def set_current_span(cls, span: Optional[trace.Span]) -> None:
33
36
  """
@@ -48,6 +51,49 @@ class SessionManager:
48
51
  """
49
52
  return cls._current_span
50
53
 
54
+ @classmethod
55
+ def register_span(cls, name: str, span: trace.Span) -> None:
56
+ """
57
+ Register a span under a given name. Supports nested spans with the same name via a stack.
58
+ """
59
+ try:
60
+ stack = cls._spans_by_name.get(name)
61
+ if stack is None:
62
+ cls._spans_by_name[name] = [span]
63
+ else:
64
+ stack.append(span)
65
+ except Exception:
66
+ logger.exception("Failed to register span '%s'", name)
67
+
68
+ @classmethod
69
+ def unregister_span(cls, name: str, span: trace.Span) -> None:
70
+ """
71
+ Unregister a span for a given name. Safe if not present.
72
+ """
73
+ try:
74
+ stack = cls._spans_by_name.get(name)
75
+ if not stack:
76
+ return
77
+ # Remove the last matching instance (normal case)
78
+ for i in range(len(stack) - 1, -1, -1):
79
+ if stack[i] is span:
80
+ stack.pop(i)
81
+ break
82
+ if not stack:
83
+ cls._spans_by_name.pop(name, None)
84
+ except Exception:
85
+ logger.exception("Failed to unregister span '%s'", name)
86
+
87
+ @classmethod
88
+ def get_span_by_name(cls, name: str) -> Optional[trace.Span]:
89
+ """
90
+ Get the most recently registered span with the given name.
91
+ """
92
+ stack = cls._spans_by_name.get(name)
93
+ if stack:
94
+ return stack[-1]
95
+ return None
96
+
51
97
  @classmethod
52
98
  def push_entity(cls, entity_type: str, entity_name: str) -> None:
53
99
  """
@@ -190,3 +236,45 @@ class SessionManager:
190
236
  span.add_event(name=name, attributes=attributes, timestamp=timestamp_ns)
191
237
  except Exception as e:
192
238
  logger.exception(f"Failed to add custom event: {name} - {e}")
239
+
240
+ @classmethod
241
+ def set_attribute_on_target_span(cls, attr_key: str, attr_value: Any, span_name: Optional[str] = None) -> None:
242
+ """
243
+ Best-effort setter to annotate the active span with the provided attribute.
244
+
245
+ If span_name is provided, we look up that span via SessionManager's registry and set
246
+ the attribute on that span explicitly. Otherwise, we annotate the active span.
247
+ We first try the OpenTelemetry current span; if that's invalid, we fall back to
248
+ the SDK-managed current span from `SessionManager`.
249
+ """
250
+ try:
251
+ # Convert attribute value to a JSON-safe string representation
252
+ try:
253
+ if isinstance(attr_value, str):
254
+ attr_str = attr_value
255
+ else:
256
+ import json
257
+
258
+ attr_str = json.dumps(attr_value)
259
+ except Exception:
260
+ attr_str = str(attr_value)
261
+
262
+ # If a target span name is provided, use the registry for explicit lookup
263
+ if span_name is not None:
264
+ target = cls.get_span_by_name(span_name)
265
+ if target is None:
266
+ logger.debug("No span found with name '%s' to set attribute %s", span_name, attr_key)
267
+ return
268
+ target.set_attribute(attr_key, attr_str)
269
+ return
270
+
271
+ # Otherwise annotate the active span
272
+ current_span = trace.get_current_span()
273
+ has_valid_current = getattr(current_span, "is_recording", None) is not None and current_span.is_recording()
274
+ candidate = current_span if has_valid_current else cls.get_current_span()
275
+ if candidate is None:
276
+ logger.debug("No active span found to set attribute %s", attr_key)
277
+ return
278
+ candidate.set_attribute(attr_key, attr_str)
279
+ except Exception as e:
280
+ logger.exception("Failed setting attribute %s: %s", attr_key, e)
netra/span_wrapper.py CHANGED
@@ -11,6 +11,7 @@ from opentelemetry.trace.propagation import set_span_in_context
11
11
  from pydantic import BaseModel
12
12
 
13
13
  from netra.config import Config
14
+ from netra.session_manager import SessionManager
14
15
 
15
16
  # Configure logging
16
17
  logging.basicConfig(level=logging.INFO)
@@ -84,6 +85,14 @@ class SpanWrapper:
84
85
  ctx = set_span_in_context(self.span)
85
86
  self.context_token = context_api.attach(ctx)
86
87
 
88
+ # Register with SessionManager for name-based lookup
89
+ try:
90
+ SessionManager.register_span(self.name, self.span)
91
+ # Optionally set as current span for SDK consumers that rely on it
92
+ SessionManager.set_current_span(self.span)
93
+ except Exception:
94
+ logger.exception("Failed to register span '%s' with SessionManager", self.name)
95
+
87
96
  logger.info(f"Started span wrapper: {self.name}")
88
97
  return self
89
98
 
@@ -120,6 +129,11 @@ class SpanWrapper:
120
129
 
121
130
  # End OpenTelemetry span and detach context
122
131
  if self.span:
132
+ # Unregister from SessionManager before ending span
133
+ try:
134
+ SessionManager.unregister_span(self.name, self.span)
135
+ except Exception:
136
+ logger.exception("Failed to unregister span '%s' from SessionManager", self.name)
123
137
  self.span.end()
124
138
  if self.context_token:
125
139
  context_api.detach(self.context_token)
netra/tracer.py CHANGED
@@ -65,9 +65,10 @@ class Tracer:
65
65
  endpoint=self._format_endpoint(self.cfg.otlp_endpoint),
66
66
  headers=self.cfg.headers,
67
67
  )
68
- # Add span processors for session span processing and data aggregation processing
69
- from netra.processors import SessionSpanProcessor
68
+ # Add span processors: first instrumentation wrapper, then session processor
69
+ from netra.processors import InstrumentationSpanProcessor, SessionSpanProcessor
70
70
 
71
+ provider.add_span_processor(InstrumentationSpanProcessor())
71
72
  provider.add_span_processor(SessionSpanProcessor())
72
73
 
73
74
  # Install appropriate span processor
netra/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.29"
1
+ __version__ = "0.1.31"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: netra-sdk
3
- Version: 0.1.29
3
+ Version: 0.1.31
4
4
  Summary: A Python SDK for AI application observability that provides OpenTelemetry-based monitoring, tracing, and PII protection for LLM and vector database applications. Enables easy instrumentation, session tracking, and privacy-focused data collection for AI systems in production environments.
5
5
  License: Apache-2.0
6
6
  Keywords: netra,tracing,observability,sdk,ai,llm,vector,database
@@ -303,6 +303,7 @@ async def async_span(data):
303
303
  - **CrewAI** - Multi-agent AI systems
304
304
  - **Pydantic AI** - AI model communication standard
305
305
  - **MCP (Model Context Protocol)** - AI model communication standard
306
+ - **LiteLLM** - LLM provider agnostic client
306
307
 
307
308
  ## 🛡️ Privacy Protection & Security
308
309
 
@@ -1,15 +1,15 @@
1
- netra/__init__.py,sha256=EIMbq0ycEIolmyk7uGXkmgvydfWArNk-soBIEQijsiY,9612
1
+ netra/__init__.py,sha256=4eqBwPZP86tsxnUJC-Q7dEVpFAIoZcp5vgmOyKwQslo,10740
2
2
  netra/anonymizer/__init__.py,sha256=KeGPPZqKVZbtkbirEKYTYhj6aZHlakjdQhD7QHqBRio,133
3
3
  netra/anonymizer/anonymizer.py,sha256=IcrYkdwWrFauGWUeAW-0RwrSUM8VSZCFNtoywZhvIqU,3778
4
4
  netra/anonymizer/base.py,sha256=ytPxHCUD2OXlEY6fNTuMmwImNdIjgj294I41FIgoXpU,5946
5
5
  netra/anonymizer/fp_anonymizer.py,sha256=_6svIYmE0eejdIMkhKBUWCNjGtGimtrGtbLvPSOp8W4,6493
6
- netra/config.py,sha256=zmTssfUExG8viSMKN9Yfd8i--7S4oUQT8IyvwAI4C2U,5950
7
- netra/decorators.py,sha256=TqORBs0Xv_CZHOAwrWkpvscMj4FgOJsgo1CRYoG6C7Y,7435
6
+ netra/config.py,sha256=-_2iXoMP92pifMFLOt1rjpRvEnqHWc3O9usFIGhxnwA,6075
7
+ netra/decorators.py,sha256=yuQP02sdvTRIYkv-myNcP8q7dmPq3ME1AJZxJtryayI,8720
8
8
  netra/exceptions/__init__.py,sha256=uDgcBxmC4WhdS7HRYQk_TtJyxH1s1o6wZmcsnSHLAcM,174
9
9
  netra/exceptions/injection.py,sha256=ke4eUXRYUFJkMZgdSyPPkPt5PdxToTI6xLEBI0hTWUQ,1332
10
10
  netra/exceptions/pii.py,sha256=MT4p_x-zH3VtYudTSxw1Z9qQZADJDspq64WrYqSWlZc,2438
11
11
  netra/input_scanner.py,sha256=At6N9gNY8cR0O6S8x3K6swWBV3P1a_9O-XBNM_pcKz4,5348
12
- netra/instrumentation/__init__.py,sha256=pJOkAO1h7rdM_VwvZ_FZZ-zq8PCmLOzW4jvr_CwWYCI,40792
12
+ netra/instrumentation/__init__.py,sha256=HdG3n5TxPRUNlOxsqjlvwDmBcnm3UtYx1OecLhnLeQM,41578
13
13
  netra/instrumentation/aiohttp/__init__.py,sha256=M1kuF0R3gKY5rlbhEC1AR13UWHelmfokluL2yFysKWc,14398
14
14
  netra/instrumentation/aiohttp/version.py,sha256=Zy-0Aukx-HS_Mo3NKPWg-hlUoWKDzS0w58gLoVtJec8,24
15
15
  netra/instrumentation/cohere/__init__.py,sha256=3XwmCAZwZiMkHdNN3YvcBOLsNCx80ymbU31TyMzv1IY,17685
@@ -22,7 +22,10 @@ netra/instrumentation/google_genai/utils.py,sha256=2OeSN5jUaMKF4x5zWiW65R1LB_a44
22
22
  netra/instrumentation/google_genai/version.py,sha256=Hww1duZrC8kYK7ThBSQVyz0HNOb0ys_o8Pln-wVQ1hI,23
23
23
  netra/instrumentation/httpx/__init__.py,sha256=w1su_eQP_w5ZJHq0Lf-4miF5zM4OOW0ItmRp0wi85Ew,19388
24
24
  netra/instrumentation/httpx/version.py,sha256=ZRQKbgDaGz_yuLk-cUKuk6ZBKCSRKZC8nQd041NRNXk,23
25
- netra/instrumentation/instruments.py,sha256=JJF8J2O2Xd3w3k33ZYxpFNrwWgl_veRNxV6QUFCsFn0,4301
25
+ netra/instrumentation/instruments.py,sha256=O6MI_BO-5EBkVqI-dr5eqhYnk8mP5QEpI0RWJ7Fe3FQ,4349
26
+ netra/instrumentation/litellm/__init__.py,sha256=H9FsdEq-CL39zbl_dLm8D43-D1vAjoNqFTBpbmZsVXs,6740
27
+ netra/instrumentation/litellm/version.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
28
+ netra/instrumentation/litellm/wrappers.py,sha256=H_UG0et6PUmj6CQagvNzbs_WodNTMruzzGOHhedmTko,22840
26
29
  netra/instrumentation/mistralai/__init__.py,sha256=RE0b-rS6iXdoynJMFKHL9s97eYo5HghrJa013fR4ZhI,18910
27
30
  netra/instrumentation/mistralai/config.py,sha256=XCyo3mk30qkvqyCqeTrKwROahu0gcOEwmbDLOo53J5k,121
28
31
  netra/instrumentation/mistralai/utils.py,sha256=nhdIer5gJFxuGwg8FCT222hggDHeMQDhJctnDSwLqcc,894
@@ -37,14 +40,15 @@ netra/instrumentation/pydantic_ai/wrappers.py,sha256=6cfIRvELBS4d9G9TttNYcHGueNI
37
40
  netra/instrumentation/weaviate/__init__.py,sha256=EOlpWxobOLHYKqo_kMct_7nu26x1hr8qkeG5_h99wtg,4330
38
41
  netra/instrumentation/weaviate/version.py,sha256=PiCZHjonujPbnIn0KmD3Yl68hrjPRG_oKe5vJF3mmG8,24
39
42
  netra/pii.py,sha256=Rn4SjgTJW_aw9LcbjLuMqF3fKd9b1ndlYt1CaK51Ge0,33125
40
- netra/processors/__init__.py,sha256=wfnSskRBtMT90hO7LqFJoEW374LgoH_gnTxhynqtByI,109
43
+ netra/processors/__init__.py,sha256=2E18QGVwVl8nEpT-EViEZqGLdVVejMTEH1wZNugDmYU,230
44
+ netra/processors/instrumentation_span_processor.py,sha256=Ef5FTr8O5FLHcIkBAW3ueU1nlkV2DuOi-y5iIwHzldQ,4252
41
45
  netra/processors/session_span_processor.py,sha256=qcsBl-LnILWefsftI8NQhXDGb94OWPc8LvzhVA0JS_c,2432
42
46
  netra/scanner.py,sha256=kyDpeZiscCPb6pjuhS-sfsVj-dviBFRepdUWh0sLoEY,11554
43
- netra/session_manager.py,sha256=Ks8A6B9RYe0tV8PeWYuN0M-UMZqa47uquuw6D7C1vCE,6701
44
- netra/span_wrapper.py,sha256=ec2WLYTRLZ02WSSCYEsMn1PgUGVji9rFyq_CRCV9rog,7388
45
- netra/tracer.py,sha256=In5QPVLz_6BxrolWpav9EuR9_hirD2UUIlyY75QUaKk,3450
46
- netra/version.py,sha256=A-lFHZ4YpCrWZ6nw3tlt_yurFJ00mInm3gR6hz51Eww,23
47
- netra_sdk-0.1.29.dist-info/LICENCE,sha256=8B_UoZ-BAl0AqiHAHUETCgd3I2B9yYJ1WEQtVb_qFMA,11359
48
- netra_sdk-0.1.29.dist-info/METADATA,sha256=XKGT69ygEEA6hbSinO1heKikakeIngiMqotgwQbFyeQ,28151
49
- netra_sdk-0.1.29.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
50
- netra_sdk-0.1.29.dist-info/RECORD,,
47
+ netra/session_manager.py,sha256=AoQa-k4dFcq7PeOD8G8DNzhLzL1JrHUW6b_y8mRyTQo,10255
48
+ netra/span_wrapper.py,sha256=lGuV1F4Q5I_swIoIof5myzOQCFmGFdtrpgfQt7dTTus,8105
49
+ netra/tracer.py,sha256=YiuijB_5DBOLVgE39Lj3thWVmUqHLcqbdFVB0HGovW0,3543
50
+ netra/version.py,sha256=i-fDEsQ0iAiPKXFaj9eERDqcxl3BqNnavaCEqpNxmVI,23
51
+ netra_sdk-0.1.31.dist-info/LICENCE,sha256=8B_UoZ-BAl0AqiHAHUETCgd3I2B9yYJ1WEQtVb_qFMA,11359
52
+ netra_sdk-0.1.31.dist-info/METADATA,sha256=VvltGCy_nbt-TRB91KiH_hu6YC4CceY_uQW-UYXT7NE,28196
53
+ netra_sdk-0.1.31.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
54
+ netra_sdk-0.1.31.dist-info/RECORD,,