netra-sdk 0.1.44__py3-none-any.whl → 0.1.46__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
@@ -1,7 +1,7 @@
1
1
  import atexit
2
2
  import logging
3
3
  import threading
4
- from typing import Any, Dict, List, Optional, Set
4
+ from typing import Any, Dict, List, Literal, Optional, Set
5
5
 
6
6
  from opentelemetry import context as context_api
7
7
  from opentelemetry import trace
@@ -14,7 +14,7 @@ from .config import Config
14
14
  # Instrumentor functions
15
15
  from .instrumentation import init_instrumentations
16
16
  from .session_manager import ConversationType, SessionManager
17
- from .span_wrapper import ActionModel, SpanWrapper, UsageModel
17
+ from .span_wrapper import ActionModel, SpanType, SpanWrapper, UsageModel
18
18
  from .tracer import Tracer
19
19
 
20
20
  # Package-level logger. Attach NullHandler by default so library does not emit logs
@@ -263,11 +263,12 @@ class Netra:
263
263
  name: str,
264
264
  attributes: Optional[Dict[str, str]] = None,
265
265
  module_name: str = "combat_sdk",
266
+ as_type: Optional[SpanType] = SpanType.SPAN,
266
267
  ) -> SpanWrapper:
267
268
  """
268
269
  Start a new session.
269
270
  """
270
- return SpanWrapper(name, attributes, module_name)
271
+ return SpanWrapper(name, attributes, module_name, as_type=as_type)
271
272
 
272
273
 
273
- __all__ = ["Netra", "UsageModel", "ActionModel"]
274
+ __all__ = ["Netra", "UsageModel", "ActionModel", "SpanType"]
netra/config.py CHANGED
@@ -30,7 +30,7 @@ class Config:
30
30
  # Maximum length for any attribute value (strings and bytes). Processors should honor this.
31
31
  ATTRIBUTE_MAX_LEN = 2000
32
32
  # Maximum length specifically for conversation entry content (strings or JSON when serialized)
33
- CONVERSATION_CONTENT_MAX_LEN = 1000
33
+ CONVERSATION_CONTENT_MAX_LEN = 2000
34
34
 
35
35
  def __init__(
36
36
  self,
netra/decorators.py CHANGED
@@ -33,6 +33,7 @@ from opentelemetry import trace
33
33
 
34
34
  from .config import Config
35
35
  from .session_manager import SessionManager
36
+ from .span_wrapper import SpanType
36
37
 
37
38
  logger = logging.getLogger(__name__)
38
39
 
@@ -262,7 +263,12 @@ def _wrap_streaming_response_with_span(
262
263
  return resp
263
264
 
264
265
 
265
- def _create_function_wrapper(func: Callable[P, R], entity_type: str, name: Optional[str] = None) -> Callable[P, R]:
266
+ def _create_function_wrapper(
267
+ func: Callable[P, R],
268
+ entity_type: str,
269
+ name: Optional[str] = None,
270
+ as_type: Optional[SpanType] = SpanType.SPAN,
271
+ ) -> Callable[P, R]:
266
272
  module_name = func.__name__
267
273
  is_async = inspect.iscoroutinefunction(func)
268
274
  span_name = name if name is not None else func.__name__
@@ -276,6 +282,14 @@ def _create_function_wrapper(func: Callable[P, R], entity_type: str, name: Optio
276
282
 
277
283
  tracer = trace.get_tracer(module_name)
278
284
  span = tracer.start_span(span_name)
285
+ # Set span type if provided
286
+
287
+ if not isinstance(as_type, SpanType):
288
+ raise ValueError(f"Invalid span type: {as_type}")
289
+ try:
290
+ span.set_attribute("netra.span.type", as_type.value)
291
+ except Exception:
292
+ pass
279
293
  # Register and activate span
280
294
  try:
281
295
  SessionManager.register_span(span_name, span)
@@ -327,6 +341,14 @@ def _create_function_wrapper(func: Callable[P, R], entity_type: str, name: Optio
327
341
 
328
342
  tracer = trace.get_tracer(module_name)
329
343
  span = tracer.start_span(span_name)
344
+ # Set span type if provided
345
+ if as_type is not None:
346
+ if not isinstance(as_type, SpanType):
347
+ raise ValueError(f"Invalid span type: {as_type}")
348
+ try:
349
+ span.set_attribute("netra.span.type", as_type.value)
350
+ except Exception:
351
+ pass
330
352
  # Register and activate span
331
353
  try:
332
354
  SessionManager.register_span(span_name, span)
@@ -370,7 +392,12 @@ def _create_function_wrapper(func: Callable[P, R], entity_type: str, name: Optio
370
392
  return cast(Callable[P, R], sync_wrapper)
371
393
 
372
394
 
373
- def _wrap_class_methods(cls: C, entity_type: str, name: Optional[str] = None) -> C:
395
+ def _wrap_class_methods(
396
+ cls: C,
397
+ entity_type: str,
398
+ name: Optional[str] = None,
399
+ as_type: Optional[SpanType] = SpanType.SPAN,
400
+ ) -> C:
374
401
  class_name = name if name is not None else cls.__name__
375
402
  for attr_name in cls.__dict__:
376
403
  attr = getattr(cls, attr_name)
@@ -378,7 +405,7 @@ def _wrap_class_methods(cls: C, entity_type: str, name: Optional[str] = None) ->
378
405
  continue
379
406
  if callable(attr) and inspect.isfunction(attr):
380
407
  method_span_name = f"{class_name}.{attr_name}"
381
- wrapped_method = _create_function_wrapper(attr, entity_type, method_span_name)
408
+ wrapped_method = _create_function_wrapper(attr, entity_type, method_span_name, as_type=as_type)
382
409
  setattr(cls, attr_name, wrapped_method)
383
410
  return cls
384
411
 
@@ -416,10 +443,10 @@ def task(
416
443
  ) -> Union[Callable[P, R], C, Callable[[Callable[P, R]], Callable[P, R]]]:
417
444
  def decorator(obj: Union[Callable[P, R], C]) -> Union[Callable[P, R], C]:
418
445
  if inspect.isclass(obj):
419
- return _wrap_class_methods(cast(C, obj), "task", name)
446
+ return _wrap_class_methods(cast(C, obj), "task", name, as_type=SpanType.TOOL)
420
447
  else:
421
448
  # When obj is a function, it should be type Callable[P, R]
422
- return _create_function_wrapper(cast(Callable[P, R], obj), "task", name)
449
+ return _create_function_wrapper(cast(Callable[P, R], obj), "task", name, as_type=SpanType.TOOL)
423
450
 
424
451
  if target is not None:
425
452
  return decorator(target)
@@ -427,14 +454,17 @@ def task(
427
454
 
428
455
 
429
456
  def span(
430
- target: Union[Callable[P, R], C, None] = None, *, name: Optional[str] = None
457
+ target: Union[Callable[P, R], C, None] = None,
458
+ *,
459
+ name: Optional[str] = None,
460
+ as_type: Optional[SpanType] = SpanType.SPAN,
431
461
  ) -> Union[Callable[P, R], C, Callable[[Callable[P, R]], Callable[P, R]]]:
432
462
  def decorator(obj: Union[Callable[P, R], C]) -> Union[Callable[P, R], C]:
433
463
  if inspect.isclass(obj):
434
- return _wrap_class_methods(cast(C, obj), "span", name)
464
+ return _wrap_class_methods(cast(C, obj), "span", name, as_type=as_type)
435
465
  else:
436
466
  # When obj is a function, it should be type Callable[P, R]
437
- return _create_function_wrapper(cast(Callable[P, R], obj), "span", name)
467
+ return _create_function_wrapper(cast(Callable[P, R], obj), "span", name, as_type=as_type)
438
468
 
439
469
  if target is not None:
440
470
  return decorator(target)
@@ -0,0 +1,3 @@
1
+ from netra.exporters.filtering_span_exporter import FilteringSpanExporter
2
+
3
+ __all__ = ["FilteringSpanExporter"]
@@ -0,0 +1,211 @@
1
+ import logging
2
+ from typing import Any, Dict, List, Sequence
3
+
4
+ from opentelemetry.sdk.trace import ReadableSpan
5
+ from opentelemetry.sdk.trace.export import (
6
+ SpanExporter,
7
+ SpanExportResult,
8
+ )
9
+
10
+ from netra.processors.local_filtering_span_processor import (
11
+ BLOCKED_LOCAL_PARENT_MAP,
12
+ )
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class FilteringSpanExporter(SpanExporter): # type: ignore[misc]
18
+ """
19
+ SpanExporter wrapper that filters out spans by name.
20
+
21
+ Matching rules:
22
+ - Exact match: pattern "Foo" blocks span.name == "Foo".
23
+ - Prefix match: pattern ending with '*' (e.g., "CloudSpanner.*") blocks spans whose
24
+ names start with the prefix before '*', e.g., "CloudSpanner.", "CloudSpanner.Query".
25
+ - Suffix match: pattern starting with '*' (e.g., "*.Query") blocks spans whose
26
+ names end with the suffix after '*', e.g., "DB.Query", "Search.Query".
27
+ """
28
+
29
+ def __init__(self, exporter: SpanExporter, patterns: Sequence[str]) -> None:
30
+ self._exporter = exporter
31
+ # Normalize once for efficient checks
32
+ exact: List[str] = []
33
+ prefixes: List[str] = []
34
+ suffixes: List[str] = []
35
+ for p in patterns:
36
+ if not p:
37
+ continue
38
+ if p.endswith("*") and not p.startswith("*"):
39
+ prefixes.append(p[:-1])
40
+ elif p.startswith("*") and not p.endswith("*"):
41
+ suffixes.append(p[1:])
42
+ else:
43
+ exact.append(p)
44
+ self._exact = set(exact)
45
+ self._prefixes = prefixes
46
+ self._suffixes = suffixes
47
+
48
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
49
+ filtered: List[ReadableSpan] = []
50
+ blocked_parent_map: Dict[Any, Any] = {}
51
+ for span in spans:
52
+ name = getattr(span, "name", None)
53
+ if name is None:
54
+ filtered.append(span)
55
+ continue
56
+
57
+ # Global blocking (configured patterns)
58
+ globally_blocked = self._is_blocked(name)
59
+
60
+ # Local per-span blocking via attribute set by LocalFilteringSpanProcessor
61
+ locally_blocked = False
62
+ try:
63
+ local_patterns = self._get_local_patterns(span)
64
+ if local_patterns:
65
+ locally_blocked = self._matches_any_pattern(name, local_patterns)
66
+ # Fallback: if processor explicitly marked the span as locally blocked
67
+ if not locally_blocked and self._has_local_block_flag(span):
68
+ locally_blocked = True
69
+ except Exception:
70
+ locally_blocked = False
71
+
72
+ if not (globally_blocked or locally_blocked):
73
+ filtered.append(span)
74
+ continue
75
+
76
+ # Collect mapping for reparenting children of the blocked span
77
+ span_context = getattr(span, "context", None)
78
+ span_id = getattr(span_context, "span_id", None) if span_context else None
79
+ if span_id is not None:
80
+ blocked_parent_map[span_id] = getattr(span, "parent", None)
81
+
82
+ # Merge with registry of locally blocked spans captured by processor to handle
83
+ # cases where children export before their blocked parent (SimpleSpanProcessor)
84
+ merged_map: Dict[Any, Any] = {}
85
+ try:
86
+ if BLOCKED_LOCAL_PARENT_MAP:
87
+ merged_map.update(BLOCKED_LOCAL_PARENT_MAP)
88
+ except Exception:
89
+ pass
90
+ merged_map.update(blocked_parent_map)
91
+
92
+ if merged_map:
93
+ self._reparent_blocked_children(filtered, merged_map)
94
+ if not filtered:
95
+ return SpanExportResult.SUCCESS
96
+ return self._exporter.export(filtered)
97
+
98
+ def _is_blocked(self, name: str) -> bool:
99
+ if name in self._exact:
100
+ return True
101
+ for pref in self._prefixes:
102
+ if name.startswith(pref):
103
+ return True
104
+ for suf in self._suffixes:
105
+ if name.endswith(suf):
106
+ return True
107
+ return False
108
+
109
+ def _get_local_patterns(self, span: ReadableSpan) -> List[str]:
110
+ """Fetch local-block patterns from span attributes set by LocalFilteringSpanProcessor."""
111
+ try:
112
+ attrs = getattr(span, "attributes", None)
113
+ if not attrs:
114
+ return []
115
+ value = None
116
+ # Prefer Mapping.get if available
117
+ try:
118
+ if hasattr(attrs, "get"):
119
+ value = attrs.get("netra.local_blocked_spans")
120
+ else:
121
+ value = attrs["netra.local_blocked_spans"]
122
+ except Exception:
123
+ value = None
124
+ if isinstance(value, (list, tuple)) and all(isinstance(v, str) for v in value):
125
+ return [v for v in value if v]
126
+ except Exception:
127
+ logger.debug("Failed reading local blocked patterns from span", exc_info=True)
128
+ return []
129
+
130
+ def _matches_any_pattern(self, name: str, patterns: Sequence[str]) -> bool:
131
+ for p in patterns:
132
+ if not p:
133
+ continue
134
+ if p.endswith("*") and not p.startswith("*"):
135
+ if name.startswith(p[:-1]):
136
+ return True
137
+ elif p.startswith("*") and not p.endswith("*"):
138
+ if name.endswith(p[1:]):
139
+ return True
140
+ else:
141
+ if name == p:
142
+ return True
143
+ return False
144
+
145
+ def _has_local_block_flag(self, span: ReadableSpan) -> bool:
146
+ try:
147
+ attrs = getattr(span, "attributes", None)
148
+ if not attrs:
149
+ return False
150
+ try:
151
+ if hasattr(attrs, "get"):
152
+ value = attrs.get("netra.local_blocked")
153
+ else:
154
+ value = attrs["netra.local_blocked"]
155
+ except Exception:
156
+ value = None
157
+ return bool(value) is True
158
+ except Exception:
159
+ return False
160
+
161
+ def _reparent_blocked_children(
162
+ self,
163
+ spans: Sequence[ReadableSpan],
164
+ blocked_parent_map: Dict[Any, Any],
165
+ ) -> None:
166
+ if not blocked_parent_map:
167
+ return
168
+
169
+ for span in spans:
170
+ parent_context = getattr(span, "parent", None)
171
+ if parent_context is None:
172
+ continue
173
+
174
+ updated_parent = parent_context
175
+ visited: set[Any] = set()
176
+ changed = False
177
+
178
+ while updated_parent is not None:
179
+ parent_span_id = getattr(updated_parent, "span_id", None)
180
+ if parent_span_id not in blocked_parent_map or parent_span_id in visited:
181
+ break
182
+ visited.add(parent_span_id)
183
+ updated_parent = blocked_parent_map[parent_span_id]
184
+ changed = True
185
+
186
+ if changed:
187
+ self._set_span_parent(span, updated_parent)
188
+
189
+ def _set_span_parent(self, span: ReadableSpan, parent: Any) -> None:
190
+ if hasattr(span, "_parent"):
191
+ try:
192
+ span._parent = parent
193
+ return
194
+ except Exception:
195
+ pass
196
+ try:
197
+ setattr(span, "parent", parent)
198
+ except Exception:
199
+ logger.debug("Failed to reparent span %s", getattr(span, "name", "<unknown>"), exc_info=True)
200
+
201
+ def shutdown(self) -> None:
202
+ try:
203
+ self._exporter.shutdown()
204
+ except Exception:
205
+ pass
206
+
207
+ def force_flush(self, timeout_millis: int = 30000) -> Any:
208
+ try:
209
+ return self._exporter.force_flush(timeout_millis)
210
+ except Exception:
211
+ return True
@@ -1,5 +1,11 @@
1
1
  from netra.processors.instrumentation_span_processor import InstrumentationSpanProcessor
2
+ from netra.processors.local_filtering_span_processor import LocalFilteringSpanProcessor
2
3
  from netra.processors.scrubbing_span_processor import ScrubbingSpanProcessor
3
4
  from netra.processors.session_span_processor import SessionSpanProcessor
4
5
 
5
- __all__ = ["SessionSpanProcessor", "InstrumentationSpanProcessor", "ScrubbingSpanProcessor"]
6
+ __all__ = [
7
+ "SessionSpanProcessor",
8
+ "InstrumentationSpanProcessor",
9
+ "ScrubbingSpanProcessor",
10
+ "LocalFilteringSpanProcessor",
11
+ ]
@@ -74,9 +74,6 @@ class InstrumentationSpanProcessor(SpanProcessor): # type: ignore[misc]
74
74
  truncated = self._truncate_value(value)
75
75
  # Forward to original
76
76
  original_set_attribute(key, truncated)
77
- # Special rule: if model key set, mark span as llm
78
- if key == "gen_ai.request.model":
79
- original_set_attribute(f"{Config.LIBRARY_NAME}.span.type", "llm")
80
77
  except Exception:
81
78
  # Best-effort; never break span
82
79
  try:
@@ -0,0 +1,155 @@
1
+ import json
2
+ import logging
3
+ from contextlib import contextmanager
4
+ from typing import List, Optional, Sequence
5
+
6
+ from opentelemetry import baggage
7
+ from opentelemetry import context as otel_context
8
+ from opentelemetry import trace
9
+ from opentelemetry.sdk.trace import SpanProcessor
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Baggage key to carry local blocked span patterns in the active context
14
+ _LOCAL_BLOCKED_SPANS_BAGGAGE_KEY = "netra.local_blocked_spans"
15
+ # Attribute key to copy resolved local blocked patterns onto each span
16
+ _LOCAL_BLOCKED_SPANS_ATTR_KEY = "netra.local_blocked_spans"
17
+
18
+ # Registry of locally blocked spans: span_id -> parent_context
19
+ # This lets exporters reparent children reliably even when children export before parents
20
+ BLOCKED_LOCAL_PARENT_MAP: dict[object, object] = {}
21
+
22
+
23
+ class LocalFilteringSpanProcessor(SpanProcessor): # type: ignore[misc]
24
+ """Propagates local blocked span patterns from baggage to span attributes.
25
+
26
+ - On span start, reads patterns from baggage key `_LOCAL_BLOCKED_SPANS_BAGGAGE_KEY` on the provided
27
+ `parent_context` and, if present, sets them on the span attribute `_LOCAL_BLOCKED_SPANS_ATTR_KEY`.
28
+ - This enables exporters (e.g., `FilteringSpanExporter`) to decide per-span blocking based on the
29
+ active context in which the span was created.
30
+ """
31
+
32
+ def on_start(self, span: trace.Span, parent_context: Optional[otel_context.Context] = None) -> None:
33
+ try:
34
+ # Use provided parent_context if available, otherwise fall back to current context
35
+ ctx_to_read = parent_context if parent_context is not None else otel_context.get_current()
36
+ raw = baggage.get_baggage(_LOCAL_BLOCKED_SPANS_BAGGAGE_KEY, context=ctx_to_read)
37
+ if not raw:
38
+ return
39
+ patterns: Optional[List[str]] = _decode_patterns(raw)
40
+ if patterns:
41
+ try:
42
+ span.set_attribute(_LOCAL_BLOCKED_SPANS_ATTR_KEY, patterns)
43
+ except Exception:
44
+ # Best-effort: never break span start
45
+ logger.debug("Failed setting local blocked patterns on span", exc_info=True)
46
+ # If this span matches the local patterns, record it as locally blocked for reparenting
47
+ try:
48
+ name = getattr(span, "name", None)
49
+ if isinstance(name, str) and name and _matches_any_pattern(name, patterns):
50
+ ctx = getattr(span, "context", None)
51
+ span_id = getattr(ctx, "span_id", None) if ctx else None
52
+ # Determine the parent SpanContext of this blocked span from the context
53
+ parent_span = trace.get_current_span(ctx_to_read)
54
+ parent_span_context = (
55
+ parent_span.get_span_context() if hasattr(parent_span, "get_span_context") else None
56
+ )
57
+ if span_id is not None and parent_span_context is not None:
58
+ BLOCKED_LOCAL_PARENT_MAP[span_id] = parent_span_context
59
+ # Mark on the span for visibility/debugging
60
+ try:
61
+ span.set_attribute("netra.local_blocked", True)
62
+ except Exception:
63
+ pass
64
+ except Exception:
65
+ logger.debug("Failed to precompute locally blocked mapping on start", exc_info=True)
66
+ except Exception:
67
+ # Never break tracing pipeline
68
+ logger.debug("LocalFilteringSpanProcessor.on_start failed", exc_info=True)
69
+
70
+ # No-ops required by interface
71
+ def on_end(self, span: trace.Span) -> None: # noqa: D401
72
+ # Cleanup registry entry to avoid leaks
73
+ try:
74
+ ctx = getattr(span, "context", None)
75
+ span_id = getattr(ctx, "span_id", None) if ctx else None
76
+ if span_id is not None:
77
+ BLOCKED_LOCAL_PARENT_MAP.pop(span_id, None)
78
+ except Exception:
79
+ pass
80
+ return
81
+
82
+ def shutdown(self) -> None: # noqa: D401
83
+ return
84
+
85
+ def force_flush(self, timeout_millis: int = 30000) -> None: # noqa: D401
86
+ return
87
+
88
+
89
+ def _decode_patterns(raw: str) -> Optional[List[str]]:
90
+ """Decode patterns stored in baggage.
91
+
92
+ We store JSON-encoded list[str] in baggage (must be a string). This safely handles complex
93
+ characters and is robust to commas in patterns.
94
+ """
95
+ try:
96
+ parsed = json.loads(raw)
97
+ if isinstance(parsed, list) and all(isinstance(p, str) for p in parsed):
98
+ return [p for p in parsed if p]
99
+ except Exception:
100
+ logger.debug("Failed to decode local blocked patterns from baggage", exc_info=True)
101
+ return None
102
+
103
+
104
+ def _matches_any_pattern(name: str, patterns: Sequence[str]) -> bool:
105
+ """Return True if name matches any pattern (exact, prefix*, *suffix)."""
106
+ try:
107
+ for p in patterns:
108
+ if not p:
109
+ continue
110
+ if p.endswith("*") and not p.startswith("*"):
111
+ if name.startswith(p[:-1]):
112
+ return True
113
+ elif p.startswith("*") and not p.endswith("*"):
114
+ if name.endswith(p[1:]):
115
+ return True
116
+ else:
117
+ if name == p:
118
+ return True
119
+ except Exception:
120
+ # Be conservative: on error, treat as no match
121
+ return False
122
+ return False
123
+
124
+
125
+ @contextmanager
126
+ def block_spans_local(patterns: Sequence[str]): # type: ignore[no-untyped-def]
127
+ """Context manager to locally block spans by name patterns.
128
+
129
+ Usage:
130
+ with block_spans_local(["openai.*", "*.internal"]):
131
+ # Spans created in this context will inherit these local rules
132
+ ...
133
+
134
+ Patterns follow the same rules as global blocking in the exporter:
135
+ - Exact match: "Foo"
136
+ - Prefix: "CloudSpanner.*"
137
+ - Suffix: "*.Query"
138
+ """
139
+ # Normalize incoming sequence to a compact list of non-empty strings
140
+ normalized: List[str] = [p for p in patterns if isinstance(p, str) and p]
141
+ # Encode as JSON string for baggage
142
+ payload = json.dumps(normalized)
143
+
144
+ # Attach to current context
145
+ token = otel_context.attach(
146
+ baggage.set_baggage(_LOCAL_BLOCKED_SPANS_BAGGAGE_KEY, payload, context=otel_context.get_current())
147
+ )
148
+ try:
149
+ yield
150
+ finally:
151
+ try:
152
+ otel_context.detach(token)
153
+ except Exception:
154
+ # If context changed unexpectedly, avoid crashing user code
155
+ logger.debug("Failed to detach local blocking context token", exc_info=True)
netra/session_manager.py CHANGED
@@ -275,20 +275,27 @@ class SessionManager:
275
275
 
276
276
  # Hard runtime validation of input types and values
277
277
  if not isinstance(conversation_type, ConversationType):
278
- raise TypeError("conversation_type must be a ConversationType enum value (input, output, system)")
278
+ logger.error(
279
+ "add_conversation: conversation_type must be a ConversationType enum value (input, output, system)"
280
+ )
281
+ return
279
282
  normalized_type = conversation_type.value
280
283
 
281
284
  if not isinstance(role, str):
282
- raise TypeError(f"role must be a string, got {type(role)}")
285
+ logger.error("add_conversation: role must be a string")
286
+ return
283
287
 
284
288
  if not isinstance(content, (str, dict)):
285
- raise TypeError(f"content must be a string or dict, got {type(content)}")
289
+ logger.error("add_conversation: content must be a string or dict")
290
+ return
286
291
 
287
292
  if not role:
288
- raise ValueError("role must be a non-empty string")
293
+ logger.error("add_conversation: role must be a non-empty string")
294
+ return
289
295
 
290
296
  if not content:
291
- raise ValueError("content must not be empty")
297
+ logger.error("add_conversation: content must not be empty")
298
+ return
292
299
 
293
300
  try:
294
301
 
netra/span_wrapper.py CHANGED
@@ -2,8 +2,11 @@ import json
2
2
  import logging
3
3
  import time
4
4
  from datetime import datetime
5
+ from enum import Enum
5
6
  from typing import Any, Dict, List, Literal, Optional
6
7
 
8
+ from opentelemetry import baggage
9
+ from opentelemetry import context as otel_context
7
10
  from opentelemetry import trace
8
11
  from opentelemetry.trace import SpanKind, Status, StatusCode
9
12
  from pydantic import BaseModel
@@ -15,6 +18,9 @@ from netra.session_manager import SessionManager
15
18
  logging.basicConfig(level=logging.INFO)
16
19
  logger = logging.getLogger(__name__)
17
20
 
21
+ # Baggage key for local-only blocked spans patterns
22
+ _LOCAL_BLOCKED_SPANS_BAGGAGE_KEY = "netra.local_blocked_spans"
23
+
18
24
 
19
25
  class ActionModel(BaseModel): # type: ignore[misc]
20
26
  start_time: str = str((datetime.now().timestamp() * 1_000_000_000))
@@ -44,6 +50,13 @@ class ATTRIBUTE:
44
50
  ACTION = "action"
45
51
 
46
52
 
53
+ class SpanType(str, Enum):
54
+ SPAN = "SPAN"
55
+ GENERATION = "GENERATION"
56
+ TOOL = "TOOL"
57
+ EMBEDDING = "EMBEDDING"
58
+
59
+
47
60
  class SpanWrapper:
48
61
  """
49
62
  Context manager for tracking observability data for external API calls.
@@ -58,9 +71,16 @@ class SpanWrapper:
58
71
  span.set_usage(usage_data)
59
72
  """
60
73
 
61
- def __init__(self, name: str, attributes: Optional[Dict[str, str]] = None, module_name: str = "combat_sdk"):
74
+ def __init__(
75
+ self,
76
+ name: str,
77
+ attributes: Optional[Dict[str, str]] = None,
78
+ module_name: str = "combat_sdk",
79
+ as_type: Optional[SpanType] = SpanType.SPAN,
80
+ ):
62
81
  self.name = name
63
82
  self.attributes = attributes or {}
83
+
64
84
  self.start_time: Optional[float] = None
65
85
  self.end_time: Optional[float] = None
66
86
  self.status = "pending"
@@ -72,11 +92,34 @@ class SpanWrapper:
72
92
  self.span: Optional[trace.Span] = None
73
93
  # Internal context manager to manage current-span scope safely
74
94
  self._span_cm: Optional[Any] = None
95
+ # Token for locally attached baggage (if any)
96
+ self._local_block_token: Optional[object] = None
97
+
98
+ if isinstance(as_type, SpanType):
99
+ self.attributes["netra.span.type"] = as_type.value
100
+ else:
101
+ raise ValueError(f"Invalid span type: {as_type}")
75
102
 
76
103
  def __enter__(self) -> "SpanWrapper":
77
104
  """Start the span wrapper, begin time tracking, and create OpenTelemetry span."""
78
105
  self.start_time = time.time()
79
106
 
107
+ # If user provided local blocked patterns in attributes, attach them as baggage
108
+ try:
109
+ patterns = None
110
+ # Accept either explicit key or short key for convenience
111
+ if isinstance(self.attributes.get("netra.local_blocked_spans"), list):
112
+ patterns = [p for p in self.attributes.get("netra.local_blocked_spans", []) if isinstance(p, str) and p]
113
+ elif isinstance(self.attributes.get("blocked_spans"), list):
114
+ patterns = [p for p in self.attributes.get("blocked_spans", []) if isinstance(p, str) and p]
115
+ if patterns:
116
+ payload = json.dumps(patterns)
117
+ self._local_block_token = otel_context.attach(
118
+ baggage.set_baggage(_LOCAL_BLOCKED_SPANS_BAGGAGE_KEY, payload, context=otel_context.get_current())
119
+ )
120
+ except Exception:
121
+ logger.debug("Failed to attach local blocked spans baggage on span start", exc_info=True)
122
+
80
123
  # Create OpenTelemetry span and make it current using OTel's context manager
81
124
  # Store the context manager so we can close it in __exit__
82
125
  self._span_cm = self.tracer.start_as_current_span(
@@ -139,6 +182,15 @@ class SpanWrapper:
139
182
  finally:
140
183
  self._span_cm = None
141
184
 
185
+ # Detach local blocking baggage if we attached it
186
+ if self._local_block_token is not None:
187
+ try:
188
+ otel_context.detach(self._local_block_token)
189
+ except Exception:
190
+ logger.debug("Failed to detach local blocked spans baggage token", exc_info=True)
191
+ finally:
192
+ self._local_block_token = None
193
+
142
194
  # Don't suppress exceptions
143
195
  return False
144
196
 
netra/tracer.py CHANGED
@@ -5,139 +5,24 @@ including exporter setup and span processor configuration.
5
5
  """
6
6
 
7
7
  import logging
8
- from typing import Any, Dict, List, Sequence
8
+ from typing import Any, Dict
9
9
 
10
10
  from opentelemetry import trace
11
11
  from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
12
12
  from opentelemetry.sdk.resources import DEPLOYMENT_ENVIRONMENT, SERVICE_NAME, Resource
13
- from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
13
+ from opentelemetry.sdk.trace import TracerProvider
14
14
  from opentelemetry.sdk.trace.export import (
15
15
  BatchSpanProcessor,
16
16
  ConsoleSpanExporter,
17
17
  SimpleSpanProcessor,
18
- SpanExporter,
19
- SpanExportResult,
20
18
  )
21
19
 
22
20
  from netra.config import Config
21
+ from netra.exporters import FilteringSpanExporter
23
22
 
24
23
  logger = logging.getLogger(__name__)
25
24
 
26
25
 
27
- class FilteringSpanExporter(SpanExporter): # type: ignore[misc]
28
- """
29
- SpanExporter wrapper that filters out spans by name.
30
-
31
- Matching rules:
32
- - Exact match: pattern "Foo" blocks span.name == "Foo".
33
- - Prefix match: pattern ending with '*' (e.g., "CloudSpanner.*") blocks spans whose
34
- names start with the prefix before '*', e.g., "CloudSpanner.", "CloudSpanner.Query".
35
- - Suffix match: pattern starting with '*' (e.g., "*.Query") blocks spans whose
36
- names end with the suffix after '*', e.g., "DB.Query", "Search.Query".
37
- """
38
-
39
- def __init__(self, exporter: SpanExporter, patterns: Sequence[str]) -> None:
40
- self._exporter = exporter
41
- # Normalize once for efficient checks
42
- exact: List[str] = []
43
- prefixes: List[str] = []
44
- suffixes: List[str] = []
45
- for p in patterns:
46
- if not p:
47
- continue
48
- if p.endswith("*") and not p.startswith("*"):
49
- prefixes.append(p[:-1])
50
- elif p.startswith("*") and not p.endswith("*"):
51
- suffixes.append(p[1:])
52
- else:
53
- exact.append(p)
54
- self._exact = set(exact)
55
- self._prefixes = prefixes
56
- self._suffixes = suffixes
57
-
58
- def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
59
- filtered: List[ReadableSpan] = []
60
- blocked_parent_map: Dict[Any, Any] = {}
61
- for span in spans:
62
- name = getattr(span, "name", None)
63
- if name is None or not self._is_blocked(name):
64
- filtered.append(span)
65
- continue
66
-
67
- span_context = getattr(span, "context", None)
68
- span_id = getattr(span_context, "span_id", None) if span_context else None
69
- if span_id is not None:
70
- blocked_parent_map[span_id] = getattr(span, "parent", None)
71
- if blocked_parent_map:
72
- self._reparent_blocked_children(filtered, blocked_parent_map)
73
- if not filtered:
74
- return SpanExportResult.SUCCESS
75
- return self._exporter.export(filtered)
76
-
77
- def _is_blocked(self, name: str) -> bool:
78
- if name in self._exact:
79
- return True
80
- for pref in self._prefixes:
81
- if name.startswith(pref):
82
- return True
83
- for suf in self._suffixes:
84
- if name.endswith(suf):
85
- return True
86
- return False
87
-
88
- def _reparent_blocked_children(
89
- self,
90
- spans: Sequence[ReadableSpan],
91
- blocked_parent_map: Dict[Any, Any],
92
- ) -> None:
93
- if not blocked_parent_map:
94
- return
95
-
96
- for span in spans:
97
- parent_context = getattr(span, "parent", None)
98
- if parent_context is None:
99
- continue
100
-
101
- updated_parent = parent_context
102
- visited: set[Any] = set()
103
- changed = False
104
-
105
- while updated_parent is not None:
106
- parent_span_id = getattr(updated_parent, "span_id", None)
107
- if parent_span_id not in blocked_parent_map or parent_span_id in visited:
108
- break
109
- visited.add(parent_span_id)
110
- updated_parent = blocked_parent_map[parent_span_id]
111
- changed = True
112
-
113
- if changed:
114
- self._set_span_parent(span, updated_parent)
115
-
116
- def _set_span_parent(self, span: ReadableSpan, parent: Any) -> None:
117
- if hasattr(span, "_parent"):
118
- try:
119
- span._parent = parent
120
- return
121
- except Exception:
122
- pass
123
- try:
124
- setattr(span, "parent", parent)
125
- except Exception:
126
- logger.debug("Failed to reparent span %s", getattr(span, "name", "<unknown>"), exc_info=True)
127
-
128
- def shutdown(self) -> None:
129
- try:
130
- self._exporter.shutdown()
131
- except Exception:
132
- pass
133
-
134
- def force_flush(self, timeout_millis: int = 30000) -> Any:
135
- try:
136
- return self._exporter.force_flush(timeout_millis)
137
- except Exception:
138
- return True
139
-
140
-
141
26
  class Tracer:
142
27
  """
143
28
  Configures Netra's OpenTelemetry tracer with OTLP exporter (or Console exporter as fallback)
@@ -181,17 +66,26 @@ class Tracer:
181
66
  endpoint=self._format_endpoint(self.cfg.otlp_endpoint),
182
67
  headers=self.cfg.headers,
183
68
  )
184
- # Wrap exporter with filtering if blocked span patterns are provided
69
+ # Wrap exporter with filtering to support both global and local (baggage-based) rules
185
70
  try:
186
- patterns = getattr(self.cfg, "blocked_spans", None)
71
+ patterns = getattr(self.cfg, "blocked_spans", None) or []
72
+ exporter = FilteringSpanExporter(exporter, patterns)
187
73
  if patterns:
188
- exporter = FilteringSpanExporter(exporter, patterns)
189
- logger.info("Enabled FilteringSpanExporter with %d pattern(s)", len(patterns))
74
+ logger.info("Enabled FilteringSpanExporter with %d global pattern(s)", len(patterns))
75
+ else:
76
+ logger.info("Enabled FilteringSpanExporter with local-only rules")
190
77
  except Exception as e:
191
78
  logger.warning("Failed to enable FilteringSpanExporter: %s", e)
192
79
  # Add span processors: first instrumentation wrapper, then session processor
193
- from netra.processors import InstrumentationSpanProcessor, ScrubbingSpanProcessor, SessionSpanProcessor
80
+ from netra.processors import (
81
+ InstrumentationSpanProcessor,
82
+ LocalFilteringSpanProcessor,
83
+ ScrubbingSpanProcessor,
84
+ SessionSpanProcessor,
85
+ )
194
86
 
87
+ # Apply local filtering propagation first so later processors and spans see attributes
88
+ provider.add_span_processor(LocalFilteringSpanProcessor())
195
89
  provider.add_span_processor(InstrumentationSpanProcessor())
196
90
  provider.add_span_processor(SessionSpanProcessor())
197
91
 
netra/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.44"
1
+ __version__ = "0.1.46"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netra-sdk
3
- Version: 0.1.44
3
+ Version: 0.1.46
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-Expression: Apache-2.0
6
6
  License-File: LICENCE
@@ -1,13 +1,15 @@
1
- netra/__init__.py,sha256=-eEBq3ndX3JAWDo4_Ra0L19KrTfrJhNDjlAwChb3_IA,10353
1
+ netra/__init__.py,sha256=tbj0UsUHKMG8RHJ58Y0tCvPVDS_XkGb7sF_xM1lxJEY,10454
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=MMSAKrX_HCZ7QECjTocs_jsNFgcOnbP8aAVfOdq9YEs,7032
7
- netra/decorators.py,sha256=qZFHrwdj10FsTFqggo3XjdGB12aMxsrrDMMmslDqZ-0,17424
6
+ netra/config.py,sha256=RqLul2vlNHHKGU31OQIJTUpcKMSmVse9IlGFwImW6sE,7032
7
+ netra/decorators.py,sha256=NHJZeZo3LEuOxCz-lfz3j74o6CS0jS8JBRw2LvoeeWE,18389
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
+ netra/exporters/__init__.py,sha256=8KFjTVKbTzoH2X0Yu1Cll6_CvCguD7dX9dHjFJqrM4M,111
12
+ netra/exporters/filtering_span_exporter.py,sha256=rWTGL8ja9v9hVhm9nuJCZGmQ6zw59F0Oh1hahhFJwU8,7649
11
13
  netra/input_scanner.py,sha256=At6N9gNY8cR0O6S8x3K6swWBV3P1a_9O-XBNM_pcKz4,5348
12
14
  netra/instrumentation/__init__.py,sha256=HdG3n5TxPRUNlOxsqjlvwDmBcnm3UtYx1OecLhnLeQM,41578
13
15
  netra/instrumentation/aiohttp/__init__.py,sha256=M1kuF0R3gKY5rlbhEC1AR13UWHelmfokluL2yFysKWc,14398
@@ -40,17 +42,18 @@ netra/instrumentation/pydantic_ai/wrappers.py,sha256=6cfIRvELBS4d9G9TttNYcHGueNI
40
42
  netra/instrumentation/weaviate/__init__.py,sha256=EOlpWxobOLHYKqo_kMct_7nu26x1hr8qkeG5_h99wtg,4330
41
43
  netra/instrumentation/weaviate/version.py,sha256=PiCZHjonujPbnIn0KmD3Yl68hrjPRG_oKe5vJF3mmG8,24
42
44
  netra/pii.py,sha256=Rn4SjgTJW_aw9LcbjLuMqF3fKd9b1ndlYt1CaK51Ge0,33125
43
- netra/processors/__init__.py,sha256=TLVBKk4Bli7MOyHTy_F-4NSm0thzIcJcZAVVNoq6gK8,333
44
- netra/processors/instrumentation_span_processor.py,sha256=VzurzwtGleFltxzKD_gjVkUQiRC6SGlb0oG4Nlpu85A,4365
45
+ netra/processors/__init__.py,sha256=56RO7xMoBx2aoVuESyQXll65o7IoO98nSHZ5KpR5iQk,471
46
+ netra/processors/instrumentation_span_processor.py,sha256=7iDnJUSBXyiRcWxoxk3uStoh0r4Yxjh7PBTbFDVMjlA,4150
47
+ netra/processors/local_filtering_span_processor.py,sha256=Vk_UP--ZxWwdrbJA7a95iRo853QfPCME2C5ChgjiHl8,6664
45
48
  netra/processors/scrubbing_span_processor.py,sha256=dJ86Ncmjvmrhm_uAdGTwcGvRpZbVVWqD9AOFwEMWHZY,6701
46
49
  netra/processors/session_span_processor.py,sha256=qcsBl-LnILWefsftI8NQhXDGb94OWPc8LvzhVA0JS_c,2432
47
50
  netra/scanner.py,sha256=kyDpeZiscCPb6pjuhS-sfsVj-dviBFRepdUWh0sLoEY,11554
48
- netra/session_manager.py,sha256=VzmSAiP63ODCuOWv-irsxyU2LvHoqjOBUuXtyxboBU0,13740
49
- netra/span_wrapper.py,sha256=IygQX78xQRlL_Z1MfKfUbv0okihx92qNClnRlYFtRNc,8004
50
- netra/tracer.py,sha256=3s_ZAHgpfeegcnA43KVEinEPVyo3kGi_0bvSrQcKljk,8348
51
+ netra/session_manager.py,sha256=jxA62zAgiTsuTyV_UuPkVb2W1Uieb7I5jWsYiJ6jSxk,13897
52
+ netra/span_wrapper.py,sha256=80-0MkAw7AOQg0u7dj2YfXfnuUQDE8ymsGNzh6y6JtA,10102
53
+ netra/tracer.py,sha256=IPVYMc4x74XZJeKvCfV6b0X9mUHGvsssUzLnRvFPV6M,4629
51
54
  netra/utils.py,sha256=FblSzI8qMTfEbusakGBKE9CNELW0GEBHl09mPPxgI-w,2521
52
- netra/version.py,sha256=AoJtnEXXv6E20uj57ChQUsGoLfKG8mvSQpdz97tcyis,23
53
- netra_sdk-0.1.44.dist-info/METADATA,sha256=f7QrKDQQtQyaaAzIzGFF44KOpHpNW00wrZ3KIPt0zvA,28208
54
- netra_sdk-0.1.44.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
55
- netra_sdk-0.1.44.dist-info/licenses/LICENCE,sha256=8B_UoZ-BAl0AqiHAHUETCgd3I2B9yYJ1WEQtVb_qFMA,11359
56
- netra_sdk-0.1.44.dist-info/RECORD,,
55
+ netra/version.py,sha256=X3e_85I7oZGwZD8nW9SBKEUbQU7-_3W9FXuicrfxHjc,23
56
+ netra_sdk-0.1.46.dist-info/METADATA,sha256=QWfOewRPrRyNlamWqdvuS-1U01UocBvxcRZQ_H6Qmvw,28208
57
+ netra_sdk-0.1.46.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
58
+ netra_sdk-0.1.46.dist-info/licenses/LICENCE,sha256=8B_UoZ-BAl0AqiHAHUETCgd3I2B9yYJ1WEQtVb_qFMA,11359
59
+ netra_sdk-0.1.46.dist-info/RECORD,,