netra-sdk 0.1.44__tar.gz → 0.1.46__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.
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/PKG-INFO +1 -1
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/__init__.py +5 -4
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/config.py +1 -1
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/decorators.py +38 -8
- netra_sdk-0.1.46/netra/exporters/__init__.py +3 -0
- netra_sdk-0.1.46/netra/exporters/filtering_span_exporter.py +211 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/processors/__init__.py +7 -1
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/processors/instrumentation_span_processor.py +0 -3
- netra_sdk-0.1.46/netra/processors/local_filtering_span_processor.py +155 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/session_manager.py +12 -5
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/span_wrapper.py +53 -1
- netra_sdk-0.1.46/netra/tracer.py +121 -0
- netra_sdk-0.1.46/netra/version.py +1 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/pyproject.toml +1 -1
- netra_sdk-0.1.44/netra/tracer.py +0 -227
- netra_sdk-0.1.44/netra/version.py +0 -1
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/LICENCE +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/README.md +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/anonymizer/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/anonymizer/anonymizer.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/anonymizer/base.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/anonymizer/fp_anonymizer.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/exceptions/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/exceptions/injection.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/exceptions/pii.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/input_scanner.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/aiohttp/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/aiohttp/version.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/cohere/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/cohere/version.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/fastapi/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/fastapi/version.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/google_genai/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/google_genai/config.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/google_genai/utils.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/google_genai/version.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/httpx/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/httpx/version.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/instruments.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/litellm/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/litellm/version.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/litellm/wrappers.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/mistralai/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/mistralai/config.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/mistralai/utils.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/mistralai/version.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/openai/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/openai/version.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/openai/wrappers.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/pydantic_ai/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/pydantic_ai/utils.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/pydantic_ai/version.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/pydantic_ai/wrappers.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/weaviate/__init__.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/instrumentation/weaviate/version.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/pii.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/processors/scrubbing_span_processor.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/processors/session_span_processor.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/scanner.py +0 -0
- {netra_sdk-0.1.44 → netra_sdk-0.1.46}/netra/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: netra-sdk
|
|
3
|
-
Version: 0.1.
|
|
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,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"]
|
|
@@ -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 =
|
|
33
|
+
CONVERSATION_CONTENT_MAX_LEN = 2000
|
|
34
34
|
|
|
35
35
|
def __init__(
|
|
36
36
|
self,
|
|
@@ -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(
|
|
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(
|
|
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,
|
|
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,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__ = [
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
285
|
+
logger.error("add_conversation: role must be a string")
|
|
286
|
+
return
|
|
283
287
|
|
|
284
288
|
if not isinstance(content, (str, dict)):
|
|
285
|
-
|
|
289
|
+
logger.error("add_conversation: content must be a string or dict")
|
|
290
|
+
return
|
|
286
291
|
|
|
287
292
|
if not role:
|
|
288
|
-
|
|
293
|
+
logger.error("add_conversation: role must be a non-empty string")
|
|
294
|
+
return
|
|
289
295
|
|
|
290
296
|
if not content:
|
|
291
|
-
|
|
297
|
+
logger.error("add_conversation: content must not be empty")
|
|
298
|
+
return
|
|
292
299
|
|
|
293
300
|
try:
|
|
294
301
|
|
|
@@ -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__(
|
|
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
|
|
|
@@ -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.46"
|
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "netra-sdk"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.46"
|
|
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"}
|
netra_sdk-0.1.44/netra/tracer.py
DELETED
|
@@ -1,227 +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
|
-
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
|
-
class Tracer:
|
|
142
|
-
"""
|
|
143
|
-
Configures Netra's OpenTelemetry tracer with OTLP exporter (or Console exporter as fallback)
|
|
144
|
-
and appropriate span processor.
|
|
145
|
-
"""
|
|
146
|
-
|
|
147
|
-
def __init__(self, cfg: Config) -> None:
|
|
148
|
-
"""Initialize the Netra tracer with the provided configuration.
|
|
149
|
-
|
|
150
|
-
Args:
|
|
151
|
-
cfg: Configuration object with tracer settings
|
|
152
|
-
"""
|
|
153
|
-
self.cfg = cfg
|
|
154
|
-
self._setup_tracer()
|
|
155
|
-
|
|
156
|
-
def _setup_tracer(self) -> None:
|
|
157
|
-
"""Set up the OpenTelemetry tracer with appropriate exporters and processors.
|
|
158
|
-
|
|
159
|
-
Creates a resource with service name and custom attributes,
|
|
160
|
-
configures the appropriate exporter (OTLP or Console fallback),
|
|
161
|
-
and sets up either a batch or simple span processor based on configuration.
|
|
162
|
-
"""
|
|
163
|
-
# Create Resource with service.name + custom attributes
|
|
164
|
-
resource_attrs: Dict[str, Any] = {
|
|
165
|
-
SERVICE_NAME: self.cfg.app_name,
|
|
166
|
-
DEPLOYMENT_ENVIRONMENT: self.cfg.environment,
|
|
167
|
-
}
|
|
168
|
-
if self.cfg.resource_attributes:
|
|
169
|
-
resource_attrs.update(self.cfg.resource_attributes)
|
|
170
|
-
resource = Resource(attributes=resource_attrs)
|
|
171
|
-
|
|
172
|
-
# Build TracerProvider
|
|
173
|
-
provider = TracerProvider(resource=resource)
|
|
174
|
-
|
|
175
|
-
# Configure exporter based on configuration
|
|
176
|
-
if not self.cfg.otlp_endpoint:
|
|
177
|
-
logger.warning("OTLP endpoint not provided, falling back to console exporter")
|
|
178
|
-
exporter = ConsoleSpanExporter()
|
|
179
|
-
else:
|
|
180
|
-
exporter = OTLPSpanExporter(
|
|
181
|
-
endpoint=self._format_endpoint(self.cfg.otlp_endpoint),
|
|
182
|
-
headers=self.cfg.headers,
|
|
183
|
-
)
|
|
184
|
-
# Wrap exporter with filtering if blocked span patterns are provided
|
|
185
|
-
try:
|
|
186
|
-
patterns = getattr(self.cfg, "blocked_spans", None)
|
|
187
|
-
if patterns:
|
|
188
|
-
exporter = FilteringSpanExporter(exporter, patterns)
|
|
189
|
-
logger.info("Enabled FilteringSpanExporter with %d pattern(s)", len(patterns))
|
|
190
|
-
except Exception as e:
|
|
191
|
-
logger.warning("Failed to enable FilteringSpanExporter: %s", e)
|
|
192
|
-
# Add span processors: first instrumentation wrapper, then session processor
|
|
193
|
-
from netra.processors import InstrumentationSpanProcessor, ScrubbingSpanProcessor, SessionSpanProcessor
|
|
194
|
-
|
|
195
|
-
provider.add_span_processor(InstrumentationSpanProcessor())
|
|
196
|
-
provider.add_span_processor(SessionSpanProcessor())
|
|
197
|
-
|
|
198
|
-
# Add scrubbing processor if enabled
|
|
199
|
-
if self.cfg.enable_scrubbing:
|
|
200
|
-
provider.add_span_processor(ScrubbingSpanProcessor()) # type: ignore[no-untyped-call]
|
|
201
|
-
|
|
202
|
-
# Install appropriate span processor
|
|
203
|
-
if self.cfg.disable_batch:
|
|
204
|
-
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
205
|
-
else:
|
|
206
|
-
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
207
|
-
|
|
208
|
-
# Set global tracer provider
|
|
209
|
-
trace.set_tracer_provider(provider)
|
|
210
|
-
logger.info(
|
|
211
|
-
"Netra TracerProvider initialized: endpoint=%s, disable_batch=%s",
|
|
212
|
-
self.cfg.otlp_endpoint,
|
|
213
|
-
self.cfg.disable_batch,
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
def _format_endpoint(self, endpoint: str) -> str:
|
|
217
|
-
"""Format the OTLP endpoint URL to ensure it ends with '/v1/traces'.
|
|
218
|
-
|
|
219
|
-
Args:
|
|
220
|
-
endpoint: Base OTLP endpoint URL
|
|
221
|
-
|
|
222
|
-
Returns:
|
|
223
|
-
Properly formatted endpoint URL
|
|
224
|
-
"""
|
|
225
|
-
if not endpoint.endswith("/v1/traces"):
|
|
226
|
-
return endpoint.rstrip("/") + "/v1/traces"
|
|
227
|
-
return endpoint
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.44"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|