diff --git a/CHANGELOG.md b/CHANGELOG.md index c3092ad6..86a8e9e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog and this project adheres to Semantic Versioning. +## [0.1.96] - 2026-07-09 + +- **Add opt-in TTL caching for `get_prompt`** - `Netra.prompts.get_prompt` now accepts `use_cache` and `cache_ttl` parameters for in-memory caching. Default TTL is `PROMPT_CACHE_TTL_SECONDS` (60); override per call with `cache_ttl`. Use `Netra.prompts.clear_cache()` to invalidate cached entries. +- **Add opt-in TTL caching for `get_model_pricing`** - `Netra.models.get_model_pricing` now accepts `use_cache` and `cache_ttl` parameters for in-memory caching. Default TTL is `MODEL_PRICING_CACHE_TTL_SECONDS` (300); override per call with `cache_ttl`. Use `Netra.models.clear_cache()` to invalidate cached entries. + ## [0.1.95] - 2026-06-26 - **Added get_all_datasets with tag as optional param** - If tag is provided, we get details of all the datasets with that particular tag attached. @@ -309,4 +314,4 @@ Users can be now overwrite the input and ouput attributes of spans created by in - Added utility to set input and output data for any active span in a trace -[0.1.95]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main +[0.1.96]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main diff --git a/README.md b/README.md index 08395a0a..798702c1 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ - 📈 **Session Management**: Track user sessions and custom attributes - 🌐 **HTTP Client Instrumentation**: Automatic tracing for aiohttp and httpx - 💾 **Vector Database Support**: Weaviate, Qdrant, and other vector DB instrumentation +- 📋 **Prompt Management**: Fetch managed prompts from Netra with optional in-memory TTL caching +- 💰 **Model Pricing**: Fetch model pricing from Netra with optional in-memory TTL caching ## 📦 Installation @@ -319,6 +321,78 @@ Action tracking follows this schema: ] ``` +## 📋 Prompt Management + +Fetch managed prompt versions from Netra via `Netra.prompts`. Caching is opt-in and disabled by default. Default TTL is **60 seconds** (`PROMPT_CACHE_TTL_SECONDS`); override per call with `cache_ttl`. + +```python +from netra import Netra +from netra.instrumentation.instruments import InstrumentSet + +Netra.init( + app_name="My App", + instruments={InstrumentSet.OPENAI}, +) + +# Fetch a prompt (calls the API on every request by default) +prompt = Netra.prompts.get_prompt("my-prompt", label="production") + +# Opt in to in-memory caching to reduce API calls (default TTL: 60s) +prompt = Netra.prompts.get_prompt("my-prompt", label="production", use_cache=True) + +# Override TTL for a single call (seconds) +prompt = Netra.prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=300) + +# Clear cached entries after updating a prompt +Netra.prompts.clear_cache() +``` + +Caching notes: + +- `use_cache` defaults to `False`; enable it per call when you want caching. +- Default TTL is the module constant `PROMPT_CACHE_TTL_SECONDS` (60); override with `cache_ttl`. +- Cache keys are scoped by prompt `name` and `label`. +- Empty or failed responses are not stored in the cache. +- The prompt cache is cleared automatically when `Netra.shutdown()` is called. + +## 💰 Model Pricing + +Fetch model details and pricing for your project via `Netra.models`. Caching is opt-in and disabled by default. Default TTL is **300 seconds** (`MODEL_PRICING_CACHE_TTL_SECONDS`); override per call with `cache_ttl`. + +```python +from netra import Netra +from netra.instrumentation.instruments import InstrumentSet + +Netra.init( + app_name="My App", + instruments={InstrumentSet.OPENAI}, +) + +# Fetch all model pricing (calls the API on every request by default) +models = Netra.models.get_model_pricing() + +# Fetch pricing for a specific model +models = Netra.models.get_model_pricing(name="gpt-4o") + +# Opt in to in-memory caching to reduce API calls (default TTL: 300s) +models = Netra.models.get_model_pricing(use_cache=True) + +# Override TTL for a single call (seconds) +models = Netra.models.get_model_pricing(use_cache=True, cache_ttl=600) + +# Clear cached entries after pricing updates +Netra.models.clear_cache() +``` + +Caching notes: + +- `use_cache` defaults to `False`; enable it per call when you want caching. +- Default TTL is the module constant `MODEL_PRICING_CACHE_TTL_SECONDS` (300); override with `cache_ttl`. +- Cache keys are scoped by model `name` (or `"all"` when no name is passed). +- Empty or failed responses are not stored in the cache. +- When `use_cache` is `True`, do not mutate the returned list or nested dicts — the same objects may be served on later cache hits. +- The model pricing cache is cleared automatically when `Netra.shutdown()` is called. + ## 🔧 Advanced Configuration ### Environment Variables diff --git a/netra/__init__.py b/netra/__init__.py index b9963731..d36b346e 100644 --- a/netra/__init__.py +++ b/netra/__init__.py @@ -297,6 +297,16 @@ def shutdown(cls) -> None: cls.simulation.close() except Exception: pass + if hasattr(cls, "prompts") and cls.prompts is not None: + try: + cls.prompts.clear_cache() + except Exception: + pass + if hasattr(cls, "models") and cls.models is not None: + try: + cls.models.clear_cache() + except Exception: + pass @classmethod def get_meter(cls, name: str = "netra", version: Optional[str] = None) -> otel_metrics.Meter: @@ -415,6 +425,48 @@ def set_custom_event(cls, event_name: str, attributes: Any) -> None: else: logger.warning("Both event_name and attributes must be provided for custom events.") + @classmethod + def record_exception( + cls, + exception: BaseException, + attributes: Optional[Dict[str, Any]] = None, + escaped: Optional[bool] = False, + ) -> None: + """Record a caught exception on the currently active span. + + Use this inside ``except`` blocks to attach exception details to the + current span when the exception is being handled and will not propagate + to the SDK's automatic capture logic. + + The method adds a standard OpenTelemetry exception event (with type, + message, and stacktrace), sets the span status to ERROR, and records + the ``netra.error_message`` attribute for consistency with the rest of + the SDK. + + Args: + exception: The exception instance to record. + attributes: Optional extra attributes to attach to the exception + event. + + Example:: + + @workflow + def process_order(order_id: str) -> str: + try: + result = call_payment_api(order_id) + except PaymentError as exc: + Netra.record_exception(exc) + return "fallback_result" + return result + """ + if not isinstance(exception, BaseException): + logger.error( + "record_exception: exception must be a BaseException instance, got %s", + type(exception), + ) + return + SessionManager.record_exception(exception, attributes=attributes, escaped=escaped) + @classmethod def add_conversation(cls, conversation_type: ConversationType, role: str, content: Any) -> None: """ @@ -467,6 +519,28 @@ def set_root_output(cls, value: Any) -> None: """ SessionManager.set_root_output(value) + @classmethod + def set_root_output_stream(cls, value: Any) -> Any: + """ + Wrap a stream so the accumulated output is set on the root span when iteration ends. + + The returned object is a transparent proxy — iterate over it instead of the original:: + + stream = Netra.set_root_output_stream(stream) + for chunk in stream: + ... + + Supports both sync and async iterables. Returns *value* unchanged if no active trace + context exists or if *value* is not iterable. + + Args: + value: The stream to wrap (Netra-instrumented or any generic iterable). + + Returns: + A wrapped stream proxy, or *value* unchanged if wrapping is not possible. + """ + return SessionManager.set_root_output_stream(value) + @classmethod def start_span( cls, diff --git a/netra/cache.py b/netra/cache.py new file mode 100644 index 00000000..fdd79c5a --- /dev/null +++ b/netra/cache.py @@ -0,0 +1,41 @@ +import threading +import time +from typing import Dict, Generic, Optional, Tuple, TypeVar + +T = TypeVar("T") + + +class TTLCache(Generic[T]): + """In-memory TTL cache for SDK read API responses.""" + + def __init__(self, default_ttl: int = 60) -> None: + self._default_ttl = default_ttl + self._store: Dict[str, Tuple[T, float]] = {} + self._lock = threading.Lock() + + def get(self, key: str) -> Optional[T]: + with self._lock: + entry = self._store.get(key) + if entry is None: + return None + value, expires_at = entry + if time.monotonic() > expires_at: + del self._store[key] + return None + return value + + def set(self, key: str, value: T, ttl: Optional[int] = None) -> None: + ttl_seconds = self._default_ttl if ttl is None else ttl + if ttl_seconds <= 0: + return + expires_at = time.monotonic() + ttl_seconds + with self._lock: + self._store[key] = (value, expires_at) + + def invalidate(self, key: str) -> None: + with self._lock: + self._store.pop(key, None) + + def clear(self) -> None: + with self._lock: + self._store.clear() diff --git a/netra/evaluation/__init__.py b/netra/evaluation/__init__.py index 2f2959b2..30fcd25a 100644 --- a/netra/evaluation/__init__.py +++ b/netra/evaluation/__init__.py @@ -2,20 +2,24 @@ from netra.evaluation.evaluator import BaseEvaluator from netra.evaluation.models import ( DatasetItem, + DatasetType, EvaluatorConfig, EvaluatorContext, EvaluatorOutput, LocalDataset, ScoreType, + TurnType, ) __all__ = [ - "Evaluation", - "DatasetItem", "BaseEvaluator", + "DatasetItem", + "DatasetType", + "Evaluation", + "EvaluatorConfig", "EvaluatorContext", "EvaluatorOutput", - "EvaluatorConfig", - "ScoreType", "LocalDataset", + "ScoreType", + "TurnType", ] diff --git a/netra/evaluation/api.py b/netra/evaluation/api.py index b27fbdb0..6f1724c6 100644 --- a/netra/evaluation/api.py +++ b/netra/evaluation/api.py @@ -11,6 +11,7 @@ Dataset, DatasetItem, DatasetRecord, + DatasetType, EvaluatorConfig, GetAllDatasetsResponse, GetDatasetItemsResponse, @@ -48,7 +49,13 @@ def __init__(self, config: Config) -> None: self._config = config self._client = EvaluationHttpClient(config) - def create_dataset(self, name: str, tags: Optional[List[str]] = None, turn_type: TurnType = TurnType.SINGLE) -> Any: + def create_dataset( + self, + name: str, + dataset_type: DatasetType = DatasetType.TEXT, + turn_type: TurnType = TurnType.SINGLE, + tags: Optional[List[str]] = None, + ) -> Any: """ Create an empty dataset and return its id on success, else None. @@ -63,7 +70,7 @@ def create_dataset(self, name: str, tags: Optional[List[str]] = None, turn_type: if not name: logger.error("netra.evaluation: Failed to create dataset: dataset name is required") return None - response = self._client.create_dataset(name=name, tags=tags, turn_type=turn_type) + response = self._client.create_dataset(name=name, dataset_type=dataset_type, turn_type=turn_type, tags=tags) if not response: return None diff --git a/netra/evaluation/client.py b/netra/evaluation/client.py index de397911..b41ce10b 100644 --- a/netra/evaluation/client.py +++ b/netra/evaluation/client.py @@ -7,7 +7,7 @@ import httpx from netra.config import Config -from netra.evaluation.models import DatasetItem, TurnType +from netra.evaluation.models import DatasetItem, DatasetType, TurnType logger = logging.getLogger(__name__) @@ -102,7 +102,11 @@ def _get_timeout(self) -> float: return 10.0 def create_dataset( - self, name: Optional[str], tags: Optional[List[str]] = None, turn_type: TurnType = TurnType.SINGLE + self, + name: Optional[str], + dataset_type: DatasetType = DatasetType.TEXT, + turn_type: TurnType = TurnType.SINGLE, + tags: Optional[List[str]] = None, ) -> Any: """ Create an empty dataset @@ -121,7 +125,12 @@ def create_dataset( return None try: url = "/evaluations/dataset" - payload: Dict[str, Any] = {"name": name, "tags": tags if tags else [], "turnType": turn_type.value} + payload: Dict[str, Any] = { + "name": name, + "tags": tags if tags else [], + "turnType": turn_type.value, + "datasetType": dataset_type.value, + } response = self._client.post(url, json=payload) response.raise_for_status() data = response.json() diff --git a/netra/evaluation/models.py b/netra/evaluation/models.py index 15d6b0a6..d2bade65 100644 --- a/netra/evaluation/models.py +++ b/netra/evaluation/models.py @@ -136,6 +136,11 @@ class TurnType(str, Enum): MULTI = "multi" +class DatasetType(str, Enum): + TEXT = "text" + IMAGE = "image" + + @dataclass class ItemProcessingResult: """Result of processing a single dataset item.""" diff --git a/netra/instrumentation/agno/utils.py b/netra/instrumentation/agno/utils.py index 2a3829da..26be21aa 100644 --- a/netra/instrumentation/agno/utils.py +++ b/netra/instrumentation/agno/utils.py @@ -941,7 +941,7 @@ def set_request_attributes( span.set_attribute("input", input_content) -def set_response_attributes(span: Span, response: Any) -> None: +def set_response_attributes(span: Span, response: Any) -> Optional[str]: """Set response-side span attributes from an Agno response object. Writes token usage, output content, response ID, and output type. @@ -951,7 +951,7 @@ def set_response_attributes(span: Span, response: Any) -> None: response: The Agno response object (RunResponse, TeamRunOutput, etc.). """ if not span.is_recording(): - return + return None usage = extract_token_usage(response) if usage: @@ -965,6 +965,8 @@ def set_response_attributes(span: Span, response: Any) -> None: if response_id: span.set_attribute(ATTR_RESPONSE_ID, response_id) + return output + def sanitize_headers(raw_headers: List[Tuple[bytes, bytes]]) -> Dict[str, str]: """Convert ASGI raw header pairs to a dict with sensitive values redacted. diff --git a/netra/instrumentation/agno/wrappers.py b/netra/instrumentation/agno/wrappers.py index 8aab90d9..91dacbb9 100644 --- a/netra/instrumentation/agno/wrappers.py +++ b/netra/instrumentation/agno/wrappers.py @@ -157,6 +157,8 @@ def _set_common_span_attributes(span: Span, entity_type: str) -> None: class _BaseStreamWrapper: """Shared base for all span streaming wrappers.""" + _netra_stream_wrapper = True + def __init__(self, span: Span, response: Any, ctx_token: Any = None) -> None: """Initialise the streaming wrapper. @@ -222,10 +224,14 @@ class _AgentStreamOutputMixin: def _set_output_on_success(self) -> None: """Write accumulated run content and token usage to the span before it closes.""" + self._netra_output = "" if self._last_response is not None: - set_response_attributes(self._span, self._last_response) + output = set_response_attributes(self._span, self._last_response) + self._netra_output = output if output else "" if self._content_chunks: - self._span.set_attribute("output", "".join(self._content_chunks)) + output = "".join(self._content_chunks) + self._span.set_attribute("output", output) + self._netra_output = output class _LlmStreamOutputMixin: @@ -239,9 +245,11 @@ class _LlmStreamOutputMixin: def _set_output_on_success(self) -> None: """Write accumulated LLM content, token usage, and timing metrics to the span.""" output_str = None + self._netra_output = "" if self._content_chunks: content = "".join(self._content_chunks) output_str = json.dumps([{"role": "assistant", "content": content}]) + self._netra_output = content elif self._tool_calls: try: tc_serialized = serialize_value(self._tool_calls, clean=True) @@ -251,10 +259,12 @@ def _set_output_on_success(self) -> None: except (json.JSONDecodeError, ValueError): tc_data = tc_serialized output_str = json.dumps([{"role": "assistant", "tool_calls": tc_data}]) + self._netra_output = tc_serialized except Exception as e: logger.debug("netra.instrumentation.agno: failed to serialize tool_calls for LLM output: %s", e) elif self._last_response is not None: output_str = format_response_as_output(self._last_response) + self._netra_output = output_str if output_str else "" if output_str: self._span.set_attribute("output", output_str) set_llm_completion_attributes(self._span, output_str) diff --git a/netra/instrumentation/cerebras/wrappers.py b/netra/instrumentation/cerebras/wrappers.py index e0c2f065..2ebd2863 100644 --- a/netra/instrumentation/cerebras/wrappers.py +++ b/netra/instrumentation/cerebras/wrappers.py @@ -40,6 +40,8 @@ def _detect_streaming(args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> bool: class StreamingWrapper(ObjectProxy): # type: ignore[misc] """Wrapper for streaming responses""" + _netra_stream_wrapper = True + def __init__(self, span: Span, response: Iterator[Any], request_kwargs: Dict[str, Any]) -> None: super().__init__(response) self._span = span @@ -59,6 +61,15 @@ def _ensure_choice(self, index: int) -> None: else: self._complete_response["choices"].append({"text": ""}) + def _extract_content_text(self) -> str: + """Extract the plain text content from the accumulated response.""" + parts = [] + for choice in self._complete_response.get("choices", []): + msg = choice.get("message", {}) + if content := msg.get("content"): + parts.append(content) + return "".join(parts) + def __iter__(self) -> Iterator[Any]: return self @@ -129,6 +140,7 @@ def _finalize_span(self) -> None: """Finalize span when streaming is complete""" record_span_timing(self._span, LLM_RESPONSE_DURATION) set_response_attributes(self._span, self._complete_response) + self._netra_output = self._extract_content_text() self._span.set_status(Status(StatusCode.OK)) self._span.end() @@ -136,6 +148,8 @@ def _finalize_span(self) -> None: class AsyncStreamingWrapper(ObjectProxy): # type: ignore[misc] """Async wrapper for streaming responses""" + _netra_stream_wrapper = True + def __init__(self, span: Span, response: AsyncIterator[Any], request_kwargs: Dict[str, Any]) -> None: super().__init__(response) self._span = span @@ -155,6 +169,15 @@ def _ensure_choice(self, index: int) -> None: else: self._complete_response["choices"].append({"text": ""}) + def _extract_content_text(self) -> str: + """Extract the plain text content from the accumulated response.""" + parts = [] + for choice in self._complete_response.get("choices", []): + msg = choice.get("message", {}) + if content := msg.get("content"): + parts.append(content) + return "".join(parts) + def __aiter__(self) -> AsyncIterator[Any]: return self @@ -227,6 +250,7 @@ def _finalize_span(self) -> None: """Finalize span when streaming is complete""" record_span_timing(self._span, LLM_RESPONSE_DURATION) set_response_attributes(self._span, self._complete_response) + self._netra_output = self._extract_content_text() self._span.set_status(Status(StatusCode.OK)) self._span.end() diff --git a/netra/instrumentation/fastapi/utils.py b/netra/instrumentation/fastapi/utils.py index ae82c6ba..31060fac 100644 --- a/netra/instrumentation/fastapi/utils.py +++ b/netra/instrumentation/fastapi/utils.py @@ -134,6 +134,7 @@ def build_request_url(scope: Dict[str, Any]) -> str: url = f"{scheme}://{host}{path}" else: url = f"{scheme}://{host}:{port}{path}" + else: url = path if query_string: diff --git a/netra/instrumentation/google_genai/wrappers.py b/netra/instrumentation/google_genai/wrappers.py index 21472d9d..934b9e91 100644 --- a/netra/instrumentation/google_genai/wrappers.py +++ b/netra/instrumentation/google_genai/wrappers.py @@ -235,6 +235,8 @@ async def wrapper(wrapped: Callable[..., Any], instance: Any, args: Tuple[Any, . class StreamingWrapper: + _netra_stream_wrapper = True + def __init__(self, span: Span, response: Iterator[Any]) -> None: self._span = span self._buffer: dict[Any, Any] = {"chunk": None, "content": ""} @@ -272,11 +274,14 @@ def _process_chunk(self, chunk: Any) -> None: def _finalize_span(self) -> None: record_span_timing(self._span, LLM_RESPONSE_DURATION) set_response_attributes(self._span, self._buffer) + self._netra_output = self._buffer.get("content", "") if isinstance(self._buffer, dict) else "" self._span.set_status(Status(StatusCode.OK)) self._span.end() class AsyncStreamingWrapper: + _netra_stream_wrapper = True + def __init__(self, span: Span, response: AsyncIterator[Any]) -> None: self._span = span self._buffer: dict[Any, Any] = {"chunk": None, "content": ""} @@ -313,5 +318,6 @@ def _process_chunk(self, chunk: Any) -> None: def _finalize_span(self) -> None: record_span_timing(self._span, LLM_RESPONSE_DURATION) set_response_attributes(self._span, self._buffer) + self._netra_output = self._buffer.get("content", "") if isinstance(self._buffer, dict) else "" self._span.set_status(Status(StatusCode.OK)) self._span.end() diff --git a/netra/instrumentation/groq/wrappers.py b/netra/instrumentation/groq/wrappers.py index e64241cb..872e7f42 100644 --- a/netra/instrumentation/groq/wrappers.py +++ b/netra/instrumentation/groq/wrappers.py @@ -26,6 +26,8 @@ class StreamingWrapper(ObjectProxy): # type: ignore[misc] """Wrapper for streaming responses (OpenAI-style).""" + _netra_stream_wrapper = True + def __init__(self, span: Span, response: Iterator[Any], request_kwargs: Dict[str, Any]) -> None: super().__init__(response) self._span = span @@ -43,6 +45,15 @@ def _ensure_choice(self, index: int) -> None: else: self._complete_response["choices"].append({"text": ""}) + def _extract_content_text(self) -> str: + """Extract the plain text content from the accumulated response.""" + parts = [] + for choice in self._complete_response.get("choices", []): + msg = choice.get("message", {}) + if content := msg.get("content"): + parts.append(content) + return "".join(parts) + def __iter__(self) -> Iterator[Any]: return self @@ -98,6 +109,7 @@ def _process_chunk(self, chunk: Any) -> None: def _finalize_span(self) -> None: record_span_timing(self._span, LLM_RESPONSE_DURATION) set_response_attributes(self._span, self._complete_response) + self._netra_output = self._extract_content_text() self._span.set_status(Status(StatusCode.OK)) self._span.end() @@ -105,6 +117,8 @@ def _finalize_span(self) -> None: class AsyncStreamingWrapper(ObjectProxy): # type: ignore[misc] """Async wrapper for streaming responses (OpenAI-style).""" + _netra_stream_wrapper = True + def __init__(self, span: Span, response: AsyncIterator[Any], request_kwargs: Dict[str, Any]) -> None: super().__init__(response) self._span = span @@ -122,6 +136,15 @@ def _ensure_choice(self, index: int) -> None: else: self._complete_response["choices"].append({"text": ""}) + def _extract_content_text(self) -> str: + """Extract the plain text content from the accumulated response.""" + parts = [] + for choice in self._complete_response.get("choices", []): + msg = choice.get("message", {}) + if content := msg.get("content"): + parts.append(content) + return "".join(parts) + def __aiter__(self) -> AsyncIterator[Any]: return self @@ -177,6 +200,7 @@ def _process_chunk(self, chunk: Any) -> None: def _finalize_span(self) -> None: record_span_timing(self._span, LLM_RESPONSE_DURATION) set_response_attributes(self._span, self._complete_response) + self._netra_output = self._extract_content_text() self._span.set_status(Status(StatusCode.OK)) self._span.end() diff --git a/netra/instrumentation/litellm/__init__.py b/netra/instrumentation/litellm/__init__.py index 02a9654f..0d9ac643 100644 --- a/netra/instrumentation/litellm/__init__.py +++ b/netra/instrumentation/litellm/__init__.py @@ -115,6 +115,12 @@ def _uninstrument(self, **kwargs): # type: ignore[no-untyped-def] except (AttributeError, ModuleNotFoundError): logger.error("Failed to uninstrument LiteLLM completions") + try: + unwrap("litellm", "responses") + unwrap("litellm", "aresponses") + except (AttributeError, ModuleNotFoundError): + logger.error("Failed to uninstrument LiteLLM responses") + try: unwrap("litellm", "embedding") unwrap("litellm", "aembedding") diff --git a/netra/instrumentation/litellm/wrappers.py b/netra/instrumentation/litellm/wrappers.py index 659acfd9..7ff657fc 100644 --- a/netra/instrumentation/litellm/wrappers.py +++ b/netra/instrumentation/litellm/wrappers.py @@ -335,6 +335,8 @@ async def wrapper( class StreamingWrapper(ObjectProxy): # type: ignore[misc] """Wrapper for streaming responses""" + _netra_stream_wrapper = True + def __init__(self, span: Span, response: Iterator[Any], request_kwargs: Dict[str, Any]) -> None: super().__init__(response) self._span = span @@ -354,6 +356,15 @@ def _ensure_choice(self, index: int) -> None: else: self._complete_response["choices"].append({"text": ""}) + def _extract_content_text(self) -> str: + """Extract the plain text content from the accumulated response.""" + parts = [] + for choice in self._complete_response.get("choices", []): + msg = choice.get("message", {}) + if content := msg.get("content"): + parts.append(content) + return "".join(parts) + def __enter__(self) -> "StreamingWrapper": if hasattr(self.__wrapped__, "__enter__"): self.__wrapped__.__enter__() @@ -444,6 +455,7 @@ def _finalize_span(self) -> None: """Finalize span when streaming is complete""" record_span_timing(self._span, LLM_RESPONSE_DURATION) set_response_attributes(self._span, self._complete_response) + self._netra_output = self._extract_content_text() self._span.set_status(Status(StatusCode.OK)) self._span.end() @@ -451,6 +463,8 @@ def _finalize_span(self) -> None: class AsyncStreamingWrapper(ObjectProxy): # type: ignore[misc] """Async wrapper for streaming responses""" + _netra_stream_wrapper = True + def __init__(self, span: Span, response: AsyncIterator[Any], request_kwargs: Dict[str, Any]) -> None: super().__init__(response) self._span = span @@ -470,6 +484,15 @@ def _ensure_choice(self, index: int) -> None: else: self._complete_response["choices"].append({"text": ""}) + def _extract_content_text(self) -> str: + """Extract the plain text content from the accumulated response.""" + parts = [] + for choice in self._complete_response.get("choices", []): + msg = choice.get("message", {}) + if content := msg.get("content"): + parts.append(content) + return "".join(parts) + async def __aenter__(self) -> "AsyncStreamingWrapper": if hasattr(self.__wrapped__, "__aenter__"): await self.__wrapped__.__aenter__() @@ -560,5 +583,6 @@ def _finalize_span(self) -> None: """Finalize span when streaming is complete""" record_span_timing(self._span, LLM_RESPONSE_DURATION) set_response_attributes(self._span, self._complete_response) + self._netra_output = self._extract_content_text() self._span.set_status(Status(StatusCode.OK)) self._span.end() diff --git a/netra/instrumentation/openai/wrappers.py b/netra/instrumentation/openai/wrappers.py index e32e82cf..3d013f5f 100644 --- a/netra/instrumentation/openai/wrappers.py +++ b/netra/instrumentation/openai/wrappers.py @@ -281,6 +281,8 @@ async def wrapper(wrapped: Callable[..., Awaitable[Any]], instance: Any, args: A class StreamingWrapper(ObjectProxy): # type: ignore[misc] """Wrapper for streaming responses""" + _netra_stream_wrapper = True + def __init__(self, span: Span, response: Iterator[Any], request_kwargs: Dict[str, Any]) -> None: super().__init__(response) self._span = span @@ -300,6 +302,15 @@ def _ensure_choice(self, index: int) -> None: else: self._complete_response["choices"].append({"text": ""}) + def _extract_content_text(self) -> str: + """Extract the plain text content from the accumulated response.""" + parts = [] + for choice in self._complete_response.get("choices", []): + msg = choice.get("message", {}) + if content := msg.get("content"): + parts.append(content) + return "".join(parts) + def __enter__(self) -> "StreamingWrapper": if hasattr(self.__wrapped__, "__enter__"): self.__wrapped__.__enter__() @@ -412,6 +423,7 @@ def _finalize_span(self) -> None: msg["tool_calls"] = [msg["tool_calls"][i] for i in sorted(msg["tool_calls"].keys())] record_span_timing(self._span, LLM_RESPONSE_DURATION) set_response_attributes(self._span, self._complete_response) + self._netra_output = self._extract_content_text() self._span.set_status(Status(StatusCode.OK)) self._span.end() @@ -419,6 +431,8 @@ def _finalize_span(self) -> None: class AsyncStreamingWrapper(ObjectProxy): # type: ignore[misc] """Async wrapper for streaming responses""" + _netra_stream_wrapper = True + def __init__(self, span: Span, response: AsyncIterator[Any], request_kwargs: Dict[str, Any]) -> None: super().__init__(response) self._span = span @@ -438,6 +452,15 @@ def _ensure_choice(self, index: int) -> None: else: self._complete_response["choices"].append({"text": ""}) + def _extract_content_text(self) -> str: + """Extract the plain text content from the accumulated response.""" + parts = [] + for choice in self._complete_response.get("choices", []): + msg = choice.get("message", {}) + if content := msg.get("content"): + parts.append(content) + return "".join(parts) + async def __aenter__(self) -> "AsyncStreamingWrapper": if hasattr(self.__wrapped__, "__aenter__"): await self.__wrapped__.__aenter__() @@ -550,5 +573,6 @@ def _finalize_span(self) -> None: msg["tool_calls"] = [msg["tool_calls"][i] for i in sorted(msg["tool_calls"].keys())] record_span_timing(self._span, LLM_RESPONSE_DURATION) set_response_attributes(self._span, self._complete_response) + self._netra_output = self._extract_content_text() self._span.set_status(Status(StatusCode.OK)) self._span.end() diff --git a/netra/instrumentation/stream_utils.py b/netra/instrumentation/stream_utils.py new file mode 100644 index 00000000..0a81440b --- /dev/null +++ b/netra/instrumentation/stream_utils.py @@ -0,0 +1,201 @@ +""" +Utilities for wrapping stream objects so that when iteration completes, the +accumulated output is automatically set on the root span of the current trace. + +Three flows are supported: + + 1. Netra-wrapped stream (``_netra_stream_wrapper = True``) + The inner instrumentation wrapper has already accumulated the output + in ``_netra_output``. The outer tap simply delegates iteration and + reads that attribute once the inner wrapper signals exhaustion. + + 2. Generic / unknown stream + Any iterable whose type Netra does not know about. Chunks are + converted to strings via ``str(chunk)`` and concatenated. + + 3. Objects that carry no iterator protocol are returned unchanged with a + warning log. +""" + +import logging +from typing import Any, Callable, List, Union + +from opentelemetry.trace import Span + +from netra.session_manager import NETRA_USER_OUTPUT +from netra.utils import serialize_value + +logger = logging.getLogger(__name__) + + +def _set_output_on_root(root_span: Span, output: Any) -> None: + """Write serialized *output* to *root_span* as ``NETRA_USER_OUTPUT``.""" + try: + serialized = serialize_value(output) + if serialized: + root_span.set_attribute(NETRA_USER_OUTPUT, serialized) + except Exception: + logger.warning("root_output_stream: failed to set output on root span", exc_info=True) + + +# Extractors — injected at construction time, kept stateless +def _netra_extractor(wrapper: Union["RootOutputSyncStreamWrapper", "RootOutputAsyncStreamWrapper"]) -> Any: + """Read accumulated output from the inner Netra wrapper.""" + inner = wrapper._stream + output = getattr(inner, "_netra_output", None) + if output is not None: + return output + # Nested wrapping: inner is another RootOutput* wrapper with _chunks + chunks = getattr(inner, "_chunks", None) + if chunks is not None: + return "".join(chunks) + return None + + +def _generic_extractor(wrapper: Union["RootOutputSyncStreamWrapper", "RootOutputAsyncStreamWrapper"]) -> Any: + """Return the concatenated stringified chunks.""" + return "".join(wrapper._chunks) + + +# Sync wrapper +class RootOutputSyncStreamWrapper: + """Wraps a sync iterable; on exhaustion sets the output on the root span.""" + + _netra_stream_wrapper = True + + def __init__(self, stream: Any, root_span: Span, extractor: Callable[[Any], Any]) -> None: + self._stream = stream + self._root_span = root_span + self._extractor = extractor + self._chunks: List[str] = [] + self._track_chunks: bool = extractor is _generic_extractor + self._committed = False + + def __iter__(self) -> "RootOutputSyncStreamWrapper": + return self + + def __next__(self) -> Any: + try: + chunk = next(self._stream) + if self._track_chunks: + self._chunks.append(str(chunk)) + return chunk + except StopIteration: + self._commit() + raise + + def __enter__(self) -> "RootOutputSyncStreamWrapper": + if hasattr(self._stream, "__enter__"): + self._stream.__enter__() + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + if hasattr(self._stream, "__exit__"): + self._stream.__exit__(exc_type, exc_val, exc_tb) + if exc_type is None: + self._commit() + + def __getattr__(self, name: str) -> Any: + return getattr(self._stream, name) + + def __del__(self) -> None: + if not self._committed: + self._commit() + + def _commit(self) -> None: + if self._committed: + return + self._committed = True + try: + _set_output_on_root(self._root_span, self._extractor(self)) + except Exception: + logger.debug("RootOutputSyncWrapper: failed to commit output to root span", exc_info=True) + + +# Async wrapper +class RootOutputAsyncStreamWrapper: + """Wraps an async iterable; on exhaustion sets the output on the root span.""" + + _netra_stream_wrapper = True + + def __init__(self, stream: Any, root_span: Span, extractor: Callable[[Any], Any]) -> None: + self._stream = stream + self._root_span = root_span + self._extractor = extractor + self._chunks: List[str] = [] + self._track_chunks: bool = extractor is _generic_extractor + self._committed = False + + def __aiter__(self) -> "RootOutputAsyncStreamWrapper": + return self + + async def __anext__(self) -> Any: + try: + chunk = await self._stream.__anext__() + if self._track_chunks: + self._chunks.append(str(chunk)) + return chunk + except StopAsyncIteration: + self._commit() + raise + + async def __aenter__(self) -> "RootOutputAsyncStreamWrapper": + if hasattr(self._stream, "__aenter__"): + await self._stream.__aenter__() + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + if hasattr(self._stream, "__aexit__"): + await self._stream.__aexit__(exc_type, exc_val, exc_tb) + if exc_type is None: + self._commit() + + def __getattr__(self, name: str) -> Any: + return getattr(self._stream, name) + + def __del__(self) -> None: + if not self._committed: + self._commit() + + def _commit(self) -> None: + if self._committed: + return + self._committed = True + try: + _set_output_on_root(self._root_span, self._extractor(self)) + except Exception: + logger.debug("RootOutputAsyncWrapper: failed to commit output to root span", exc_info=True) + + +def wrap_stream_for_root_output(stream: Any, root_span: Span) -> Any: + """Wrap *stream* so the accumulated output is set on *root_span* when iteration ends. + + Detection order: + 1. ``_netra_stream_wrapper`` attribute present (Netra-wrapped) + 2. Has ``__aiter__`` or ``__iter__`` (generic) + 3. Not iterable (return unchanged) + + Args: + stream: The stream to wrap. May be sync or async. + root_span: The root OTel span that will receive the ``NETRA_USER_OUTPUT`` attribute. + + Returns: + A :class:`RootOutputSyncWrapper`, :class:`RootOutputAsyncWrapper`, or the + original *stream* unchanged if it is not iterable. + """ + is_netra = getattr(stream, "_netra_stream_wrapper", False) + extractor: Callable[[Union["RootOutputSyncStreamWrapper", "RootOutputAsyncStreamWrapper"]], Any] = ( + _netra_extractor if is_netra else _generic_extractor + ) + + if hasattr(stream, "__aiter__"): + return RootOutputAsyncStreamWrapper(stream, root_span, extractor) + + if hasattr(stream, "__iter__"): + return RootOutputSyncStreamWrapper(stream, root_span, extractor) + + logger.warning( + "set_root_output_stream: passed object of type %s is not iterable; returning unchanged", + type(stream).__name__, + ) + return stream diff --git a/netra/models/api.py b/netra/models/api.py index 81ce7c94..ab1a746f 100644 --- a/netra/models/api.py +++ b/netra/models/api.py @@ -1,11 +1,14 @@ import logging from typing import Any, List, Optional +from netra.cache import TTLCache from netra.config import Config from netra.models.client import ModelsHttpClient logger = logging.getLogger(__name__) +MODEL_PRICING_CACHE_TTL_SECONDS = 300 + class Models: """Public entry-point exposed as Netra.models""" @@ -19,25 +22,53 @@ def __init__(self, config: Config) -> None: """ self._config = config self._client = ModelsHttpClient(config) + self._cache: TTLCache[Any] = TTLCache(default_ttl=MODEL_PRICING_CACHE_TTL_SECONDS) + + def clear_cache(self) -> None: + """Clear all cached model pricing entries.""" + self._cache.clear() - def get_model_pricing(self, name: Optional[str] = None) -> List[Any] | Any: + def get_model_pricing( + self, + name: Optional[str] = None, + use_cache: bool = False, + cache_ttl: Optional[int] = None, + ) -> List[Any] | Any: """ Fetch models for the project associated with the configured API key. Args: name: Optional model name to filter results. + use_cache: When True, read/write the in-memory cache (default: False). + cache_ttl: Per-call cache TTL in seconds (default: 300). Returns: - List of model dicts from the API response, or None on failure. + List of model dicts from the API response, or empty list on failure. + When use_cache is True, do not mutate the returned list or nested + dicts/prices — the same objects may be served on later cache hits. """ + cache_key = f"model:pricing:{name or 'all'}" + + if use_cache: + cached = self._cache.get(cache_key) + if cached is not None: + return cached + result = self._client.get_model_pricing(name=name) if not isinstance(result, dict): return result + # Client failure sentinel is {}; do not treat as a successful empty list. + if not result: + return [] + items = result.get("data", []) or [] if not isinstance(items, list): logger.error("netra.models: Unexpected response format; 'data' is not a list") return [] + if use_cache: + self._cache.set(cache_key, items, cache_ttl) + return items diff --git a/netra/prompts/api.py b/netra/prompts/api.py index c85ed714..81f15747 100644 --- a/netra/prompts/api.py +++ b/netra/prompts/api.py @@ -1,11 +1,14 @@ import logging -from typing import Any +from typing import Any, Optional +from netra.cache import TTLCache from netra.config import Config from netra.prompts.client import PromptsHttpClient logger = logging.getLogger(__name__) +PROMPT_CACHE_TTL_SECONDS = 60 + class Prompts: """ @@ -21,20 +24,45 @@ def __init__(self, cfg: Config) -> None: """ self._config = cfg self._client = PromptsHttpClient(cfg) + self._cache: TTLCache[Any] = TTLCache(default_ttl=PROMPT_CACHE_TTL_SECONDS) + + def clear_cache(self) -> None: + """Clear all cached prompt entries.""" + self._cache.clear() - def get_prompt(self, name: str, label: str = "production") -> Any: + def get_prompt( + self, + name: str, + label: str = "production", + use_cache: bool = False, + cache_ttl: Optional[int] = None, + ) -> Any: """ Fetch a prompt version by name and label. Args: name: Name of the prompt label: Label of the prompt version (default: "production") + use_cache: When True, read/write the in-memory cache (default: False) + cache_ttl: Per-call cache TTL in seconds (default: PROMPT_CACHE_TTL_SECONDS) Returns: - Prompt version data or empty dict if not found + Prompt version data or None/empty dict if not found """ if not name: logger.error("netra.prompts: name is required to fetch a prompt") return None - return self._client.get_prompt_version(prompt_name=name, label=label) + cache_key = f"prompt:{name}:{label}" + + if use_cache: + cached = self._cache.get(cache_key) + if cached is not None: + return cached + + result = self._client.get_prompt_version(prompt_name=name, label=label) + + if use_cache and result is not None and result != {}: + self._cache.set(cache_key, result, cache_ttl) + + return result diff --git a/netra/session_manager.py b/netra/session_manager.py index 7eee85e2..db8bdc14 100644 --- a/netra/session_manager.py +++ b/netra/session_manager.py @@ -1,4 +1,3 @@ -import json import logging from datetime import datetime from enum import Enum @@ -9,11 +8,15 @@ from opentelemetry import trace from netra.config import Config -from netra.utils import process_content_for_max_len +from netra.utils import process_content_for_max_len, serialize_value logger = logging.getLogger(__name__) +NETRA_USER_INPUT = "netra.user.input" +NETRA_USER_OUTPUT = "netra.user.output" + + class ConversationType(str, Enum): INPUT = "input" OUTPUT = "output" @@ -399,11 +402,8 @@ def set_input(cls, value: Any) -> None: value: The input value to record. """ try: - if isinstance(value, (dict, list)): - serialized = json.dumps(value, default=str)[: Config.ATTRIBUTE_MAX_LEN] - else: - serialized = str(value)[: Config.ATTRIBUTE_MAX_LEN] - cls.set_attribute_on_active_span("netra.user.input", serialized) + serialized = serialize_value(value) + cls.set_attribute_on_active_span(NETRA_USER_INPUT, serialized) except Exception: logger.exception("SessionManager.set_input: failed to set input attribute") @@ -419,11 +419,8 @@ def set_output(cls, value: Any) -> None: value: The output value to record. """ try: - if isinstance(value, (dict, list)): - serialized = json.dumps(value, default=str)[: Config.ATTRIBUTE_MAX_LEN] - else: - serialized = str(value)[: Config.ATTRIBUTE_MAX_LEN] - cls.set_attribute_on_active_span("netra.user.output", serialized) + serialized = serialize_value(value) + cls.set_attribute_on_active_span(NETRA_USER_OUTPUT, serialized) except Exception: logger.exception("SessionManager.set_output: failed to set output attribute") @@ -438,11 +435,8 @@ def set_root_input(cls, value: Any) -> None: value: The input value to record. """ try: - if isinstance(value, (dict, list)): - serialized = json.dumps(value, default=str)[: Config.ATTRIBUTE_MAX_LEN] - else: - serialized = str(value)[: Config.ATTRIBUTE_MAX_LEN] - cls.set_attribute_on_root_span("netra.user.input", serialized) + serialized = serialize_value(value) + cls.set_attribute_on_root_span(NETRA_USER_INPUT, serialized) except Exception: logger.exception("SessionManager.set_root_input: failed to set input attribute") @@ -457,14 +451,44 @@ def set_root_output(cls, value: Any) -> None: value: The output value to record. """ try: - if isinstance(value, (dict, list)): - serialized = json.dumps(value, default=str)[: Config.ATTRIBUTE_MAX_LEN] - else: - serialized = str(value)[: Config.ATTRIBUTE_MAX_LEN] - cls.set_attribute_on_root_span("netra.user.output", serialized) + serialized = serialize_value(value) + cls.set_attribute_on_root_span(NETRA_USER_OUTPUT, serialized) except Exception: logger.exception("SessionManager.set_root_output: failed to set output attribute") + @classmethod + def set_root_output_stream(cls, value: Any) -> Any: + """Wrap a stream so that the accumulated output is set on the root span when iteration ends. + + The stream is wrapped transparently — the user should iterate over the returned object + instead of the original stream. On exhaustion (or garbage collection), the output is + automatically written to the ``netra.user.output`` attribute of the root span for the + current trace, which is then promoted to ``output`` by the export pipeline. + + Supports both sync iterables and async iterables. + + Args: + value: The stream to wrap. May be a Netra-instrumented wrapper or any generic iterable. + + Returns: + A wrapped stream proxy. Returns *value* unchanged if no active trace context + exists or if *value* is not iterable, so callers can always reassign safely:: + + stream = Netra.set_root_output_stream(stream) + """ + try: + from netra.instrumentation.stream_utils import wrap_stream_for_root_output + from netra.processors.root_span_processor import RootSpanProcessor + + root_span = RootSpanProcessor.get_root_span(trace.get_current_span()) + if not root_span: + logger.warning("SessionManager.set_root_output_stream: no root span found for current trace") + return value + return wrap_stream_for_root_output(value, root_span) + except Exception: + logger.exception("SessionManager.set_root_output_stream: failed to wrap stream") + return value + @classmethod def set_attribute_on_root_span(cls, attr_key: str, attr_value: Any) -> None: """Set an attribute on the root span of the current trace. @@ -492,6 +516,36 @@ def set_attribute_on_root_span(cls, attr_key: str, attr_value: Any) -> None: except Exception: logger.exception("Failed to set attribute '%s' on root span", attr_key) + @staticmethod + def record_exception( + exception: BaseException, + attributes: Optional[Dict[str, Any]] = None, + escaped: Optional[bool] = False, + ) -> None: + """Record a caught exception on the currently active span. + + Adds a standard OTel exception event to the span and marks its status + as ERROR. Intended to be called from within user exception-handling + blocks where the exception would otherwise not propagate to the SDK's + automatic capture logic. + + Args: + exception: The exception instance to record. + attributes: Optional extra attributes to attach to the exception + event. + """ + try: + span = trace.get_current_span() + if not (span and getattr(span, "is_recording", lambda: False)()): + logger.warning("record_exception: no active recording span to record exception on") + return + + span.record_exception(exception, attributes=attributes, escaped=escaped) + span.set_status(trace.Status(trace.StatusCode.ERROR, str(exception))) + span.set_attribute(f"{Config.LIBRARY_NAME}.error_message", str(exception)) + except Exception: + logger.exception("Failed to record exception on active span") + @staticmethod def set_attribute_on_active_span(attr_key: str, attr_value: Any) -> None: """ diff --git a/netra/span_wrapper.py b/netra/span_wrapper.py index 1e73b886..b6266553 100644 --- a/netra/span_wrapper.py +++ b/netra/span_wrapper.py @@ -161,9 +161,12 @@ def __exit__(self, exc_type: Optional[type], exc_val: Optional[Exception], exc_t # Handle status and errors if exc_type is None and self.status == "pending": - self.status = "success" - if self.span: - self.span.set_status(Status(StatusCode.OK)) + if self._span_has_error_status(): + self.status = "error" + else: + self.status = "success" + if self.span: + self.span.set_status(Status(StatusCode.OK)) elif exc_type is not None: self.status = "error" self.error_message = str(exc_val) @@ -211,6 +214,26 @@ def __exit__(self, exc_type: Optional[type], exc_val: Optional[Exception], exc_t # Don't suppress exceptions return False + def _span_has_error_status(self) -> Any: + """Check whether the underlying OTel span has already been set to ERROR. + + This handles the case where ``Netra.record_exception()`` (or any direct + OTel API call) marked the span as errored while the exception was caught + by user code, so ``__exit__`` receives ``exc_type is None``. + + Returns: + True if the span's status code is ERROR, False otherwise. + """ + if self.span is None: + return False + try: + status = getattr(self.span, "status", None) + if status is not None: + return status.status_code == StatusCode.ERROR + except Exception: + logger.exception("Failed to check span status on span '%s'", self.name) + return False + def set_attribute(self, key: str, value: str) -> "SpanWrapper": """ Set a single attribute and return self for method chaining. diff --git a/netra/utils.py b/netra/utils.py index 7d6d69b8..79265bf9 100644 --- a/netra/utils.py +++ b/netra/utils.py @@ -8,6 +8,7 @@ import logging from typing import AbstractSet, Any, Optional, Set +from netra.config import Config from netra.instrumentation.instruments import ( DEFAULT_INSTRUMENTS_FOR_ROOT, InstrumentSet, @@ -89,6 +90,20 @@ def process_content_for_max_len(content: Any, max_len: int) -> Any: return content +def serialize_value(value: Any) -> str: + """Serialize *value* to a string capped at ``Config.ATTRIBUTE_MAX_LEN``.""" + if value is None: + return "" + try: + import json + + serialized = json.dumps(value) if isinstance(value, (dict, list)) else str(value) + return truncate_string(serialized, Config.ATTRIBUTE_MAX_LEN) + except Exception: + logger.debug("utils: failed to serialize value", exc_info=True) + return "" + + def resolve_root_instruments( root_instruments: Optional[AbstractSet[NetraInstruments]], block_instruments: Optional[AbstractSet[NetraInstruments]], diff --git a/netra/version.py b/netra/version.py index a7059c5b..8569c099 100644 --- a/netra/version.py +++ b/netra/version.py @@ -1 +1 @@ -__version__ = "0.1.95" +__version__ = "0.1.96" diff --git a/pyproject.toml b/pyproject.toml index e0da22e0..0643b89e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "netra-sdk" -version = "0.1.95" +version = "0.1.96" 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." authors = [ {name = "Sooraj Thomas",email = "sooraj@keyvalue.systems"} diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 00000000..6827dcca --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,74 @@ +from unittest.mock import patch + +import pytest + +from netra.cache import TTLCache + + +class TestTTLCache: + def test_get_returns_none_for_missing_key(self) -> None: + cache = TTLCache[str]() + assert cache.get("missing") is None + + def test_set_and_get_returns_stored_value_before_ttl_expires(self) -> None: + cache = TTLCache[str](default_ttl=60) + cache.set("key", "value") + assert cache.get("key") == "value" + + def test_get_returns_none_after_ttl_expires(self) -> None: + with patch("netra.cache.time.monotonic", side_effect=[0.0, 1.1]): + cache = TTLCache[str](default_ttl=1) + cache.set("key", "value") + assert cache.get("key") is None + + def test_per_entry_ttl_override_expires_independently_of_default(self) -> None: + with patch("netra.cache.time.monotonic", side_effect=[0.0, 0.0, 1.1, 1.1]): + cache = TTLCache[str](default_ttl=60) + cache.set("short", "a", ttl=1) + cache.set("long", "b", ttl=60) + assert cache.get("short") is None + assert cache.get("long") == "b" + + def test_clear_removes_all_entries(self) -> None: + cache = TTLCache[str]() + cache.set("a", "1") + cache.set("b", "2") + cache.clear() + assert cache.get("a") is None + assert cache.get("b") is None + + def test_invalidate_removes_single_entry(self) -> None: + cache = TTLCache[str]() + cache.set("a", "1") + cache.set("b", "2") + cache.invalidate("a") + assert cache.get("a") is None + assert cache.get("b") == "2" + + def test_set_with_zero_or_negative_ttl_does_not_store(self) -> None: + cache = TTLCache[str](default_ttl=60) + cache.set("zero", "a", ttl=0) + cache.set("negative", "b", ttl=-1) + assert cache.get("zero") is None + assert cache.get("negative") is None + + def test_thread_safe_concurrent_access(self) -> None: + cache = TTLCache[int](default_ttl=60) + errors: list[Exception] = [] + + def worker(i: int) -> None: + try: + cache.set(f"key-{i}", i) + assert cache.get(f"key-{i}") == i + except Exception as exc: + errors.append(exc) + + import threading + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(20)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert not errors diff --git a/tests/test_fastapi_instrumentation.py b/tests/test_fastapi_instrumentation.py index 18a6a711..4d947991 100644 --- a/tests/test_fastapi_instrumentation.py +++ b/tests/test_fastapi_instrumentation.py @@ -358,8 +358,8 @@ def test_get_default_span_details_with_route_and_method( span_name, attributes = get_default_span_details(scope) - assert span_name == "GET /test/path" - assert attributes["http.route"] == "/test/path" + assert span_name == "GET" + assert attributes == {} @patch("netra.instrumentation.fastapi.utils.get_route_details") @patch("netra.instrumentation.fastapi.utils.sanitize_method") @@ -386,8 +386,8 @@ def test_get_default_span_details_other_method( span_name, attributes = get_default_span_details(scope) - assert span_name == "HTTP /test/path" - assert attributes["http.route"] == "/test/path" + assert span_name == "HTTP" + assert attributes == {} class TestHeaderSanitization: @@ -759,7 +759,7 @@ async def mock_receive() -> dict: mock_extract.return_value = Mock() mock_ctx.attach.return_value = Mock() - asyncio.get_event_loop().run_until_complete(middleware(scope, mock_receive, mock_send)) + asyncio.run(middleware(scope, mock_receive, mock_send)) mock_extract.assert_called_once_with(carrier=scope, getter=_asgi_getter) @@ -804,7 +804,7 @@ def test_non_http_scope_skips_propagation(self) -> None: scope = {"type": "websocket", "headers": []} - asyncio.get_event_loop().run_until_complete(middleware(scope, AsyncMock(), AsyncMock())) + asyncio.run(middleware(scope, AsyncMock(), AsyncMock())) mock_app.assert_called_once() mock_tracer.start_as_current_span.assert_not_called() diff --git a/tests/test_google_genai_instrumentation.py b/tests/test_google_genai_instrumentation.py index 5921e954..ed2ed89b 100644 --- a/tests/test_google_genai_instrumentation.py +++ b/tests/test_google_genai_instrumentation.py @@ -1,53 +1,32 @@ """ -Unit tests for GoogleGenAiInstrumentor class. +Unit tests for NetraGoogleGenAiInstrumentor class. Minimal tests focusing on core functionality and happy path scenarios. """ from typing import Collection from unittest.mock import Mock, patch -from netra.instrumentation.google_genai import ( - GoogleGenAiInstrumentor, - is_async_streaming_response, - is_streaming_response, - should_send_prompts, -) +from netra.instrumentation.google_genai import NetraGoogleGenAiInstrumentor -class TestGoogleGenAiInstrumentor: - """Test GoogleGenAiInstrumentor core functionality.""" +class TestNetraGoogleGenAiInstrumentor: + """Test NetraGoogleGenAiInstrumentor core functionality.""" def test_initialization(self): - """Test GoogleGenAiInstrumentor initialization.""" - # Act - instrumentor = GoogleGenAiInstrumentor(exception_logger=None) + """Test NetraGoogleGenAiInstrumentor initialization.""" + instrumentor = NetraGoogleGenAiInstrumentor() - # Assert assert instrumentor is not None assert hasattr(instrumentor, "_instrument") assert hasattr(instrumentor, "_uninstrument") assert hasattr(instrumentor, "instrumentation_dependencies") - def test_initialization_with_exception_logger(self): - """Test GoogleGenAiInstrumentor initialization with custom exception logger.""" - # Arrange - mock_logger = Mock() - - # Act - instrumentor = GoogleGenAiInstrumentor(exception_logger=mock_logger) - - # Assert - assert instrumentor is not None - def test_instrumentation_dependencies(self): """Test instrumentation_dependencies returns correct packages.""" - # Arrange - instrumentor = GoogleGenAiInstrumentor(exception_logger=None) + instrumentor = NetraGoogleGenAiInstrumentor() - # Act dependencies = instrumentor.instrumentation_dependencies() - # Assert assert isinstance(dependencies, Collection) assert "google-genai >= 0.1.0" in dependencies @@ -55,119 +34,36 @@ def test_instrumentation_dependencies(self): @patch("netra.instrumentation.google_genai.wrap_function_wrapper") def test_instrument_with_default_parameters(self, mock_wrap_function, mock_get_tracer): """Test _instrument method with default parameters.""" - # Arrange - instrumentor = GoogleGenAiInstrumentor(exception_logger=None) + instrumentor = NetraGoogleGenAiInstrumentor() mock_tracer = Mock() mock_get_tracer.return_value = mock_tracer - # Act instrumentor._instrument() - # Assert mock_get_tracer.assert_called_once() - # Should wrap all methods defined in WRAPPED_METHODS (8 methods) assert mock_wrap_function.call_count == 8 @patch("netra.instrumentation.google_genai.get_tracer") @patch("netra.instrumentation.google_genai.wrap_function_wrapper") def test_instrument_with_custom_tracer_provider(self, mock_wrap_function, mock_get_tracer): """Test _instrument method with custom tracer provider.""" - # Arrange - instrumentor = GoogleGenAiInstrumentor(exception_logger=None) + instrumentor = NetraGoogleGenAiInstrumentor() mock_tracer_provider = Mock() mock_tracer = Mock() mock_get_tracer.return_value = mock_tracer - # Act instrumentor._instrument(tracer_provider=mock_tracer_provider) - # Assert mock_get_tracer.assert_called_once_with( - "netra.instrumentation.google_genai", mock_get_tracer.call_args[0][1], mock_tracer_provider # version + "netra.instrumentation.google_genai", mock_get_tracer.call_args[0][1], mock_tracer_provider ) assert mock_wrap_function.call_count == 8 @patch("netra.instrumentation.google_genai.unwrap") def test_uninstrument(self, mock_unwrap): """Test _uninstrument method unwraps all wrapped methods.""" - # Arrange - instrumentor = GoogleGenAiInstrumentor(exception_logger=None) + instrumentor = NetraGoogleGenAiInstrumentor() - # Act instrumentor._uninstrument() - # Assert - # Should unwrap all methods defined in WRAPPED_METHODS (8 methods) assert mock_unwrap.call_count == 8 - - -class TestUtilityFunctions: - """Test utility functions in the google_genai instrumentation module.""" - - @patch.dict("os.environ", {"TRACELOOP_TRACE_CONTENT": "true"}) - def test_should_send_prompts_true_from_env(self): - """Test should_send_prompts returns True when environment variable is set.""" - # Act - result = should_send_prompts() - - # Assert - assert result is True - - def test_should_send_prompts_default(self): - """Test should_send_prompts returns default value when no environment variable is set.""" - # Act - result = should_send_prompts() - - # Assert - assert result is True # Default behavior when no env var is set - - def test_is_streaming_response_with_generator(self): - """Test is_streaming_response returns True for generator objects.""" - - # Arrange - def sample_generator(): - yield 1 - yield 2 - - generator = sample_generator() - - # Act - result = is_streaming_response(generator) - - # Assert - assert result is True - - def test_is_streaming_response_with_non_generator(self): - """Test is_streaming_response returns False for non-generator objects.""" - # Act - result = is_streaming_response("not a generator") - - # Assert - assert result is False - - def test_is_async_streaming_response_with_async_generator(self): - """Test is_async_streaming_response returns True for async generator objects.""" - - # Arrange - async def sample_async_generator(): - yield 1 - yield 2 - - async_generator = sample_async_generator() - - # Act - result = is_async_streaming_response(async_generator) - - # Assert - assert result is True - - # Cleanup - async_generator.aclose() - - def test_is_async_streaming_response_with_non_async_generator(self): - """Test is_async_streaming_response returns False for non-async generator objects.""" - # Act - result = is_async_streaming_response("not an async generator") - - # Assert - assert result is False diff --git a/tests/test_input_scanner.py b/tests/test_input_scanner.py index 9716706e..5568917f 100644 --- a/tests/test_input_scanner.py +++ b/tests/test_input_scanner.py @@ -172,16 +172,18 @@ def test_get_scanner_with_invalid_threshold_type(self) -> None: def test_get_scanner_with_llm_guard_available(self) -> None: """Test _get_scanner when llm_guard is available.""" - with patch("netra.scanner.PromptInjection") as mock_prompt_injection: - mock_scanner = Mock(spec=Scanner) - mock_prompt_injection.return_value = mock_scanner + mock_match_module = Mock() + mock_match_module.MatchType = Mock(FULL="full") + with patch.dict("sys.modules", {"llm_guard.input_scanners.prompt_injection": mock_match_module}): + with patch("netra.scanner.PromptInjection") as mock_prompt_injection: + mock_scanner = Mock(spec=Scanner) + mock_prompt_injection.return_value = mock_scanner - result = InputScanner._get_scanner(ScannerType.PROMPT_INJECTION, match_type="custom") + result = InputScanner._get_scanner(ScannerType.PROMPT_INJECTION, match_type="custom") - assert result == mock_scanner - # Check that custom match_type was passed - call_args = mock_prompt_injection.call_args - assert call_args.kwargs["match_type"] == "custom" + assert result == mock_scanner + call_args = mock_prompt_injection.call_args + assert call_args.kwargs["match_type"] == "custom" def test_get_scanner_with_llm_guard_unavailable(self) -> None: """Test _get_scanner when llm_guard is not available.""" diff --git a/tests/test_litellm_instrumentation.py b/tests/test_litellm_instrumentation.py index 45b59813..5893830f 100644 --- a/tests/test_litellm_instrumentation.py +++ b/tests/test_litellm_instrumentation.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from opentelemetry.semconv_ai import SpanAttributes from netra.instrumentation.litellm import LiteLLMInstrumentor, should_suppress_instrumentation from netra.instrumentation.litellm.wrappers import ( @@ -38,40 +39,27 @@ def test_instrumentation_dependencies(self): assert isinstance(dependencies, Collection) assert "litellm >= 1.0.0" in dependencies + @patch("netra.instrumentation.litellm.wrap_function_wrapper") @patch("netra.instrumentation.litellm.get_tracer") @patch("netra.instrumentation.litellm.logger") - def test_instrument_with_default_parameters(self, mock_logger, mock_get_tracer): + def test_instrument_with_default_parameters(self, mock_logger, mock_get_tracer, mock_wrap): """Test _instrument method with default parameters.""" # Arrange instrumentor = LiteLLMInstrumentor() mock_tracer = Mock() mock_get_tracer.return_value = mock_tracer - # Mock litellm module - mock_litellm = Mock() - mock_litellm.completion = Mock() - mock_litellm.acompletion = AsyncMock() - mock_litellm.embedding = Mock() - mock_litellm.aembedding = AsyncMock() - mock_litellm.image_generation = Mock() - mock_litellm.aimage_generation = AsyncMock() - - with patch.dict("sys.modules", {"litellm": mock_litellm}): - # Act - instrumentor._instrument() + # Act + instrumentor._instrument() - # Assert - mock_get_tracer.assert_called_once() - # Verify original functions are stored - assert hasattr(instrumentor, "_original_completion") - assert hasattr(instrumentor, "_original_acompletion") - assert hasattr(instrumentor, "_original_embedding") - assert hasattr(instrumentor, "_original_aembedding") - assert hasattr(instrumentor, "_original_image_generation") - assert hasattr(instrumentor, "_original_aimage_generation") + # Assert + mock_get_tracer.assert_called_once() + # completion, acompletion, responses, aresponses, embedding, aembedding, image_generation, aimage_generation + assert mock_wrap.call_count == 8 + @patch("netra.instrumentation.litellm.wrap_function_wrapper") @patch("netra.instrumentation.litellm.get_tracer") - def test_instrument_with_custom_tracer_provider(self, mock_get_tracer): + def test_instrument_with_custom_tracer_provider(self, mock_get_tracer, mock_wrap): """Test _instrument method with custom tracer provider.""" # Arrange instrumentor = LiteLLMInstrumentor() @@ -79,85 +67,60 @@ def test_instrument_with_custom_tracer_provider(self, mock_get_tracer): mock_tracer = Mock() mock_get_tracer.return_value = mock_tracer - # Mock litellm module - mock_litellm = Mock() - mock_litellm.completion = Mock() - mock_litellm.acompletion = AsyncMock() - mock_litellm.embedding = Mock() - mock_litellm.aembedding = AsyncMock() - mock_litellm.image_generation = Mock() - mock_litellm.aimage_generation = AsyncMock() - - with patch.dict("sys.modules", {"litellm": mock_litellm}): - # Act - instrumentor._instrument(tracer_provider=mock_tracer_provider) + # Act + instrumentor._instrument(tracer_provider=mock_tracer_provider) - # Assert - mock_get_tracer.assert_called_once_with( - "netra.instrumentation.litellm", mock_get_tracer.call_args[0][1], mock_tracer_provider - ) + # Assert + mock_get_tracer.assert_called_once_with( + "netra.instrumentation.litellm", mock_get_tracer.call_args[0][1], mock_tracer_provider + ) + assert mock_wrap.call_count == 8 + @patch("netra.instrumentation.litellm.wrap_function_wrapper", side_effect=ImportError("No module named 'litellm'")) @patch("netra.instrumentation.litellm.logger") - def test_instrument_with_import_error(self, mock_logger): + def test_instrument_with_import_error(self, mock_logger, mock_wrap): """Test _instrument method handles import error gracefully.""" # Arrange instrumentor = LiteLLMInstrumentor() - with patch("netra.instrumentation.litellm.get_tracer"), patch.dict("sys.modules", {"litellm": None}): - with patch("builtins.__import__", side_effect=ImportError("No module named 'litellm'")): - # Act - instrumentor._instrument() + with patch("netra.instrumentation.litellm.get_tracer"): + # Act + instrumentor._instrument() - # Assert - mock_logger.error.assert_called_once() + # Assert + assert mock_logger.error.called - def test_uninstrument(self): - """Test _uninstrument method restores original functions.""" + @patch("netra.instrumentation.litellm.unwrap") + def test_uninstrument(self, mock_unwrap): + """Test _uninstrument method unwraps LiteLLM functions.""" # Arrange instrumentor = LiteLLMInstrumentor() - mock_litellm = Mock() - original_completion = Mock() - original_acompletion = AsyncMock() - original_embedding = Mock() - original_aembedding = AsyncMock() - original_image_generation = Mock() - original_aimage_generation = AsyncMock() - - # Set up original functions - instrumentor._original_completion = original_completion - instrumentor._original_acompletion = original_acompletion - instrumentor._original_embedding = original_embedding - instrumentor._original_aembedding = original_aembedding - instrumentor._original_image_generation = original_image_generation - instrumentor._original_aimage_generation = original_aimage_generation - - with patch.dict("sys.modules", {"litellm": mock_litellm}): - # Act - instrumentor._uninstrument() - # Assert - assert mock_litellm.completion == original_completion - assert mock_litellm.acompletion == original_acompletion - assert mock_litellm.embedding == original_embedding - assert mock_litellm.aembedding == original_aembedding - assert mock_litellm.image_generation == original_image_generation - assert mock_litellm.aimage_generation == original_aimage_generation - - def test_uninstrument_with_import_error(self): + # Act + instrumentor._uninstrument() + + # Assert — same eight methods that _instrument wraps + assert mock_unwrap.call_count == 8 + + @patch("netra.instrumentation.litellm.unwrap", side_effect=ModuleNotFoundError("litellm")) + @patch("netra.instrumentation.litellm.logger") + def test_uninstrument_with_import_error(self, mock_logger, mock_unwrap): """Test _uninstrument method handles import error gracefully.""" # Arrange instrumentor = LiteLLMInstrumentor() - with patch.dict("sys.modules", {"litellm": None}): - with patch("builtins.__import__", side_effect=ImportError("No module named 'litellm'")): - # Act & Assert - should not raise exception - instrumentor._uninstrument() + # Act + instrumentor._uninstrument() + + # Assert + assert mock_logger.error.called class TestWrappers: """Test wrapper functionality in the LiteLLM instrumentation module.""" - def test_completion_wrapper_non_streaming(self): + @patch("netra.instrumentation.litellm.wrappers.record_span_timing") + def test_completion_wrapper_non_streaming(self, mock_record_timing): """Test completion_wrapper for non-streaming requests.""" from netra.instrumentation.litellm.wrappers import completion_wrapper @@ -269,7 +232,8 @@ async def mock_wrapped(*args, **kwargs): # Verify wrapper creation doesn't call tracer methods yet mock_tracer.start_span.assert_not_called() - def test_embedding_wrapper(self): + @patch("netra.instrumentation.litellm.wrappers.record_span_timing") + def test_embedding_wrapper(self, mock_record_timing): """Test embedding_wrapper for embedding requests.""" from netra.instrumentation.litellm.wrappers import embedding_wrapper @@ -316,7 +280,8 @@ async def mock_wrapped(*args, **kwargs): assert callable(wrapper) mock_tracer.start_as_current_span.assert_not_called() # Should not be called until wrapper is invoked - def test_image_generation_wrapper(self): + @patch("netra.instrumentation.litellm.wrappers.record_span_timing") + def test_image_generation_wrapper(self, mock_record_timing): """Test image_generation_wrapper for image generation requests.""" from netra.instrumentation.litellm.wrappers import image_generation_wrapper @@ -414,7 +379,7 @@ def test_is_streaming_response_with_non_generator(self): assert is_streaming_response({"key": "value"}) is False assert is_streaming_response(b"bytes") is False - @patch("netra.instrumentation.litellm.context_api.get_value") + @patch("netra.instrumentation.litellm.utils.context_api.get_value") def test_should_suppress_instrumentation_true(self, mock_get_value): """Test should_suppress_instrumentation returns True when suppression is enabled.""" # Arrange @@ -426,7 +391,7 @@ def test_should_suppress_instrumentation_true(self, mock_get_value): # Assert assert result is True - @patch("netra.instrumentation.litellm.context_api.get_value") + @patch("netra.instrumentation.litellm.utils.context_api.get_value") def test_should_suppress_instrumentation_false(self, mock_get_value): """Test should_suppress_instrumentation returns False when suppression is disabled.""" # Arrange @@ -504,12 +469,13 @@ def test_set_request_attributes_chat(self): set_request_attributes(mock_span, kwargs, "chat") # Assert - mock_span.set_attribute.assert_any_call("llm.request.type", "chat") - mock_span.set_attribute.assert_any_call("gen_ai.system", "LiteLLM") - mock_span.set_attribute.assert_any_call("gen_ai.request.model", "gpt-4") - mock_span.set_attribute.assert_any_call("gen_ai.request.temperature", 0.7) - mock_span.set_attribute.assert_any_call("gen_ai.request.max_tokens", 100) - mock_span.set_attribute.assert_any_call("gen_ai.stream", False) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_TYPE, "chat") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_MODEL, "gpt-4") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_TEMPERATURE, 0.7) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_MAX_TOKENS, 100) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_IS_STREAMING, False) + mock_span.set_attribute.assert_any_call(f"{SpanAttributes.LLM_PROMPTS}.0.role", "user") + mock_span.set_attribute.assert_any_call(f"{SpanAttributes.LLM_PROMPTS}.0.content", "Hello") def test_set_request_attributes_embedding(self): """Test set_request_attributes for embedding.""" @@ -522,9 +488,8 @@ def test_set_request_attributes_embedding(self): set_request_attributes(mock_span, kwargs, "embedding") # Assert - mock_span.set_attribute.assert_any_call("llm.request.type", "embedding") - mock_span.set_attribute.assert_any_call("gen_ai.system", "LiteLLM") - mock_span.set_attribute.assert_any_call("gen_ai.request.model", "text-embedding-ada-002") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_TYPE, "embedding") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_MODEL, "text-embedding-ada-002") def test_set_request_attributes_image_generation(self): """Test set_request_attributes for image generation.""" @@ -537,11 +502,8 @@ def test_set_request_attributes_image_generation(self): set_request_attributes(mock_span, kwargs, "image_generation") # Assert - mock_span.set_attribute.assert_any_call("llm.request.type", "image_generation") - mock_span.set_attribute.assert_any_call("gen_ai.prompt", "A sunset") - mock_span.set_attribute.assert_any_call("gen_ai.request.n", 1) - mock_span.set_attribute.assert_any_call("gen_ai.request.size", "1024x1024") - mock_span.set_attribute.assert_any_call("gen_ai.request.quality", "hd") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_TYPE, "image_generation") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_REQUEST_MODEL, "dall-e-3") def test_set_response_attributes_chat(self): """Test set_response_attributes for chat completion.""" @@ -556,14 +518,15 @@ def test_set_response_attributes_chat(self): } # Act - set_response_attributes(mock_span, response_dict, "chat") + set_response_attributes(mock_span, response_dict) # Assert - mock_span.set_attribute.assert_any_call("gen_ai.response.model", "gpt-4") - mock_span.set_attribute.assert_any_call("gen_ai.response.id", "chatcmpl-123") - mock_span.set_attribute.assert_any_call("gen_ai.usage.prompt_tokens", 10) - mock_span.set_attribute.assert_any_call("gen_ai.usage.completion_tokens", 20) - mock_span.set_attribute.assert_any_call("llm.usage.total_tokens", 30) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_RESPONSE_MODEL, "gpt-4") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_USAGE_PROMPT_TOKENS, 10) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, 20) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_USAGE_TOTAL_TOKENS, 30) + mock_span.set_attribute.assert_any_call(f"{SpanAttributes.LLM_COMPLETIONS}.0.role", "assistant") + mock_span.set_attribute.assert_any_call(f"{SpanAttributes.LLM_COMPLETIONS}.0.content", "Hello!") def test_set_response_attributes_embedding(self): """Test set_response_attributes for embedding.""" @@ -577,12 +540,12 @@ def test_set_response_attributes_embedding(self): } # Act - set_response_attributes(mock_span, response_dict, "embedding") + set_response_attributes(mock_span, response_dict) # Assert - mock_span.set_attribute.assert_any_call("gen_ai.response.model", "text-embedding-ada-002") - mock_span.set_attribute.assert_any_call("gen_ai.response.embeddings.0.index", 0) - mock_span.set_attribute.assert_any_call("gen_ai.response.embeddings.0.dimensions", 3) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_RESPONSE_MODEL, "text-embedding-ada-002") + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_USAGE_PROMPT_TOKENS, 5) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_USAGE_TOTAL_TOKENS, 5) def test_set_response_attributes_image_generation(self): """Test set_response_attributes for image generation.""" @@ -590,17 +553,15 @@ def test_set_response_attributes_image_generation(self): mock_span = Mock() mock_span.is_recording.return_value = True response_dict = { - "data": [{"url": "https://example.com/image.png", "revised_prompt": "A beautiful sunset over mountains"}] + "model": "dall-e-3", + "data": [{"url": "https://example.com/image.png", "revised_prompt": "A beautiful sunset over mountains"}], } # Act - set_response_attributes(mock_span, response_dict, "image_generation") + set_response_attributes(mock_span, response_dict) # Assert - mock_span.set_attribute.assert_any_call("gen_ai.response.images.0.url", "https://example.com/image.png") - mock_span.set_attribute.assert_any_call( - "gen_ai.response.images.0.revised_prompt", "A beautiful sunset over mountains" - ) + mock_span.set_attribute.assert_any_call(SpanAttributes.LLM_RESPONSE_MODEL, "dall-e-3") def test_set_request_attributes_not_recording(self): """Test set_request_attributes when span is not recording.""" @@ -623,7 +584,7 @@ def test_set_response_attributes_not_recording(self): response_dict = {"model": "gpt-4"} # Act - set_response_attributes(mock_span, response_dict, "chat") + set_response_attributes(mock_span, response_dict) # Assert mock_span.set_attribute.assert_not_called() diff --git a/tests/test_models_cache.py b/tests/test_models_cache.py new file mode 100644 index 00000000..ecdaca92 --- /dev/null +++ b/tests/test_models_cache.py @@ -0,0 +1,171 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from netra import Netra +from netra.config import Config +from netra.models.api import MODEL_PRICING_CACHE_TTL_SECONDS, Models + + +@pytest.fixture +def models() -> Models: + cfg = Config() + client = MagicMock() + instance = Models(cfg) + instance._client = client + return instance + + +class TestModelsGetModelPricingCaching: + def test_use_cache_omitted_calls_http_every_time(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + models.get_model_pricing() + models.get_model_pricing() + + assert models._client.get_model_pricing.call_count == 2 + + def test_use_cache_true_second_call_skips_http(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + first = models.get_model_pricing(use_cache=True) + second = models.get_model_pricing(use_cache=True) + + assert models._client.get_model_pricing.call_count == 1 + assert first == [{"name": "gpt-4o"}] + assert second == [{"name": "gpt-4o"}] + + def test_use_cache_true_different_names_use_separate_entries(self, models: Models) -> None: + models._client.get_model_pricing.side_effect = [ + {"data": [{"name": "gpt-4o"}]}, + {"data": [{"name": "claude-3"}]}, + ] + + gpt = models.get_model_pricing("gpt-4o", use_cache=True) + claude = models.get_model_pricing("claude-3", use_cache=True) + + assert models._client.get_model_pricing.call_count == 2 + assert gpt == [{"name": "gpt-4o"}] + assert claude == [{"name": "claude-3"}] + + def test_name_none_uses_all_cache_key(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + models.get_model_pricing(use_cache=True) + models.get_model_pricing(name=None, use_cache=True) + + assert models._client.get_model_pricing.call_count == 1 + + def test_empty_list_is_cached(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": []} + + first = models.get_model_pricing(use_cache=True) + second = models.get_model_pricing(use_cache=True) + + assert models._client.get_model_pricing.call_count == 1 + assert first == [] + assert second == [] + + def test_api_failure_empty_dict_does_not_store_in_cache(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {} + + models.get_model_pricing(use_cache=True) + models.get_model_pricing(use_cache=True) + + assert models._client.get_model_pricing.call_count == 2 + + def test_api_none_response_does_not_store_in_cache(self, models: Models) -> None: + models._client.get_model_pricing.return_value = None + + models.get_model_pricing(use_cache=True) + models.get_model_pricing(use_cache=True) + + assert models._client.get_model_pricing.call_count == 2 + + def test_non_list_data_does_not_store_in_cache(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": {"name": "bad"}} + + models.get_model_pricing(use_cache=True) + models.get_model_pricing(use_cache=True) + + assert models._client.get_model_pricing.call_count == 2 + + def test_per_call_cache_ttl_expires_before_default(self, models: Models) -> None: + with patch("netra.cache.time.monotonic", side_effect=[0.0, 0.0, 1.1, 1.1]): + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + models.get_model_pricing(use_cache=True, cache_ttl=1) + assert models._client.get_model_pricing.call_count == 1 + + models.get_model_pricing(use_cache=True, cache_ttl=1) + assert models._client.get_model_pricing.call_count == 1 + + models.get_model_pricing(use_cache=True, cache_ttl=1) + assert models._client.get_model_pricing.call_count == 2 + + def test_zero_cache_ttl_skips_cache_write(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + models.get_model_pricing(use_cache=True, cache_ttl=0) + models.get_model_pricing(use_cache=True, cache_ttl=0) + + assert models._client.get_model_pricing.call_count == 2 + + def test_use_cache_false_with_cache_ttl_ignores_cache(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + models.get_model_pricing(use_cache=False, cache_ttl=30) + models.get_model_pricing(use_cache=False, cache_ttl=30) + + assert models._client.get_model_pricing.call_count == 2 + + def test_clear_cache_forces_next_call_to_hit_http(self, models: Models) -> None: + models._client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + + models.get_model_pricing(use_cache=True) + models.clear_cache() + models.get_model_pricing(use_cache=True) + + assert models._client.get_model_pricing.call_count == 2 + + def test_default_ttl_is_models_owned_constant(self, models: Models) -> None: + assert MODEL_PRICING_CACHE_TTL_SECONDS == 300 + assert models._cache._default_ttl == MODEL_PRICING_CACHE_TTL_SECONDS + assert models._cache._default_ttl != 60 + + +class TestModelsCacheShutdown: + def setup_method(self) -> None: + with Netra._init_lock: + Netra._initialized = False + + def teardown_method(self) -> None: + with Netra._init_lock: + Netra._initialized = False + + @patch("netra.init_instrumentations") + @patch("netra.Tracer") + @patch("netra.Config") + def test_shutdown_clears_models_cache( + self, + mock_config: MagicMock, + mock_tracer: MagicMock, + mock_init_instrumentations: MagicMock, + ) -> None: + mock_cfg = MagicMock() + mock_config.return_value = mock_cfg + + Netra.init() + + mock_client = MagicMock() + mock_client.get_model_pricing.return_value = {"data": [{"name": "gpt-4o"}]} + Netra.models._client = mock_client + + Netra.models.get_model_pricing(use_cache=True) + Netra.models.get_model_pricing(use_cache=True) + assert mock_client.get_model_pricing.call_count == 1 + + Netra.shutdown() + + Netra.models.get_model_pricing(use_cache=True) + assert mock_client.get_model_pricing.call_count == 2 diff --git a/tests/test_netra_init.py b/tests/test_netra_init.py index daf2fc4d..e223777b 100644 --- a/tests/test_netra_init.py +++ b/tests/test_netra_init.py @@ -12,6 +12,7 @@ from netra import Netra from netra.config import Config +from netra.instrumentation.instruments import DEFAULT_INSTRUMENTS class TestNetraInitialization: @@ -54,12 +55,15 @@ def test_init_with_default_parameters( headers=None, disable_batch=None, trace_content=None, + debug_mode=None, + enable_root_span=None, resource_attributes=None, environment=None, - enable_root_span=None, enable_scrubbing=None, - debug_mode=None, blocked_spans=None, + enable_metrics=None, + metrics_export_interval_ms=None, + export_auto_metrics=None, ) # Verify Tracer was initialized @@ -69,7 +73,7 @@ def test_init_with_default_parameters( mock_init_instrumentations.assert_called_once_with( should_enrich_metrics=True, base64_image_uploader=None, - instruments=None, + instruments=DEFAULT_INSTRUMENTS, block_instruments=None, ) @@ -87,12 +91,15 @@ def test_init_with_custom_parameters( "headers": "key1=value1,key2=value2", "disable_batch": True, "trace_content": False, + "debug_mode": True, + "enable_root_span": False, "resource_attributes": {"env": "test", "version": "1.0.0"}, "environment": "testing", - "enable_root_span": False, "enable_scrubbing": None, - "debug_mode": True, "blocked_spans": None, + "enable_metrics": None, + "metrics_export_interval_ms": None, + "export_auto_metrics": None, } app_name = "test-app" diff --git a/tests/test_openai_instrumentation.py b/tests/test_openai_instrumentation.py index 158f0835..9d38e5df 100644 --- a/tests/test_openai_instrumentation.py +++ b/tests/test_openai_instrumentation.py @@ -6,11 +6,8 @@ from typing import Collection from unittest.mock import MagicMock, Mock, patch -from netra.instrumentation.openai import ( - NetraOpenAIInstrumentor, - is_streaming_response, - should_suppress_instrumentation, -) +from netra.instrumentation.openai import NetraOpenAIInstrumentor +from netra.instrumentation.openai.utils import should_suppress_instrumentation class TestNetraOpenAIInstrumentor: @@ -18,10 +15,8 @@ class TestNetraOpenAIInstrumentor: def test_initialization(self): """Test NetraOpenAIInstrumentor initialization.""" - # Act instrumentor = NetraOpenAIInstrumentor() - # Assert assert instrumentor is not None assert hasattr(instrumentor, "_instrument") assert hasattr(instrumentor, "_uninstrument") @@ -29,13 +24,10 @@ def test_initialization(self): def test_instrumentation_dependencies(self): """Test instrumentation_dependencies returns correct packages.""" - # Arrange instrumentor = NetraOpenAIInstrumentor() - # Act dependencies = instrumentor.instrumentation_dependencies() - # Assert assert isinstance(dependencies, Collection) assert "openai >= 1.0.0" in dependencies @@ -43,63 +35,54 @@ def test_instrumentation_dependencies(self): @patch("netra.instrumentation.openai.wrap_function_wrapper") def test_instrument_with_default_parameters(self, mock_wrap_function, mock_get_tracer): """Test _instrument method with default parameters.""" - # Arrange instrumentor = NetraOpenAIInstrumentor() mock_tracer = Mock() mock_get_tracer.return_value = mock_tracer - # Act instrumentor._instrument() - # Assert mock_get_tracer.assert_called_once() - # Should wrap all methods (chat, completion, embeddings, responses) - assert mock_wrap_function.call_count >= 6 # At least 6 methods are wrapped + # chat x2, embeddings x2, responses x2 + assert mock_wrap_function.call_count == 6 @patch("netra.instrumentation.openai.get_tracer") @patch("netra.instrumentation.openai.wrap_function_wrapper") def test_instrument_with_custom_tracer_provider(self, mock_wrap_function, mock_get_tracer): """Test _instrument method with custom tracer provider.""" - # Arrange instrumentor = NetraOpenAIInstrumentor() mock_tracer_provider = Mock() mock_tracer = Mock() mock_get_tracer.return_value = mock_tracer - # Act instrumentor._instrument(tracer_provider=mock_tracer_provider) - # Assert mock_get_tracer.assert_called_once_with( - "netra.instrumentation.openai", mock_get_tracer.call_args[0][1], mock_tracer_provider # version + "netra.instrumentation.openai", mock_get_tracer.call_args[0][1], mock_tracer_provider ) - assert mock_wrap_function.call_count >= 6 + assert mock_wrap_function.call_count == 6 @patch("netra.instrumentation.openai.unwrap") def test_uninstrument(self, mock_unwrap): - """Test _uninstrument method unwraps all wrapped methods.""" - # Arrange + """Test _uninstrument method unwraps all OpenAI methods it targets.""" instrumentor = NetraOpenAIInstrumentor() - # Act instrumentor._uninstrument() - # Assert - # Should unwrap all methods (chat, completion, embeddings, responses) - assert mock_unwrap.call_count >= 6 + # chat x2, completions x2, embeddings x2, responses x2 + assert mock_unwrap.call_count == 8 class TestWrappers: """Test wrapper functionality in the OpenAI instrumentation module.""" - def test_chat_wrapper_non_streaming(self): - """Test chat_wrapper for non-streaming requests.""" + @patch("netra.instrumentation.openai.wrappers.record_span_timing") + def test_chat_wrapper_non_streaming(self, mock_record_timing): + """Test chat_wrapper for non-streaming requests starts a span and returns the wrapped result.""" from netra.instrumentation.openai.wrappers import chat_wrapper - # Arrange mock_tracer = Mock() mock_span_context = MagicMock() - mock_span_context.__enter__.return_value + mock_span_context.__enter__.return_value = Mock() mock_tracer.start_as_current_span.return_value = mock_span_context wrapped = Mock(return_value={"id": "test-id", "choices": [{"message": {"content": "test"}}]}) @@ -109,20 +92,17 @@ def test_chat_wrapper_non_streaming(self): wrapper = chat_wrapper(mock_tracer) - # Act result = wrapper(wrapped, instance, args, kwargs) - # Assert wrapped.assert_called_once_with(*args, **kwargs) mock_tracer.start_as_current_span.assert_called_once() assert result == wrapped.return_value @patch("netra.instrumentation.openai.wrappers.StreamingWrapper") def test_chat_wrapper_streaming(self, mock_streaming_wrapper_class): - """Test chat_wrapper for streaming requests.""" + """Test chat_wrapper for streaming requests wraps the response in StreamingWrapper.""" from netra.instrumentation.openai.wrappers import chat_wrapper - # Arrange mock_tracer = Mock() mock_span = Mock() mock_tracer.start_span.return_value = mock_span @@ -136,16 +116,13 @@ def generator(): args = () kwargs = {"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}], "stream": True} - # Mock the StreamingWrapper to return a simple object mock_wrapper_instance = Mock() mock_streaming_wrapper_class.return_value = mock_wrapper_instance wrapper = chat_wrapper(mock_tracer) - # Act result = wrapper(wrapped, instance, args, kwargs) - # Assert wrapped.assert_called_once_with(*args, **kwargs) mock_tracer.start_span.assert_called_once() mock_streaming_wrapper_class.assert_called_once() @@ -155,50 +132,20 @@ def generator(): class TestUtilityFunctions: """Test utility functions in the openai instrumentation module.""" - def test_is_streaming_response_with_generator(self): - """Test is_streaming_response returns True for generator objects.""" - - # Arrange - def sample_generator(): - yield 1 - yield 2 - - generator = sample_generator() - - # Act - result = is_streaming_response(generator) - - # Assert - assert result is True - - def test_is_streaming_response_with_non_generator(self): - """Test is_streaming_response returns False for non-generator objects.""" - # Act - result = is_streaming_response("not a generator") - - # Assert - assert result is False - - @patch("netra.instrumentation.openai.context_api.get_value") + @patch("netra.instrumentation.openai.utils.context_api.get_value") def test_should_suppress_instrumentation_true(self, mock_get_value): """Test should_suppress_instrumentation returns True when suppression is enabled.""" - # Arrange mock_get_value.return_value = True - # Act result = should_suppress_instrumentation() - # Assert assert result is True - @patch("netra.instrumentation.openai.context_api.get_value") + @patch("netra.instrumentation.openai.utils.context_api.get_value") def test_should_suppress_instrumentation_false(self, mock_get_value): """Test should_suppress_instrumentation returns False when suppression is disabled.""" - # Arrange mock_get_value.return_value = False - # Act result = should_suppress_instrumentation() - # Assert assert result is False diff --git a/tests/test_prompts_cache.py b/tests/test_prompts_cache.py new file mode 100644 index 00000000..f06de871 --- /dev/null +++ b/tests/test_prompts_cache.py @@ -0,0 +1,145 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from netra import Netra +from netra.config import Config +from netra.prompts.api import PROMPT_CACHE_TTL_SECONDS, Prompts + + +@pytest.fixture +def prompts() -> Prompts: + cfg = Config() + client = MagicMock() + instance = Prompts(cfg) + instance._client = client + return instance + + +class TestPromptsGetPromptCaching: + def test_use_cache_omitted_calls_http_every_time(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = {"template": "v1"} + + prompts.get_prompt("my-prompt") + prompts.get_prompt("my-prompt") + + assert prompts._client.get_prompt_version.call_count == 2 + + def test_use_cache_true_second_call_skips_http(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = {"template": "v1"} + + first = prompts.get_prompt("my-prompt", use_cache=True) + second = prompts.get_prompt("my-prompt", use_cache=True) + + assert prompts._client.get_prompt_version.call_count == 1 + assert first == {"template": "v1"} + assert second == {"template": "v1"} + + def test_use_cache_true_different_labels_use_separate_entries(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.side_effect = [ + {"template": "prod"}, + {"template": "staging"}, + ] + + prod = prompts.get_prompt("my-prompt", label="production", use_cache=True) + staging = prompts.get_prompt("my-prompt", label="staging", use_cache=True) + + assert prompts._client.get_prompt_version.call_count == 2 + assert prod == {"template": "prod"} + assert staging == {"template": "staging"} + + def test_api_failure_does_not_store_in_cache(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = {} + + prompts.get_prompt("my-prompt", use_cache=True) + prompts.get_prompt("my-prompt", use_cache=True) + + assert prompts._client.get_prompt_version.call_count == 2 + + def test_api_none_response_does_not_store_in_cache(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = None + + prompts.get_prompt("my-prompt", use_cache=True) + prompts.get_prompt("my-prompt", use_cache=True) + + assert prompts._client.get_prompt_version.call_count == 2 + + def test_per_call_cache_ttl_expires_before_default(self, prompts: Prompts) -> None: + with patch("netra.cache.time.monotonic", side_effect=[0.0, 0.0, 1.1, 1.1]): + prompts._client.get_prompt_version.return_value = {"template": "v1"} + + prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=1) + assert prompts._client.get_prompt_version.call_count == 1 + + prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=1) + assert prompts._client.get_prompt_version.call_count == 1 + + prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=1) + assert prompts._client.get_prompt_version.call_count == 2 + + def test_zero_cache_ttl_skips_cache_write(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = {"template": "v1"} + + prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=0) + prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=0) + + assert prompts._client.get_prompt_version.call_count == 2 + + def test_use_cache_false_with_cache_ttl_ignores_cache(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = {"template": "v1"} + + prompts.get_prompt("my-prompt", use_cache=False, cache_ttl=30) + prompts.get_prompt("my-prompt", use_cache=False, cache_ttl=30) + + assert prompts._client.get_prompt_version.call_count == 2 + + def test_clear_cache_forces_next_call_to_hit_http(self, prompts: Prompts) -> None: + prompts._client.get_prompt_version.return_value = {"template": "v1"} + + prompts.get_prompt("my-prompt", use_cache=True) + prompts.clear_cache() + prompts.get_prompt("my-prompt", use_cache=True) + + assert prompts._client.get_prompt_version.call_count == 2 + + def test_default_ttl_is_prompts_owned_constant(self, prompts: Prompts) -> None: + assert PROMPT_CACHE_TTL_SECONDS == 60 + assert prompts._cache._default_ttl == PROMPT_CACHE_TTL_SECONDS + assert prompts._cache._default_ttl != 300 + + +class TestPromptsCacheShutdown: + def setup_method(self) -> None: + with Netra._init_lock: + Netra._initialized = False + + def teardown_method(self) -> None: + with Netra._init_lock: + Netra._initialized = False + + @patch("netra.init_instrumentations") + @patch("netra.Tracer") + @patch("netra.Config") + def test_shutdown_clears_prompt_cache( + self, + mock_config: MagicMock, + mock_tracer: MagicMock, + mock_init_instrumentations: MagicMock, + ) -> None: + mock_cfg = MagicMock() + mock_config.return_value = mock_cfg + + Netra.init() + + mock_client = MagicMock() + mock_client.get_prompt_version.return_value = {"template": "v1"} + Netra.prompts._client = mock_client + + Netra.prompts.get_prompt("my-prompt", use_cache=True) + Netra.prompts.get_prompt("my-prompt", use_cache=True) + assert mock_client.get_prompt_version.call_count == 1 + + Netra.shutdown() + + Netra.prompts.get_prompt("my-prompt", use_cache=True) + assert mock_client.get_prompt_version.call_count == 2 diff --git a/tests/test_span_wrapper.py b/tests/test_span_wrapper.py index 7f8c896c..351e494d 100644 --- a/tests/test_span_wrapper.py +++ b/tests/test_span_wrapper.py @@ -122,7 +122,7 @@ def test_span_wrapper_initialization_with_defaults(self, mock_get_tracer): span_wrapper = SpanWrapper("test_span") assert span_wrapper.name == "test_span" - assert span_wrapper.attributes == {} + assert span_wrapper.attributes == {"netra.span.type": "SPAN"} assert span_wrapper.start_time is None assert span_wrapper.end_time is None assert span_wrapper.status == "pending" @@ -160,7 +160,7 @@ def test_span_wrapper_initialization_with_none_attributes(self, mock_get_tracer) span_wrapper = SpanWrapper("test_span", attributes=None) - assert span_wrapper.attributes == {} + assert span_wrapper.attributes == {"netra.span.type": "SPAN"} class TestSpanWrapperAttributeSetters: @@ -407,7 +407,7 @@ def test_enter_method(self, mock_logger, mock_time): args, kwargs = self.mock_tracer.start_as_current_span.call_args assert kwargs["name"] == "test_span" assert kwargs["kind"] == SpanKind.CLIENT - assert kwargs["attributes"] == {"initial_key": "initial_value"} + assert kwargs["attributes"] == {"initial_key": "initial_value", "netra.span.type": "SPAN"} assert self.span_wrapper.span is self.mock_span # Verify return value diff --git a/tests/test_tracer.py b/tests/test_tracer.py index e3daee86..489dc562 100644 --- a/tests/test_tracer.py +++ b/tests/test_tracer.py @@ -12,6 +12,8 @@ class TestTracerInitialization: """Test tracer initialization and setup.""" + @patch("netra.tracer.FilteringSpanExporter", side_effect=lambda exporter, patterns: exporter) + @patch("netra.tracer.TrialAwareOTLPExporter", side_effect=lambda exporter: exporter) @patch("netra.tracer.trace") @patch("netra.tracer.TracerProvider") @patch("netra.tracer.Resource") @@ -26,6 +28,8 @@ def test_tracer_initialization_with_otlp_endpoint( mock_resource, mock_tracer_provider, mock_trace, + mock_trial_exporter, + mock_filtering_exporter, ): """Test tracer initialization with OTLP endpoint.""" # Arrange @@ -43,6 +47,7 @@ def test_tracer_initialization_with_otlp_endpoint( mock_resource.return_value = mock_resource_instance mock_provider = Mock() + mock_provider._netra_processors_installed = False mock_tracer_provider.return_value = mock_provider mock_exporter = Mock() @@ -54,6 +59,8 @@ def test_tracer_initialization_with_otlp_endpoint( mock_session_proc = Mock() mock_session_processor.return_value = mock_session_proc + mock_trace.get_tracer_provider.return_value = Mock() + # Act Tracer(mock_config) @@ -80,6 +87,7 @@ def test_tracer_initialization_with_otlp_endpoint( # Verify global tracer provider is set mock_trace.set_tracer_provider.assert_called_once_with(mock_provider) + @patch("netra.tracer.FilteringSpanExporter", side_effect=lambda exporter, patterns: exporter) @patch("netra.tracer.trace") @patch("netra.tracer.TracerProvider") @patch("netra.tracer.Resource") @@ -94,6 +102,7 @@ def test_tracer_initialization_with_console_fallback( mock_resource, mock_tracer_provider, mock_trace, + mock_filtering_exporter, ): """Test tracer initialization with console exporter fallback.""" # Arrange @@ -111,6 +120,7 @@ def test_tracer_initialization_with_console_fallback( mock_resource.return_value = mock_resource_instance mock_provider = Mock() + mock_provider._netra_processors_installed = False mock_tracer_provider.return_value = mock_provider mock_exporter = Mock() @@ -119,6 +129,8 @@ def test_tracer_initialization_with_console_fallback( mock_simple_proc = Mock() mock_simple_processor.return_value = mock_simple_proc + mock_trace.get_tracer_provider.return_value = Mock() + # Act Tracer(mock_config) @@ -165,8 +177,11 @@ def test_tracer_initialization_with_minimal_config( mock_resource.return_value = mock_resource_instance mock_provider = Mock() + mock_provider._netra_processors_installed = False mock_tracer_provider.return_value = mock_provider + mock_trace.get_tracer_provider.return_value = Mock() + # Act Tracer(mock_config) @@ -318,6 +333,8 @@ def test_tracer_with_custom_resource_attributes( } mock_resource.assert_called_once_with(attributes=expected_attrs) + @patch("netra.tracer.FilteringSpanExporter", side_effect=lambda exporter, patterns: exporter) + @patch("netra.tracer.TrialAwareOTLPExporter", side_effect=lambda exporter: exporter) @patch("netra.tracer.trace") @patch("netra.tracer.TracerProvider") @patch("netra.tracer.Resource") @@ -332,6 +349,8 @@ def test_tracer_with_batch_disabled( mock_resource, mock_tracer_provider, mock_trace, + mock_trial_exporter, + mock_filtering_exporter, ): """Test tracer initialization with batch processing disabled.""" # Arrange @@ -346,6 +365,7 @@ def test_tracer_with_batch_disabled( mock_config.blocked_spans = [] mock_provider = Mock() + mock_provider._netra_processors_installed = False mock_tracer_provider.return_value = mock_provider mock_exporter = Mock() @@ -354,6 +374,8 @@ def test_tracer_with_batch_disabled( mock_simple_proc = Mock() mock_simple_processor.return_value = mock_simple_proc + mock_trace.get_tracer_provider.return_value = Mock() + # Act Tracer(mock_config)