netra-sdk 0.1.34__py3-none-any.whl → 0.1.36__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
@@ -284,5 +284,18 @@ class Netra:
284
284
  if value:
285
285
  SessionManager.set_attribute_on_target_span(f"{Config.LIBRARY_NAME}.span.output", value, span_name)
286
286
 
287
+ @classmethod
288
+ def set_prompt(cls, value: Any, span_name: Optional[str] = None) -> None:
289
+ """
290
+ Set custom attribute `netra.span.prompt` on a target span.
291
+
292
+ Args:
293
+ value: Prompt payload to record (string or JSON-serializable object)
294
+ span_name: Optional. When provided, sets the attribute on the span registered
295
+ with this name. Otherwise sets on the active span.
296
+ """
297
+ if value:
298
+ SessionManager.set_attribute_on_target_span(f"{Config.LIBRARY_NAME}.span.prompt", value, span_name)
299
+
287
300
 
288
301
  __all__ = ["Netra", "UsageModel", "ActionModel"]
netra/decorators.py CHANGED
@@ -8,7 +8,26 @@ import functools
8
8
  import inspect
9
9
  import json
10
10
  import logging
11
- from typing import Any, Awaitable, Callable, Dict, Optional, ParamSpec, Tuple, TypeVar, Union, cast
11
+ from typing import (
12
+ Any,
13
+ AsyncGenerator,
14
+ Awaitable,
15
+ Callable,
16
+ Dict,
17
+ Generator,
18
+ Optional,
19
+ ParamSpec,
20
+ Tuple,
21
+ TypeVar,
22
+ Union,
23
+ cast,
24
+ )
25
+
26
+ try:
27
+ # Optional import: only present in FastAPI/Starlette environments
28
+ from starlette.responses import StreamingResponse
29
+ except Exception: # pragma: no cover - starlette may not be installed in some environments
30
+ StreamingResponse = None
12
31
 
13
32
  from opentelemetry import trace
14
33
 
@@ -73,6 +92,176 @@ def _add_output_attributes(span: trace.Span, result: Any) -> None:
73
92
  span.set_attribute(f"{Config.LIBRARY_NAME}.entity.output_error", str(e))
74
93
 
75
94
 
95
+ def _is_streaming_response(obj: Any) -> bool:
96
+ """Return True if obj is a Starlette StreamingResponse instance."""
97
+ if StreamingResponse is None:
98
+ return False
99
+ try:
100
+ return isinstance(obj, StreamingResponse)
101
+ except Exception:
102
+ return False
103
+
104
+
105
+ def _is_async_generator(obj: Any) -> bool:
106
+ return inspect.isasyncgen(obj)
107
+
108
+
109
+ def _is_sync_generator(obj: Any) -> bool:
110
+ return inspect.isgenerator(obj)
111
+
112
+
113
+ def _wrap_async_generator_with_span(
114
+ span: trace.Span,
115
+ agen: AsyncGenerator[Any, None],
116
+ span_name: str,
117
+ entity_type: str,
118
+ ) -> AsyncGenerator[Any, None]:
119
+ """Wrap an async generator so the span remains current for the full iteration and ends afterwards."""
120
+
121
+ async def _wrapped() -> AsyncGenerator[Any, None]:
122
+ # Activate span for the entire iteration
123
+ with trace.use_span(span, end_on_exit=False):
124
+ try:
125
+ async for item in agen:
126
+ yield item
127
+ except Exception as e:
128
+ try:
129
+ span.set_attribute(f"{Config.LIBRARY_NAME}.entity.error", str(e))
130
+ span.record_exception(e)
131
+ finally:
132
+ span.end()
133
+ # De-register and pop entity at the very end for streaming lifecycle
134
+ try:
135
+ SessionManager.unregister_span(span_name, span)
136
+ except Exception:
137
+ logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
138
+ SessionManager.pop_entity(entity_type)
139
+ raise
140
+ else:
141
+ # Normal completion
142
+ span.end()
143
+ try:
144
+ SessionManager.unregister_span(span_name, span)
145
+ except Exception:
146
+ logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
147
+ SessionManager.pop_entity(entity_type)
148
+
149
+ return _wrapped()
150
+
151
+
152
+ def _wrap_sync_generator_with_span(
153
+ span: trace.Span,
154
+ gen: Generator[Any, None, None],
155
+ span_name: str,
156
+ entity_type: str,
157
+ ) -> Generator[Any, None, None]:
158
+ """Wrap a sync generator so the span remains current for the full iteration and ends afterwards."""
159
+
160
+ def _wrapped() -> Generator[Any, None, None]:
161
+ with trace.use_span(span, end_on_exit=False):
162
+ try:
163
+ for item in gen:
164
+ yield item
165
+ except Exception as e:
166
+ try:
167
+ span.set_attribute(f"{Config.LIBRARY_NAME}.entity.error", str(e))
168
+ span.record_exception(e)
169
+ finally:
170
+ span.end()
171
+ try:
172
+ SessionManager.unregister_span(span_name, span)
173
+ except Exception:
174
+ logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
175
+ SessionManager.pop_entity(entity_type)
176
+ raise
177
+ else:
178
+ span.end()
179
+ try:
180
+ SessionManager.unregister_span(span_name, span)
181
+ except Exception:
182
+ logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
183
+ SessionManager.pop_entity(entity_type)
184
+
185
+ return _wrapped()
186
+
187
+
188
+ def _wrap_streaming_response_with_span(
189
+ span: trace.Span,
190
+ resp: Any,
191
+ span_name: str,
192
+ entity_type: str,
193
+ ) -> Any:
194
+ """Wrap StreamingResponse.body_iterator with a generator that keeps span current and ends it afterwards."""
195
+ try:
196
+ body_iter = getattr(resp, "body_iterator", None)
197
+ if body_iter is None:
198
+ return resp
199
+ # Async iterator
200
+ if inspect.isasyncgen(body_iter) or hasattr(body_iter, "__aiter__"):
201
+
202
+ async def _aiter_wrapper(): # type: ignore[no-untyped-def]
203
+ with trace.use_span(span, end_on_exit=False):
204
+ try:
205
+ async for chunk in body_iter:
206
+ yield chunk
207
+ except Exception as e:
208
+ try:
209
+ span.set_attribute(f"{Config.LIBRARY_NAME}.entity.error", str(e))
210
+ span.record_exception(e)
211
+ finally:
212
+ span.end()
213
+ try:
214
+ SessionManager.unregister_span(span_name, span)
215
+ except Exception:
216
+ logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
217
+ SessionManager.pop_entity(entity_type)
218
+ raise
219
+ else:
220
+ span.end()
221
+ try:
222
+ SessionManager.unregister_span(span_name, span)
223
+ except Exception:
224
+ logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
225
+ SessionManager.pop_entity(entity_type)
226
+
227
+ resp.body_iterator = _aiter_wrapper() # type: ignore[no-untyped-call]
228
+ return resp
229
+
230
+ # Sync iterator
231
+ if inspect.isgenerator(body_iter) or hasattr(body_iter, "__iter__"):
232
+
233
+ def _iter_wrapper(): # type: ignore[no-untyped-def]
234
+ with trace.use_span(span, end_on_exit=False):
235
+ try:
236
+ for chunk in body_iter:
237
+ yield chunk
238
+ except Exception as e:
239
+ try:
240
+ span.set_attribute(f"{Config.LIBRARY_NAME}.entity.error", str(e))
241
+ span.record_exception(e)
242
+ finally:
243
+ span.end()
244
+ try:
245
+ SessionManager.unregister_span(span_name, span)
246
+ except Exception:
247
+ logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
248
+ SessionManager.pop_entity(entity_type)
249
+ raise
250
+ else:
251
+ span.end()
252
+ try:
253
+ SessionManager.unregister_span(span_name, span)
254
+ except Exception:
255
+ logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
256
+ SessionManager.pop_entity(entity_type)
257
+
258
+ resp.body_iterator = _iter_wrapper() # type: ignore[no-untyped-call]
259
+ return resp
260
+ except Exception:
261
+ logger.exception("Failed to wrap StreamingResponse with span '%s'", span_name)
262
+ return resp
263
+
264
+
76
265
  def _create_function_wrapper(func: Callable[P, R], entity_type: str, name: Optional[str] = None) -> Callable[P, R]:
77
266
  module_name = func.__name__
78
267
  is_async = inspect.iscoroutinefunction(func)
@@ -82,33 +271,50 @@ def _create_function_wrapper(func: Callable[P, R], entity_type: str, name: Optio
82
271
 
83
272
  @functools.wraps(func)
84
273
  async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
85
- # Push entity to stack before span starts so SessionSpanProcessor can capture it
274
+ # Push entity before span starts so processors can capture it
86
275
  SessionManager.push_entity(entity_type, span_name)
87
276
 
88
277
  tracer = trace.get_tracer(module_name)
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
-
278
+ span = tracer.start_span(span_name)
279
+ # Register and activate span
280
+ try:
281
+ SessionManager.register_span(span_name, span)
282
+ SessionManager.set_current_span(span)
283
+ except Exception:
284
+ logger.exception("Failed to register span '%s' with SessionManager", span_name)
285
+
286
+ with trace.use_span(span, end_on_exit=False):
97
287
  _add_span_attributes(span, func, args, kwargs, entity_type)
98
288
  try:
99
289
  result = await cast(Awaitable[Any], func(*args, **kwargs))
100
- _add_output_attributes(span, result)
101
- return result
102
290
  except Exception as e:
103
291
  span.set_attribute(f"{Config.LIBRARY_NAME}.entity.error", str(e))
104
- raise
105
- finally:
106
- # Unregister and pop entity from stack after function call is done
292
+ span.record_exception(e)
293
+ span.end()
107
294
  try:
108
295
  SessionManager.unregister_span(span_name, span)
109
296
  except Exception:
110
297
  logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
111
298
  SessionManager.pop_entity(entity_type)
299
+ raise
300
+
301
+ # If result is streaming, defer span end to when stream completes
302
+ if _is_streaming_response(result):
303
+ return _wrap_streaming_response_with_span(span, result, span_name, entity_type)
304
+ if _is_async_generator(result):
305
+ return _wrap_async_generator_with_span(span, result, span_name, entity_type)
306
+ if _is_sync_generator(result):
307
+ return _wrap_sync_generator_with_span(span, result, span_name, entity_type)
308
+
309
+ # Non-streaming: finalize now
310
+ _add_output_attributes(span, result)
311
+ span.end()
312
+ try:
313
+ SessionManager.unregister_span(span_name, span)
314
+ except Exception:
315
+ logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
316
+ SessionManager.pop_entity(entity_type)
317
+ return result
112
318
 
113
319
  return cast(Callable[P, R], async_wrapper)
114
320
 
@@ -116,33 +322,50 @@ def _create_function_wrapper(func: Callable[P, R], entity_type: str, name: Optio
116
322
 
117
323
  @functools.wraps(func)
118
324
  def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
119
- # Push entity to stack before span starts so SessionSpanProcessor can capture it
325
+ # Push entity before span starts so processors can capture it
120
326
  SessionManager.push_entity(entity_type, span_name)
121
327
 
122
328
  tracer = trace.get_tracer(module_name)
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
-
329
+ span = tracer.start_span(span_name)
330
+ # Register and activate span
331
+ try:
332
+ SessionManager.register_span(span_name, span)
333
+ SessionManager.set_current_span(span)
334
+ except Exception:
335
+ logger.exception("Failed to register span '%s' with SessionManager", span_name)
336
+
337
+ with trace.use_span(span, end_on_exit=False):
131
338
  _add_span_attributes(span, func, args, kwargs, entity_type)
132
339
  try:
133
340
  result = func(*args, **kwargs)
134
- _add_output_attributes(span, result)
135
- return result
136
341
  except Exception as e:
137
342
  span.set_attribute(f"{Config.LIBRARY_NAME}.entity.error", str(e))
138
- raise
139
- finally:
140
- # Unregister and pop entity from stack after function call is done
343
+ span.record_exception(e)
344
+ span.end()
141
345
  try:
142
346
  SessionManager.unregister_span(span_name, span)
143
347
  except Exception:
144
348
  logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
145
349
  SessionManager.pop_entity(entity_type)
350
+ raise
351
+
352
+ # If result is streaming, defer span end to when stream completes
353
+ if _is_streaming_response(result):
354
+ return _wrap_streaming_response_with_span(span, result, span_name, entity_type)
355
+ if _is_async_generator(result):
356
+ return _wrap_async_generator_with_span(span, result, span_name, entity_type) # type: ignore[arg-type]
357
+ if _is_sync_generator(result):
358
+ return _wrap_sync_generator_with_span(span, result, span_name, entity_type) # type: ignore[arg-type]
359
+
360
+ # Non-streaming: finalize now
361
+ _add_output_attributes(span, result)
362
+ span.end()
363
+ try:
364
+ SessionManager.unregister_span(span_name, span)
365
+ except Exception:
366
+ logger.exception("Failed to unregister span '%s' from SessionManager", span_name)
367
+ SessionManager.pop_entity(entity_type)
368
+ return result
146
369
 
147
370
  return cast(Callable[P, R], sync_wrapper)
148
371
 
netra/session_manager.py CHANGED
@@ -31,6 +31,10 @@ class SessionManager:
31
31
  # Span registry: name -> stack of spans (most-recent last)
32
32
  _spans_by_name: Dict[str, List[trace.Span]] = {}
33
33
 
34
+ # Global stack of active spans in creation order (oldest first, newest last)
35
+ # Maintained for spans registered via SessionManager (e.g., SpanWrapper)
36
+ _active_spans: List[trace.Span] = []
37
+
34
38
  @classmethod
35
39
  def set_current_span(cls, span: Optional[trace.Span]) -> None:
36
40
  """
@@ -62,6 +66,8 @@ class SessionManager:
62
66
  cls._spans_by_name[name] = [span]
63
67
  else:
64
68
  stack.append(span)
69
+ # Track globally as active
70
+ cls._active_spans.append(span)
65
71
  except Exception:
66
72
  logger.exception("Failed to register span '%s'", name)
67
73
 
@@ -81,6 +87,11 @@ class SessionManager:
81
87
  break
82
88
  if not stack:
83
89
  cls._spans_by_name.pop(name, None)
90
+ # Also remove from global active list (remove last matching instance)
91
+ for i in range(len(cls._active_spans) - 1, -1, -1):
92
+ if cls._active_spans[i] is span:
93
+ cls._active_spans.pop(i)
94
+ break
84
95
  except Exception:
85
96
  logger.exception("Failed to unregister span '%s'", name)
86
97
 
@@ -240,12 +251,13 @@ class SessionManager:
240
251
  @classmethod
241
252
  def set_attribute_on_target_span(cls, attr_key: str, attr_value: Any, span_name: Optional[str] = None) -> None:
242
253
  """
243
- Best-effort setter to annotate the active span with the provided attribute.
254
+ Best-effort setter to annotate a target span with the provided attribute.
244
255
 
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`.
256
+ Behavior:
257
+ - If span_name is provided, set the attribute on the span registered with that name.
258
+ - If no span_name is provided, attempt to set the attribute on the SDK root span
259
+ (created when Netra.init(enable_root_span=True)). If the root span is unavailable,
260
+ fall back to the currently active span (OTel current span or SDK-managed current span).
249
261
  """
250
262
  try:
251
263
  # Convert attribute value to a JSON-safe string representation
@@ -268,10 +280,41 @@ class SessionManager:
268
280
  target.set_attribute(attr_key, attr_str)
269
281
  return
270
282
 
271
- # Otherwise annotate the active span
283
+ # Otherwise, attempt to set on the root-most span in the current trace
284
+ candidate = None
285
+
286
+ # Determine current trace_id from the active/current span
272
287
  current_span = trace.get_current_span()
273
288
  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()
289
+ base_span = current_span if has_valid_current else cls.get_current_span()
290
+ trace_id: Optional[int] = None
291
+ try:
292
+ if base_span is not None and hasattr(base_span, "get_span_context"):
293
+ sc = base_span.get_span_context()
294
+ trace_id = getattr(sc, "trace_id", None)
295
+ except Exception:
296
+ trace_id = None
297
+
298
+ # Find the earliest active span in this process that belongs to the same trace
299
+ if trace_id is not None:
300
+ try:
301
+ for s in cls._active_spans:
302
+ if s is None:
303
+ continue
304
+ if not getattr(s, "is_recording", lambda: False)():
305
+ continue
306
+ sc = getattr(s, "get_span_context", lambda: None)()
307
+ if sc is None:
308
+ continue
309
+ if getattr(sc, "trace_id", None) == trace_id:
310
+ candidate = s
311
+ break
312
+ except Exception:
313
+ candidate = None
314
+
315
+ # Fallback to the current active span if no root-most could be found
316
+ if candidate is None:
317
+ candidate = base_span
275
318
  if candidate is None:
276
319
  logger.debug("No active span found to set attribute %s", attr_key)
277
320
  return
netra/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.34"
1
+ __version__ = "0.1.36"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: netra-sdk
3
- Version: 0.1.34
3
+ Version: 0.1.36
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
@@ -1,10 +1,10 @@
1
- netra/__init__.py,sha256=bBnv8InguoqBleHuA5KNU145eozdPOooxQrIMaKgJ5c,10942
1
+ netra/__init__.py,sha256=V6PkE_FPhNBcBDZWt9MuSItYplRcr0MfnBp8o2vZyUw,11519
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
6
  netra/config.py,sha256=51m8R0NoOrw58gMV7arOniEuFdJ7EIu3PNdFtIQ5xfg,6893
7
- netra/decorators.py,sha256=yuQP02sdvTRIYkv-myNcP8q7dmPq3ME1AJZxJtryayI,8720
7
+ netra/decorators.py,sha256=qZFHrwdj10FsTFqggo3XjdGB12aMxsrrDMMmslDqZ-0,17424
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
@@ -45,11 +45,11 @@ netra/processors/instrumentation_span_processor.py,sha256=Ef5FTr8O5FLHcIkBAW3ueU
45
45
  netra/processors/scrubbing_span_processor.py,sha256=dJ86Ncmjvmrhm_uAdGTwcGvRpZbVVWqD9AOFwEMWHZY,6701
46
46
  netra/processors/session_span_processor.py,sha256=qcsBl-LnILWefsftI8NQhXDGb94OWPc8LvzhVA0JS_c,2432
47
47
  netra/scanner.py,sha256=kyDpeZiscCPb6pjuhS-sfsVj-dviBFRepdUWh0sLoEY,11554
48
- netra/session_manager.py,sha256=AoQa-k4dFcq7PeOD8G8DNzhLzL1JrHUW6b_y8mRyTQo,10255
48
+ netra/session_manager.py,sha256=pyhtWYwFkrieNfrVFxSqxDsErPZzxPIt94zqvABLTAI,12229
49
49
  netra/span_wrapper.py,sha256=IygQX78xQRlL_Z1MfKfUbv0okihx92qNClnRlYFtRNc,8004
50
50
  netra/tracer.py,sha256=FJO8Cine-WL9K_4wn6RVjQOgX6c1JCp_8QowUbRSVHk,7718
51
- netra/version.py,sha256=79r5jd-MqbhXLbIBDVBqUJvhvcucjkaId96r46KF18I,23
52
- netra_sdk-0.1.34.dist-info/LICENCE,sha256=8B_UoZ-BAl0AqiHAHUETCgd3I2B9yYJ1WEQtVb_qFMA,11359
53
- netra_sdk-0.1.34.dist-info/METADATA,sha256=Wmf0VwjOEEmgtJcNuhmsFlgDlAT2e9RxXbY73TQl-CU,28210
54
- netra_sdk-0.1.34.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
55
- netra_sdk-0.1.34.dist-info/RECORD,,
51
+ netra/version.py,sha256=z6JYG2yALo9HI8zZMbgirtKP92kE14gI-nkfBP7Sf24,23
52
+ netra_sdk-0.1.36.dist-info/LICENCE,sha256=8B_UoZ-BAl0AqiHAHUETCgd3I2B9yYJ1WEQtVb_qFMA,11359
53
+ netra_sdk-0.1.36.dist-info/METADATA,sha256=rfE5HHEwlUD7Z9HvYF6hii6GhQ7k3Mdvr2OmnmbXFqI,28210
54
+ netra_sdk-0.1.36.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
55
+ netra_sdk-0.1.36.dist-info/RECORD,,