netra-sdk 0.1.36__tar.gz → 0.1.38__tar.gz

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.

Files changed (56) hide show
  1. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/PKG-INFO +1 -1
  2. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/__init__.py +3 -2
  3. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/litellm/wrappers.py +114 -102
  4. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/processors/instrumentation_span_processor.py +11 -8
  5. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/session_manager.py +56 -3
  6. netra_sdk-0.1.38/netra/version.py +1 -0
  7. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/pyproject.toml +1 -1
  8. netra_sdk-0.1.36/netra/version.py +0 -1
  9. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/LICENCE +0 -0
  10. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/README.md +0 -0
  11. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/anonymizer/__init__.py +0 -0
  12. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/anonymizer/anonymizer.py +0 -0
  13. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/anonymizer/base.py +0 -0
  14. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/anonymizer/fp_anonymizer.py +0 -0
  15. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/config.py +0 -0
  16. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/decorators.py +0 -0
  17. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/exceptions/__init__.py +0 -0
  18. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/exceptions/injection.py +0 -0
  19. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/exceptions/pii.py +0 -0
  20. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/input_scanner.py +0 -0
  21. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/__init__.py +0 -0
  22. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/aiohttp/__init__.py +0 -0
  23. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/aiohttp/version.py +0 -0
  24. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/cohere/__init__.py +0 -0
  25. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/cohere/version.py +0 -0
  26. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/fastapi/__init__.py +0 -0
  27. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/fastapi/version.py +0 -0
  28. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/google_genai/__init__.py +0 -0
  29. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/google_genai/config.py +0 -0
  30. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/google_genai/utils.py +0 -0
  31. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/google_genai/version.py +0 -0
  32. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/httpx/__init__.py +0 -0
  33. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/httpx/version.py +0 -0
  34. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/instruments.py +0 -0
  35. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/litellm/__init__.py +0 -0
  36. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/litellm/version.py +0 -0
  37. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/mistralai/__init__.py +0 -0
  38. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/mistralai/config.py +0 -0
  39. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/mistralai/utils.py +0 -0
  40. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/mistralai/version.py +0 -0
  41. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/openai/__init__.py +0 -0
  42. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/openai/version.py +0 -0
  43. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/openai/wrappers.py +0 -0
  44. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/pydantic_ai/__init__.py +0 -0
  45. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/pydantic_ai/utils.py +0 -0
  46. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/pydantic_ai/version.py +0 -0
  47. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/pydantic_ai/wrappers.py +0 -0
  48. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/weaviate/__init__.py +0 -0
  49. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/instrumentation/weaviate/version.py +0 -0
  50. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/pii.py +0 -0
  51. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/processors/__init__.py +0 -0
  52. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/processors/scrubbing_span_processor.py +0 -0
  53. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/processors/session_span_processor.py +0 -0
  54. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/scanner.py +0 -0
  55. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/span_wrapper.py +0 -0
  56. {netra_sdk-0.1.36 → netra_sdk-0.1.38}/netra/tracer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: netra-sdk
3
- Version: 0.1.36
3
+ Version: 0.1.38
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
@@ -222,16 +222,17 @@ class Netra:
222
222
  @classmethod
223
223
  def set_custom_attributes(cls, key: str, value: Any) -> None:
224
224
  """
225
- Set custom attributes context in the current OpenTelemetry context.
225
+ Set a custom attribute on the currently active OpenTelemetry span only.
226
226
 
227
227
  Args:
228
228
  key: Custom attribute key
229
229
  value: Custom attribute value
230
230
  """
231
231
  if key and value:
232
- SessionManager.set_session_context("custom_attributes", {key: value})
232
+ SessionManager.set_attribute_on_active_span(f"{Config.LIBRARY_NAME}.custom.{key}", value)
233
233
  else:
234
234
  logger.warning("Both key and value must be provided for custom attributes.")
235
+ return
235
236
 
236
237
  @classmethod
237
238
  def set_custom_event(cls, event_name: str, attributes: Any) -> None:
@@ -47,115 +47,127 @@ def set_request_attributes(span: Span, kwargs: Dict[str, Any], operation_type: s
47
47
  """Set request attributes on span"""
48
48
  if not span.is_recording():
49
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"])
50
+ try:
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(
76
+ f"{SpanAttributes.LLM_PROMPTS}.{index}.content", str(message.get("content", ""))
77
+ )
78
+
79
+ # Embedding specific attributes
80
+ if operation_type == "embedding" and kwargs.get("input"):
81
+ input_data = kwargs["input"]
82
+ if isinstance(input_data, str):
83
+ span.set_attribute(f"{SpanAttributes.LLM_PROMPTS}.0.content", input_data)
84
+ elif isinstance(input_data, list):
85
+ for index, text in enumerate(input_data):
86
+ if isinstance(text, str):
87
+ span.set_attribute(f"{SpanAttributes.LLM_PROMPTS}.{index}.content", text)
88
+
89
+ # Image generation specific attributes
90
+ if operation_type == "image_generation":
91
+ if kwargs.get("prompt"):
92
+ span.set_attribute("gen_ai.prompt", kwargs["prompt"])
93
+ if kwargs.get("n"):
94
+ span.set_attribute("gen_ai.request.n", kwargs["n"])
95
+ if kwargs.get("size"):
96
+ span.set_attribute("gen_ai.request.size", kwargs["size"])
97
+ if kwargs.get("quality"):
98
+ span.set_attribute("gen_ai.request.quality", kwargs["quality"])
99
+ if kwargs.get("style"):
100
+ span.set_attribute("gen_ai.request.style", kwargs["style"])
101
+ except Exception as e:
102
+ logger.error(f"Failed to set attributes for LiteLLM span: {e}")
99
103
 
100
104
 
101
105
  def set_response_attributes(span: Span, response_dict: Dict[str, Any], operation_type: str) -> None:
102
106
  """Set response attributes on span"""
103
107
  if not span.is_recording():
104
108
  return
109
+ try:
110
+ if response_dict.get("model"):
111
+ span.set_attribute(f"{SpanAttributes.LLM_RESPONSE_MODEL}", response_dict["model"])
112
+
113
+ if response_dict.get("id"):
114
+ span.set_attribute("gen_ai.response.id", response_dict["id"])
115
+
116
+ # Usage information
117
+ usage = response_dict.get("usage", {})
118
+ if usage:
119
+ if usage.get("prompt_tokens"):
120
+ span.set_attribute(f"{SpanAttributes.LLM_USAGE_PROMPT_TOKENS}", usage["prompt_tokens"])
121
+ if usage.get("completion_tokens"):
122
+ span.set_attribute(f"{SpanAttributes.LLM_USAGE_COMPLETION_TOKENS}", usage["completion_tokens"])
123
+ if usage.get("cache_read_input_tokens"):
124
+ span.set_attribute(
125
+ f"{SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS}", usage["cache_read_input_tokens"]
126
+ )
127
+ if usage.get("cache_creation_input_tokens"):
128
+ span.set_attribute("gen_ai.usage.cache_creation_input_tokens", usage["cache_creation_input_tokens"])
129
+ if usage.get("total_tokens"):
130
+ span.set_attribute(f"{SpanAttributes.LLM_USAGE_TOTAL_TOKENS}", usage["total_tokens"])
131
+
132
+ # Chat completion response content
133
+ if operation_type == "chat":
134
+ choices = response_dict.get("choices", [])
135
+ for index, choice in enumerate(choices):
136
+ if choice.get("message", {}).get("role"):
137
+ span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{index}.role", choice["message"]["role"])
138
+ if choice.get("message", {}).get("content"):
139
+ span.set_attribute(
140
+ f"{SpanAttributes.LLM_COMPLETIONS}.{index}.content", choice["message"]["content"]
141
+ )
142
+ if choice.get("finish_reason"):
143
+ span.set_attribute(
144
+ f"{SpanAttributes.LLM_COMPLETIONS}.{index}.finish_reason", choice["finish_reason"]
145
+ )
105
146
 
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"])
147
+ # Embedding response content
148
+ elif operation_type == "embedding":
149
+ data = response_dict.get("data", [])
150
+ for index, embedding_data in enumerate(data):
151
+ if embedding_data.get("index") is not None:
152
+ span.set_attribute(f"gen_ai.response.embeddings.{index}.index", embedding_data["index"])
153
+ if embedding_data.get("embedding"):
154
+ # Don't log the actual embedding vector, just its dimensions
155
+ embedding_vector = embedding_data["embedding"]
156
+ if isinstance(embedding_vector, list):
157
+ span.set_attribute(f"gen_ai.response.embeddings.{index}.dimensions", len(embedding_vector))
158
+
159
+ # Image generation response content
160
+ elif operation_type == "image_generation":
161
+ data = response_dict.get("data", [])
162
+ for index, image_data in enumerate(data):
163
+ if image_data.get("url"):
164
+ span.set_attribute(f"gen_ai.response.images.{index}.url", image_data["url"])
165
+ if image_data.get("b64_json"):
166
+ span.set_attribute(f"gen_ai.response.images.{index}.has_b64_json", True)
167
+ if image_data.get("revised_prompt"):
168
+ span.set_attribute(f"gen_ai.response.images.{index}.revised_prompt", image_data["revised_prompt"])
169
+ except Exception as e:
170
+ logger.error(f"Failed to set attributes for LiteLLM span: {e}")
159
171
 
160
172
 
161
173
  def completion_wrapper(tracer: Tracer) -> Callable[..., Any]:
@@ -30,13 +30,15 @@ class InstrumentationSpanProcessor(SpanProcessor): # type: ignore[misc]
30
30
  if scope is not None:
31
31
  name = getattr(scope, "name", None)
32
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
33
+ # Only truncate when coming from known namespaces
34
+ if name.startswith("opentelemetry.instrumentation.") or name.startswith("netra.instrumentation."):
35
+ try:
36
+ base = name.rsplit(".", 1)[-1].strip()
37
+ if base:
38
+ return base
39
+ except Exception:
40
+ pass
41
+ # Otherwise, return as-is
40
42
  return name
41
43
  return None
42
44
 
@@ -87,7 +89,8 @@ class InstrumentationSpanProcessor(SpanProcessor): # type: ignore[misc]
87
89
 
88
90
  # Set this span's instrumentation name
89
91
  name = self._detect_raw_instrumentation_name(span)
90
- if name and any(allowed in name for allowed in ALLOWED_INSTRUMENTATION_NAMES):
92
+ print(name)
93
+ if name in ALLOWED_INSTRUMENTATION_NAMES:
91
94
  span.set_attribute(f"{Config.LIBRARY_NAME}.instrumentation.name", name)
92
95
  except Exception:
93
96
  pass
@@ -196,9 +196,21 @@ class SessionManager:
196
196
  }
197
197
 
198
198
  @staticmethod
199
- def set_session_context(session_key: str, value: Union[str, Dict[str, str]]) -> None:
199
+ def set_session_context(
200
+ session_key: str,
201
+ value: Union[str, Dict[str, str]],
202
+ attach_globally: bool = False,
203
+ ) -> None:
200
204
  """
201
- Set session context attributes in the current OpenTelemetry baggage.
205
+ Set session context attributes in OpenTelemetry baggage.
206
+
207
+ Behavior:
208
+ - Adds values to baggage on the current context.
209
+ - To avoid context token mismatch errors, this method only attaches the
210
+ modified context when it is safe (no active recording span), unless
211
+ forced via attach_globally=True.
212
+ - When not attaching (e.g., inside an active span), it annotates the
213
+ current span with equivalent attributes for visibility.
202
214
 
203
215
  Args:
204
216
  session_key: Key to set in baggage (session_id, user_id, tenant_id, or custom_attributes)
@@ -219,7 +231,20 @@ class SessionManager:
219
231
  ctx = baggage.set_baggage("custom_keys", ",".join(custom_keys), ctx)
220
232
  for key, val in value.items():
221
233
  ctx = baggage.set_baggage(f"custom.{key}", str(val), ctx)
222
- otel_context.attach(ctx)
234
+
235
+ # Decide whether to attach globally. We only attach if there is no
236
+ # active recording span (safe point) or if the caller forces it.
237
+ current_span = trace.get_current_span()
238
+ has_active_span = bool(current_span and getattr(current_span, "is_recording", lambda: False)())
239
+ if attach_globally or not has_active_span:
240
+ otel_context.attach(ctx)
241
+ else:
242
+ # Best-effort: annotate the current span for observability
243
+ if isinstance(value, str) and value:
244
+ current_span.set_attribute(f"{Config.LIBRARY_NAME}.session.{session_key}", value)
245
+ elif isinstance(value, dict) and value and session_key == "custom_attributes":
246
+ for key, val in value.items():
247
+ current_span.set_attribute(f"{Config.LIBRARY_NAME}.session.custom.{key}", str(val))
223
248
  except Exception as e:
224
249
  logger.exception(f"Failed to set session context for key={session_key}: {e}")
225
250
 
@@ -321,3 +346,31 @@ class SessionManager:
321
346
  candidate.set_attribute(attr_key, attr_str)
322
347
  except Exception as e:
323
348
  logger.exception("Failed setting attribute %s: %s", attr_key, e)
349
+
350
+ @staticmethod
351
+ def set_attribute_on_active_span(attr_key: str, attr_value: Any) -> None:
352
+ """
353
+ Set an attribute strictly on the currently active OpenTelemetry span.
354
+
355
+ - Does not fall back to any root span.
356
+ - Does not mutate global baggage/context.
357
+ - If no active recording span is present, logs a warning and returns.
358
+ """
359
+ try:
360
+ span = trace.get_current_span()
361
+ if span and getattr(span, "is_recording", lambda: False)():
362
+ # Convert attr_value to a JSON-safe string if needed
363
+ try:
364
+ if isinstance(attr_value, str):
365
+ v = attr_value
366
+ else:
367
+ import json
368
+
369
+ v = json.dumps(attr_value)
370
+ except Exception:
371
+ v = str(attr_value)
372
+ span.set_attribute(attr_key, v)
373
+ else:
374
+ logger.warning("No active span to set attribute '%s'", attr_key)
375
+ except Exception:
376
+ logger.exception("Failed to set attribute '%s' on active span", attr_key)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.38"
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "netra-sdk"
7
- version = "0.1.36"
7
+ version = "0.1.38"
8
8
  description = "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."
9
9
  authors = [
10
10
  {name = "Sooraj Thomas",email = "sooraj@keyvalue.systems"}
@@ -1 +0,0 @@
1
- __version__ = "0.1.36"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes