netra-sdk 0.1.43__tar.gz → 0.1.45__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 (61) hide show
  1. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/PKG-INFO +1 -1
  2. netra_sdk-0.1.45/netra/exporters/__init__.py +3 -0
  3. netra_sdk-0.1.45/netra/exporters/filtering_span_exporter.py +211 -0
  4. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/processors/__init__.py +7 -1
  5. netra_sdk-0.1.45/netra/processors/local_filtering_span_processor.py +155 -0
  6. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/span_wrapper.py +32 -0
  7. netra_sdk-0.1.45/netra/tracer.py +121 -0
  8. netra_sdk-0.1.45/netra/version.py +1 -0
  9. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/pyproject.toml +1 -1
  10. netra_sdk-0.1.43/netra/tracer.py +0 -203
  11. netra_sdk-0.1.43/netra/version.py +0 -1
  12. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/LICENCE +0 -0
  13. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/README.md +0 -0
  14. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/__init__.py +0 -0
  15. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/anonymizer/__init__.py +0 -0
  16. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/anonymizer/anonymizer.py +0 -0
  17. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/anonymizer/base.py +0 -0
  18. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/anonymizer/fp_anonymizer.py +0 -0
  19. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/config.py +0 -0
  20. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/decorators.py +0 -0
  21. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/exceptions/__init__.py +0 -0
  22. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/exceptions/injection.py +0 -0
  23. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/exceptions/pii.py +0 -0
  24. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/input_scanner.py +0 -0
  25. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/__init__.py +0 -0
  26. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/aiohttp/__init__.py +0 -0
  27. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/aiohttp/version.py +0 -0
  28. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/cohere/__init__.py +0 -0
  29. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/cohere/version.py +0 -0
  30. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/fastapi/__init__.py +0 -0
  31. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/fastapi/version.py +0 -0
  32. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/google_genai/__init__.py +0 -0
  33. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/google_genai/config.py +0 -0
  34. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/google_genai/utils.py +0 -0
  35. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/google_genai/version.py +0 -0
  36. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/httpx/__init__.py +0 -0
  37. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/httpx/version.py +0 -0
  38. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/instruments.py +0 -0
  39. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/litellm/__init__.py +0 -0
  40. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/litellm/version.py +0 -0
  41. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/litellm/wrappers.py +0 -0
  42. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/mistralai/__init__.py +0 -0
  43. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/mistralai/config.py +0 -0
  44. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/mistralai/utils.py +0 -0
  45. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/mistralai/version.py +0 -0
  46. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/openai/__init__.py +0 -0
  47. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/openai/version.py +0 -0
  48. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/openai/wrappers.py +0 -0
  49. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/pydantic_ai/__init__.py +0 -0
  50. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/pydantic_ai/utils.py +0 -0
  51. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/pydantic_ai/version.py +0 -0
  52. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/pydantic_ai/wrappers.py +0 -0
  53. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/weaviate/__init__.py +0 -0
  54. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/instrumentation/weaviate/version.py +0 -0
  55. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/pii.py +0 -0
  56. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/processors/instrumentation_span_processor.py +0 -0
  57. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/processors/scrubbing_span_processor.py +0 -0
  58. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/processors/session_span_processor.py +0 -0
  59. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/scanner.py +0 -0
  60. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/session_manager.py +0 -0
  61. {netra_sdk-0.1.43 → netra_sdk-0.1.45}/netra/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netra-sdk
3
- Version: 0.1.43
3
+ Version: 0.1.45
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
@@ -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
+ ]
@@ -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)
@@ -4,6 +4,8 @@ import time
4
4
  from datetime import datetime
5
5
  from typing import Any, Dict, List, Literal, Optional
6
6
 
7
+ from opentelemetry import baggage
8
+ from opentelemetry import context as otel_context
7
9
  from opentelemetry import trace
8
10
  from opentelemetry.trace import SpanKind, Status, StatusCode
9
11
  from pydantic import BaseModel
@@ -15,6 +17,9 @@ from netra.session_manager import SessionManager
15
17
  logging.basicConfig(level=logging.INFO)
16
18
  logger = logging.getLogger(__name__)
17
19
 
20
+ # Baggage key for local-only blocked spans patterns
21
+ _LOCAL_BLOCKED_SPANS_BAGGAGE_KEY = "netra.local_blocked_spans"
22
+
18
23
 
19
24
  class ActionModel(BaseModel): # type: ignore[misc]
20
25
  start_time: str = str((datetime.now().timestamp() * 1_000_000_000))
@@ -72,11 +77,29 @@ class SpanWrapper:
72
77
  self.span: Optional[trace.Span] = None
73
78
  # Internal context manager to manage current-span scope safely
74
79
  self._span_cm: Optional[Any] = None
80
+ # Token for locally attached baggage (if any)
81
+ self._local_block_token: Optional[object] = None
75
82
 
76
83
  def __enter__(self) -> "SpanWrapper":
77
84
  """Start the span wrapper, begin time tracking, and create OpenTelemetry span."""
78
85
  self.start_time = time.time()
79
86
 
87
+ # If user provided local blocked patterns in attributes, attach them as baggage
88
+ try:
89
+ patterns = None
90
+ # Accept either explicit key or short key for convenience
91
+ if isinstance(self.attributes.get("netra.local_blocked_spans"), list):
92
+ patterns = [p for p in self.attributes.get("netra.local_blocked_spans", []) if isinstance(p, str) and p]
93
+ elif isinstance(self.attributes.get("blocked_spans"), list):
94
+ patterns = [p for p in self.attributes.get("blocked_spans", []) if isinstance(p, str) and p]
95
+ if patterns:
96
+ payload = json.dumps(patterns)
97
+ self._local_block_token = otel_context.attach(
98
+ baggage.set_baggage(_LOCAL_BLOCKED_SPANS_BAGGAGE_KEY, payload, context=otel_context.get_current())
99
+ )
100
+ except Exception:
101
+ logger.debug("Failed to attach local blocked spans baggage on span start", exc_info=True)
102
+
80
103
  # Create OpenTelemetry span and make it current using OTel's context manager
81
104
  # Store the context manager so we can close it in __exit__
82
105
  self._span_cm = self.tracer.start_as_current_span(
@@ -139,6 +162,15 @@ class SpanWrapper:
139
162
  finally:
140
163
  self._span_cm = None
141
164
 
165
+ # Detach local blocking baggage if we attached it
166
+ if self._local_block_token is not None:
167
+ try:
168
+ otel_context.detach(self._local_block_token)
169
+ except Exception:
170
+ logger.debug("Failed to detach local blocked spans baggage token", exc_info=True)
171
+ finally:
172
+ self._local_block_token = None
173
+
142
174
  # Don't suppress exceptions
143
175
  return False
144
176
 
@@ -0,0 +1,121 @@
1
+ """Netra OpenTelemetry tracer configuration module.
2
+
3
+ This module handles the initialization and configuration of OpenTelemetry tracing,
4
+ including exporter setup and span processor configuration.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any, Dict
9
+
10
+ from opentelemetry import trace
11
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
12
+ from opentelemetry.sdk.resources import DEPLOYMENT_ENVIRONMENT, SERVICE_NAME, Resource
13
+ from opentelemetry.sdk.trace import TracerProvider
14
+ from opentelemetry.sdk.trace.export import (
15
+ BatchSpanProcessor,
16
+ ConsoleSpanExporter,
17
+ SimpleSpanProcessor,
18
+ )
19
+
20
+ from netra.config import Config
21
+ from netra.exporters import FilteringSpanExporter
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class Tracer:
27
+ """
28
+ Configures Netra's OpenTelemetry tracer with OTLP exporter (or Console exporter as fallback)
29
+ and appropriate span processor.
30
+ """
31
+
32
+ def __init__(self, cfg: Config) -> None:
33
+ """Initialize the Netra tracer with the provided configuration.
34
+
35
+ Args:
36
+ cfg: Configuration object with tracer settings
37
+ """
38
+ self.cfg = cfg
39
+ self._setup_tracer()
40
+
41
+ def _setup_tracer(self) -> None:
42
+ """Set up the OpenTelemetry tracer with appropriate exporters and processors.
43
+
44
+ Creates a resource with service name and custom attributes,
45
+ configures the appropriate exporter (OTLP or Console fallback),
46
+ and sets up either a batch or simple span processor based on configuration.
47
+ """
48
+ # Create Resource with service.name + custom attributes
49
+ resource_attrs: Dict[str, Any] = {
50
+ SERVICE_NAME: self.cfg.app_name,
51
+ DEPLOYMENT_ENVIRONMENT: self.cfg.environment,
52
+ }
53
+ if self.cfg.resource_attributes:
54
+ resource_attrs.update(self.cfg.resource_attributes)
55
+ resource = Resource(attributes=resource_attrs)
56
+
57
+ # Build TracerProvider
58
+ provider = TracerProvider(resource=resource)
59
+
60
+ # Configure exporter based on configuration
61
+ if not self.cfg.otlp_endpoint:
62
+ logger.warning("OTLP endpoint not provided, falling back to console exporter")
63
+ exporter = ConsoleSpanExporter()
64
+ else:
65
+ exporter = OTLPSpanExporter(
66
+ endpoint=self._format_endpoint(self.cfg.otlp_endpoint),
67
+ headers=self.cfg.headers,
68
+ )
69
+ # Wrap exporter with filtering to support both global and local (baggage-based) rules
70
+ try:
71
+ patterns = getattr(self.cfg, "blocked_spans", None) or []
72
+ exporter = FilteringSpanExporter(exporter, patterns)
73
+ if patterns:
74
+ logger.info("Enabled FilteringSpanExporter with %d global pattern(s)", len(patterns))
75
+ else:
76
+ logger.info("Enabled FilteringSpanExporter with local-only rules")
77
+ except Exception as e:
78
+ logger.warning("Failed to enable FilteringSpanExporter: %s", e)
79
+ # Add span processors: first instrumentation wrapper, then session processor
80
+ from netra.processors import (
81
+ InstrumentationSpanProcessor,
82
+ LocalFilteringSpanProcessor,
83
+ ScrubbingSpanProcessor,
84
+ SessionSpanProcessor,
85
+ )
86
+
87
+ # Apply local filtering propagation first so later processors and spans see attributes
88
+ provider.add_span_processor(LocalFilteringSpanProcessor())
89
+ provider.add_span_processor(InstrumentationSpanProcessor())
90
+ provider.add_span_processor(SessionSpanProcessor())
91
+
92
+ # Add scrubbing processor if enabled
93
+ if self.cfg.enable_scrubbing:
94
+ provider.add_span_processor(ScrubbingSpanProcessor()) # type: ignore[no-untyped-call]
95
+
96
+ # Install appropriate span processor
97
+ if self.cfg.disable_batch:
98
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
99
+ else:
100
+ provider.add_span_processor(BatchSpanProcessor(exporter))
101
+
102
+ # Set global tracer provider
103
+ trace.set_tracer_provider(provider)
104
+ logger.info(
105
+ "Netra TracerProvider initialized: endpoint=%s, disable_batch=%s",
106
+ self.cfg.otlp_endpoint,
107
+ self.cfg.disable_batch,
108
+ )
109
+
110
+ def _format_endpoint(self, endpoint: str) -> str:
111
+ """Format the OTLP endpoint URL to ensure it ends with '/v1/traces'.
112
+
113
+ Args:
114
+ endpoint: Base OTLP endpoint URL
115
+
116
+ Returns:
117
+ Properly formatted endpoint URL
118
+ """
119
+ if not endpoint.endswith("/v1/traces"):
120
+ return endpoint.rstrip("/") + "/v1/traces"
121
+ return endpoint
@@ -0,0 +1 @@
1
+ __version__ = "0.1.45"
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "netra-sdk"
7
- version = "0.1.43"
7
+ version = "0.1.45"
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,203 +0,0 @@
1
- """Netra OpenTelemetry tracer configuration module.
2
-
3
- This module handles the initialization and configuration of OpenTelemetry tracing,
4
- including exporter setup and span processor configuration.
5
- """
6
-
7
- import logging
8
- from typing import Any, Dict, List, Sequence
9
-
10
- from opentelemetry import trace
11
- from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
12
- from opentelemetry.sdk.resources import DEPLOYMENT_ENVIRONMENT, SERVICE_NAME, Resource
13
- from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
14
- from opentelemetry.sdk.trace.export import (
15
- BatchSpanProcessor,
16
- ConsoleSpanExporter,
17
- SimpleSpanProcessor,
18
- SpanExporter,
19
- SpanExportResult,
20
- )
21
-
22
- from netra.config import Config
23
-
24
- logger = logging.getLogger(__name__)
25
-
26
-
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
- for s in spans:
61
- name = getattr(s, "name", None)
62
- if name is None:
63
- filtered.append(s)
64
- continue
65
- # Only apply blocked span patterns to root-level spans (no valid parent)
66
- parent = getattr(s, "parent", None)
67
- # Determine if the span has a valid parent. SpanContext.is_valid may be a property or method.
68
- has_valid_parent = False
69
- if parent is not None:
70
- is_valid_attr = getattr(parent, "is_valid", None)
71
- if callable(is_valid_attr):
72
- try:
73
- has_valid_parent = bool(is_valid_attr())
74
- except Exception:
75
- has_valid_parent = False
76
- else:
77
- has_valid_parent = bool(is_valid_attr)
78
-
79
- is_root_span = parent is None or not has_valid_parent
80
-
81
- if is_root_span:
82
- # Apply name-based blocking only for root spans
83
- if name in self._exact:
84
- continue
85
- blocked = False
86
- for pref in self._prefixes:
87
- if name.startswith(pref):
88
- blocked = True
89
- break
90
- if not blocked and self._suffixes:
91
- for suf in self._suffixes:
92
- if name.endswith(suf):
93
- blocked = True
94
- break
95
- if not blocked:
96
- filtered.append(s)
97
- else:
98
- # Do not block child spans based on name
99
- filtered.append(s)
100
- if not filtered:
101
- return SpanExportResult.SUCCESS
102
- return self._exporter.export(filtered)
103
-
104
- def shutdown(self) -> None:
105
- try:
106
- self._exporter.shutdown()
107
- except Exception:
108
- pass
109
-
110
- def force_flush(self, timeout_millis: int = 30000) -> Any:
111
- try:
112
- return self._exporter.force_flush(timeout_millis)
113
- except Exception:
114
- return True
115
-
116
-
117
- class Tracer:
118
- """
119
- Configures Netra's OpenTelemetry tracer with OTLP exporter (or Console exporter as fallback)
120
- and appropriate span processor.
121
- """
122
-
123
- def __init__(self, cfg: Config) -> None:
124
- """Initialize the Netra tracer with the provided configuration.
125
-
126
- Args:
127
- cfg: Configuration object with tracer settings
128
- """
129
- self.cfg = cfg
130
- self._setup_tracer()
131
-
132
- def _setup_tracer(self) -> None:
133
- """Set up the OpenTelemetry tracer with appropriate exporters and processors.
134
-
135
- Creates a resource with service name and custom attributes,
136
- configures the appropriate exporter (OTLP or Console fallback),
137
- and sets up either a batch or simple span processor based on configuration.
138
- """
139
- # Create Resource with service.name + custom attributes
140
- resource_attrs: Dict[str, Any] = {
141
- SERVICE_NAME: self.cfg.app_name,
142
- DEPLOYMENT_ENVIRONMENT: self.cfg.environment,
143
- }
144
- if self.cfg.resource_attributes:
145
- resource_attrs.update(self.cfg.resource_attributes)
146
- resource = Resource(attributes=resource_attrs)
147
-
148
- # Build TracerProvider
149
- provider = TracerProvider(resource=resource)
150
-
151
- # Configure exporter based on configuration
152
- if not self.cfg.otlp_endpoint:
153
- logger.warning("OTLP endpoint not provided, falling back to console exporter")
154
- exporter = ConsoleSpanExporter()
155
- else:
156
- exporter = OTLPSpanExporter(
157
- endpoint=self._format_endpoint(self.cfg.otlp_endpoint),
158
- headers=self.cfg.headers,
159
- )
160
- # Wrap exporter with filtering if blocked span patterns are provided
161
- try:
162
- patterns = getattr(self.cfg, "blocked_spans", None)
163
- if patterns:
164
- exporter = FilteringSpanExporter(exporter, patterns)
165
- logger.info("Enabled FilteringSpanExporter with %d pattern(s)", len(patterns))
166
- except Exception as e:
167
- logger.warning("Failed to enable FilteringSpanExporter: %s", e)
168
- # Add span processors: first instrumentation wrapper, then session processor
169
- from netra.processors import InstrumentationSpanProcessor, ScrubbingSpanProcessor, SessionSpanProcessor
170
-
171
- provider.add_span_processor(InstrumentationSpanProcessor())
172
- provider.add_span_processor(SessionSpanProcessor())
173
-
174
- # Add scrubbing processor if enabled
175
- if self.cfg.enable_scrubbing:
176
- provider.add_span_processor(ScrubbingSpanProcessor()) # type: ignore[no-untyped-call]
177
-
178
- # Install appropriate span processor
179
- if self.cfg.disable_batch:
180
- provider.add_span_processor(SimpleSpanProcessor(exporter))
181
- else:
182
- provider.add_span_processor(BatchSpanProcessor(exporter))
183
-
184
- # Set global tracer provider
185
- trace.set_tracer_provider(provider)
186
- logger.info(
187
- "Netra TracerProvider initialized: endpoint=%s, disable_batch=%s",
188
- self.cfg.otlp_endpoint,
189
- self.cfg.disable_batch,
190
- )
191
-
192
- def _format_endpoint(self, endpoint: str) -> str:
193
- """Format the OTLP endpoint URL to ensure it ends with '/v1/traces'.
194
-
195
- Args:
196
- endpoint: Base OTLP endpoint URL
197
-
198
- Returns:
199
- Properly formatted endpoint URL
200
- """
201
- if not endpoint.endswith("/v1/traces"):
202
- return endpoint.rstrip("/") + "/v1/traces"
203
- return endpoint
@@ -1 +0,0 @@
1
- __version__ = "0.1.43"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes