From 1cc9c06349ba908d366e70bf745192a1cde8dbaa Mon Sep 17 00:00:00 2001 From: Akash Vijay Date: Tue, 3 Nov 2026 13:46:50 +0530 Subject: [PATCH 1/2] [1990] fix: Refactor logging to use custom logger (#434) --- netra/__init__.py | 35 +- netra/config.py | 42 +- netra/dashboard/api.py | 22 +- netra/dashboard/client.py | 40 +- netra/decorators.py | 9 +- netra/evaluation/api.py | 40 +- netra/evaluation/client.py | 87 +++- netra/evaluation/utils.py | 4 +- netra/exporters/filtering_span_exporter.py | 4 +- netra/exporters/trial_aware_otlp_exporter.py | 86 +++- netra/exporters/utils.py | 8 +- netra/instrumentation/__init__.py | 4 +- netra/instrumentation/capture/stream_utils.py | 5 +- netra/instrumentation/http/hooks.py | 4 +- netra/instrumentation/http/propagation.py | 3 +- .../libraries/agno/__init__.py | 4 +- netra/instrumentation/libraries/agno/utils.py | 4 +- .../libraries/agno/wrappers.py | 4 +- .../libraries/aiohttp/__init__.py | 6 +- .../libraries/cartesia/__init__.py | 4 +- .../libraries/cartesia/utils.py | 5 +- .../libraries/cartesia/wrappers.py | 4 +- .../libraries/cerebras/__init__.py | 4 +- .../libraries/cerebras/utils.py | 5 +- .../libraries/cerebras/wrappers.py | 4 +- .../libraries/claude_agent_sdk/__init__.py | 4 +- .../libraries/claude_agent_sdk/utils.py | 4 +- .../libraries/claude_agent_sdk/wrappers.py | 4 +- .../libraries/codex/__init__.py | 4 +- .../instrumentation/libraries/codex/utils.py | 4 +- .../libraries/codex/wrappers.py | 4 +- .../libraries/cohere/__init__.py | 5 +- .../libraries/deepgram/__init__.py | 4 +- .../libraries/deepgram/utils.py | 5 +- .../libraries/deepgram/wrappers.py | 4 +- .../libraries/dspy/__init__.py | 4 +- .../libraries/dspy/wrappers.py | 4 +- .../libraries/elevenlabs/__init__.py | 4 +- .../libraries/elevenlabs/utils.py | 5 +- .../libraries/elevenlabs/wrappers.py | 4 +- .../libraries/fastapi/__init__.py | 4 +- .../libraries/fastapi/middleware.py | 4 +- .../libraries/fastapi/utils.py | 4 +- .../libraries/google_adk/__init__.py | 4 +- .../libraries/google_adk/utils.py | 5 +- .../libraries/google_adk/wrappers.py | 4 +- .../libraries/google_genai/__init__.py | 4 +- .../libraries/google_genai/wrappers.py | 4 +- .../libraries/groq/__init__.py | 3 +- netra/instrumentation/libraries/groq/utils.py | 5 +- .../libraries/groq/wrappers.py | 4 +- .../libraries/hermes_agent/__init__.py | 4 +- .../libraries/hermes_agent/utils.py | 4 +- .../libraries/hermes_agent/wrappers.py | 4 +- .../libraries/honcho/__init__.py | 4 +- .../instrumentation/libraries/honcho/utils.py | 4 +- .../libraries/honcho/wrappers.py | 4 +- .../libraries/httpx/__init__.py | 4 +- .../instrumentation/libraries/httpx/utils.py | 4 +- .../libraries/httpx/wrappers.py | 4 +- .../libraries/litellm/__init__.py | 4 +- .../libraries/litellm/utils.py | 5 +- .../libraries/litellm/wrappers.py | 4 +- .../libraries/livekit/__init__.py | 4 +- .../libraries/livekit/audio_capture.py | 4 +- .../libraries/livekit/audio_processor.py | 4 +- .../libraries/livekit/audio_sender.py | 4 +- .../libraries/livekit/call_span.py | 4 +- .../libraries/livekit/provider_binding.py | 5 +- .../libraries/livekit/trace_processor.py | 4 +- .../libraries/livekit/wrappers.py | 4 +- .../libraries/mistralai/__init__.py | 4 +- .../libraries/mistralai/utils.py | 4 +- .../libraries/openai/__init__.py | 4 +- .../instrumentation/libraries/openai/utils.py | 4 +- .../libraries/openai/wrappers.py | 4 +- .../libraries/pydantic_ai/__init__.py | 4 +- .../libraries/pydantic_ai/wrappers.py | 4 +- .../libraries/pydantic_ai_slim/__init__.py | 4 +- .../libraries/pydantic_ai_slim/wrappers.py | 4 +- .../libraries/requests/__init__.py | 4 +- .../libraries/requests/utils.py | 4 +- .../libraries/requests/wrappers.py | 4 +- .../libraries/subprocess/__init__.py | 4 +- .../libraries/subprocess/utils.py | 4 +- .../libraries/weaviate/__init__.py | 4 +- netra/instrumentation/span_utils.py | 6 +- netra/instrumentation/wiring/activation.py | 4 +- netra/instrumentation/wiring/deferral.py | 4 +- netra/instrumentation/wiring/registry.py | 4 +- netra/instrumentation/wiring/selection.py | 4 +- netra/logging_utils.py | 7 +- netra/meter.py | 9 +- netra/models/api.py | 4 +- netra/models/client.py | 16 +- netra/netra_logger.py | 332 +++++++++++++ .../instrumentation_span_processor.py | 4 +- .../llm_trace_identifier_span_processor.py | 4 +- .../local_filtering_span_processor.py | 5 +- .../root_instrument_filter_processor.py | 4 +- netra/processors/root_span_processor.py | 5 +- netra/processors/scrubbing_span_processor.py | 5 +- netra/processors/session_span_processor.py | 4 +- netra/processors/span_io_processor.py | 5 +- netra/prompts/api.py | 6 +- netra/prompts/client.py | 14 +- netra/session_manager.py | 17 +- netra/simulation/api.py | 33 +- netra/simulation/client.py | 21 +- netra/simulation/hooks.py | 11 +- netra/simulation/utils.py | 10 +- netra/span_wrapper.py | 8 +- netra/tracer.py | 12 +- netra/usage/api.py | 26 +- netra/usage/client.py | 35 +- netra/utils.py | 11 +- tests/test_netra_init.py | 4 +- tests/test_netra_logger.py | 459 ++++++++++++++++++ 118 files changed, 1391 insertions(+), 389 deletions(-) create mode 100644 netra/netra_logger.py create mode 100644 tests/test_netra_logger.py diff --git a/netra/__init__.py b/netra/__init__.py index 04933874..63499766 100644 --- a/netra/__init__.py +++ b/netra/__init__.py @@ -1,5 +1,4 @@ import atexit -import logging import threading from typing import AbstractSet, Any, Dict, List, Optional @@ -20,6 +19,7 @@ from netra.meter import MetricsSetup from netra.meter import get_meter as _get_meter from netra.models import Models +from netra.netra_logger import NoticeLevel, get_logger from netra.prompts import Prompts from netra.session_manager import ConversationType, SessionManager from netra.simulation import Simulation @@ -28,7 +28,7 @@ from netra.usage import Usage from netra.utils import resolve_root_instruments -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class Netra: @@ -117,7 +117,7 @@ def init( """ with cls._init_lock: if cls._initialized: - logger.warning("Netra.init() called more than once; ignoring subsequent calls.") + logger.notice("Netra.init() called more than once; ignoring subsequent calls.") return # Build Config @@ -350,7 +350,7 @@ def get_meter(cls, name: str = "netra", version: Optional[str] = None) -> otel_m queue_depth.add(5) """ if not cls._initialized: - logger.warning("Netra.get_meter() called before Netra.init(); returning no-op meter.") + logger.notice("Netra.get_meter() called before Netra.init(); returning no-op meter.") return _get_meter(name, version) @classmethod @@ -362,12 +362,14 @@ def set_session_id(cls, session_id: str) -> None: session_id: Session identifier """ if not isinstance(session_id, str): - logger.error(f"set_session_id: session_id must be a string, got {type(session_id)}") + logger.notice( + f"netra: set_session_id: session_id must be a string, got {type(session_id)}", level=NoticeLevel.ERROR + ) return if session_id: SessionManager.set_session_context("session_id", session_id) else: - logger.warning("set_session_id: Session ID must be provided for setting session_id.") + logger.notice("netra: set_session_id: Session ID must be provided for setting session_id.") @classmethod def set_user_id(cls, user_id: str) -> None: @@ -378,12 +380,12 @@ def set_user_id(cls, user_id: str) -> None: user_id: User identifier """ if not isinstance(user_id, str): - logger.error(f"set_user_id: user_id must be a string, got {type(user_id)}") + logger.notice(f"netra: set_user_id: user_id must be a string, got {type(user_id)}", level=NoticeLevel.ERROR) return if user_id: SessionManager.set_session_context("user_id", user_id) else: - logger.warning("set_user_id: User ID must be provided for setting user_id.") + logger.notice("netra: set_user_id: User ID must be provided for setting user_id.") @classmethod def set_tenant_id(cls, tenant_id: str) -> None: @@ -394,12 +396,14 @@ def set_tenant_id(cls, tenant_id: str) -> None: tenant_id: Tenant identifier """ if not isinstance(tenant_id, str): - logger.error(f"set_tenant_id: tenant_id must be a string, got {type(tenant_id)}") + logger.notice( + f"netra: set_tenant_id: tenant_id must be a string, got {type(tenant_id)}", level=NoticeLevel.ERROR + ) return if tenant_id: SessionManager.set_session_context("tenant_id", tenant_id) else: - logger.warning("set_tenant_id: Tenant ID must be provided for setting tenant_id.") + logger.notice("netra: set_tenant_id: Tenant ID must be provided for setting tenant_id.") @classmethod def update_span_name(cls, name: str) -> None: @@ -417,7 +421,7 @@ def update_span_name(cls, name: str) -> None: name: The new span name """ if not isinstance(name, str) or not name: - logger.warning("update_span_name: name must be a non-empty string; ignoring") + logger.notice("netra: update_span_name: name must be a non-empty string; ignoring") return span = trace.get_current_span() if not span.get_span_context().is_valid: @@ -437,7 +441,7 @@ def set_custom_attributes(cls, key: str, value: Any) -> None: if key and value: SessionManager.set_attribute_on_active_span(f"{Config.LIBRARY_NAME}.custom.{key}", value) else: - logger.warning("Both key and value must be provided for custom attributes.") + logger.notice("netra: Both key and value must be provided for custom attributes.") return @classmethod @@ -452,7 +456,7 @@ def set_custom_event(cls, event_name: str, attributes: Any) -> None: if event_name and attributes: SessionManager.set_custom_event(event_name, attributes) else: - logger.warning("Both event_name and attributes must be provided for custom events.") + logger.notice("netra: Both event_name and attributes must be provided for custom events.") @classmethod def record_exception( @@ -489,9 +493,10 @@ def process_order(order_id: str) -> str: return result """ if not isinstance(exception, BaseException): - logger.error( - "record_exception: exception must be a BaseException instance, got %s", + logger.notice( + "netra: record_exception: exception must be a BaseException instance, got %s", type(exception), + level=NoticeLevel.ERROR, ) return SessionManager.record_exception(exception, attributes=attributes, escaped=escaped) diff --git a/netra/config.py b/netra/config.py index 9d88c0a9..7c197f48 100644 --- a/netra/config.py +++ b/netra/config.py @@ -1,13 +1,13 @@ import json -import logging import os from typing import Any, Dict, FrozenSet, List, Optional, Tuple from opentelemetry.util.re import parse_env_headers +from netra.netra_logger import NoticeLevel, get_logger from netra.version import __version__ -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Fallback limits used when no Config has been activated yet (e.g. code paths that # run before ``Netra.init()``, or tests that never call it). Once ``init()`` runs, @@ -26,6 +26,9 @@ # POST is never attempted. _AUDIO_AUTH_HEADERS = ("x-api-key", "Authorization") +# The only header the Netra backend's API-key guard reads. Lowercase, for case-insensitive matching. +_NETRA_API_KEY_HEADER = "x-api-key" + _DEFAULT_AUDIO_BATCH_BYTES = 32768 _DEFAULT_AUDIO_BATCH_INTERVAL_MS = 1000 _DEFAULT_AUDIO_BUFFER_BYTES = 2097152 @@ -157,7 +160,7 @@ def _resolve_audio_settings(self) -> None: # batch size survived. Doing it the other way round lets a tiny # max_request_bytes silently shrink the batch below its floor. if self.audio_max_request_bytes < _MIN_AUDIO_BATCH_BYTES: - logger.warning( + logger.notice( "netra.audio: NETRA_AUDIO_MAX_REQUEST_BYTES=%d is below the minimum batch size; using %d", self.audio_max_request_bytes, _MIN_AUDIO_BATCH_BYTES, @@ -166,7 +169,7 @@ def _resolve_audio_settings(self) -> None: clamped_batch = min(max(self.audio_batch_bytes, _MIN_AUDIO_BATCH_BYTES), self.audio_max_request_bytes) if clamped_batch != self.audio_batch_bytes: - logger.warning( + logger.notice( "netra.audio: NETRA_AUDIO_BATCH_BYTES=%d out of range [%d, %d]; using %d", self.audio_batch_bytes, _MIN_AUDIO_BATCH_BYTES, @@ -180,7 +183,7 @@ def _resolve_audio_settings(self) -> None: _MAX_AUDIO_BATCH_INTERVAL_MS, ) if clamped_interval != self.audio_batch_interval_ms: - logger.warning( + logger.notice( "netra.audio: NETRA_AUDIO_BATCH_INTERVAL_MS=%d out of range [%d, %d]; using %d", self.audio_batch_interval_ms, _MIN_AUDIO_BATCH_INTERVAL_MS, @@ -190,7 +193,7 @@ def _resolve_audio_settings(self) -> None: self.audio_batch_interval_ms = clamped_interval if self.audio_buffer_bytes < self.audio_batch_bytes: - logger.warning( + logger.notice( "netra.audio: NETRA_AUDIO_BUFFER_BYTES=%d is below the batch size; using %d", self.audio_buffer_bytes, self.audio_batch_bytes, @@ -198,7 +201,7 @@ def _resolve_audio_settings(self) -> None: self.audio_buffer_bytes = self.audio_batch_bytes if self.audio_max_request_bytes < self.audio_batch_bytes: - logger.warning( + logger.notice( "netra.audio: NETRA_AUDIO_MAX_REQUEST_BYTES=%d is below the batch size; using %d", self.audio_max_request_bytes, self.audio_batch_bytes, @@ -242,7 +245,7 @@ def _resolve_audio_endpoint(self) -> Optional[str]: return None if not any(header in self.headers for header in _AUDIO_AUTH_HEADERS): - logger.warning( + logger.notice( "netra.audio: an audio endpoint resolved but no credential is configured; " "audio capture is disabled. Set NETRA_API_KEY or pass an auth header." ) @@ -283,10 +286,20 @@ def _parse_headers(self, headers: Optional[str] | Dict[str, str]) -> Dict[str, s return {} def _validate_api_key(self) -> None: - """Validate that API key exists for Netra endpoints.""" - if self.otlp_endpoint and "getnetra" in self.otlp_endpoint.lower() and not self.api_key: - print("Error: Missing Netra API key, go to netra dashboard to create one") - print("Set the NETRA_API_KEY environment variable to the key") + """Validate that an API key exists for Netra endpoints. + + The key counts as present when it is set via NETRA_API_KEY or already passed as an + ``x-api-key`` header (through ``headers`` or NETRA_HEADERS); header names match case-insensitively. + """ + if not self.otlp_endpoint or "getnetra" not in self.otlp_endpoint.lower() or self.api_key: + return + if any(name.lower() == _NETRA_API_KEY_HEADER for name in self.headers): + return + logger.notice( + "netra: NETRA_API_KEY is not set, so Netra will reject the traces this app sends. " + "Fix: create an API key in the Netra dashboard and set NETRA_API_KEY to it.", + level=NoticeLevel.ERROR, + ) def _setup_authentication(self) -> None: """Setup authentication headers based on endpoint and API key.""" @@ -325,10 +338,7 @@ def _get_resource_attributes(self, resource_attributes: Optional[Dict[str, Any]] try: return json.loads(env_ra) except (json.JSONDecodeError, ValueError) as e: - import logging - - logger = logging.getLogger(__name__) - logger.warning(f"Failed to parse NETRA_RESOURCE_ATTRS: {e}") + logger.notice(f"netra: Failed to parse NETRA_RESOURCE_ATTRS: {e}") return {} def _get_redact_headers(self) -> FrozenSet[str]: diff --git a/netra/dashboard/api.py b/netra/dashboard/api.py index 347c0dee..2e3c10f4 100644 --- a/netra/dashboard/api.py +++ b/netra/dashboard/api.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Iterator, List, Optional from netra.config import Config @@ -16,8 +15,9 @@ SortField, SortOrder, ) +from netra.netra_logger import NoticeLevel, get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class Dashboard: @@ -101,7 +101,9 @@ def get_session_stats( SessionStatsResult containing session data and pagination info. """ if not start_time or not end_time: - logger.error("netra.dashboard: start_time and end_time are required to fetch session stats") + logger.notice( + "netra.dashboard: start_time and end_time are required to fetch session stats", level=NoticeLevel.ERROR + ) return None result = self._client.get_session_stats( @@ -161,7 +163,10 @@ def iter_session_stats( SessionStatsData items from all pages. """ if not start_time or not end_time: - logger.error("netra.dashboard: start_time and end_time are required to iterate session stats") + logger.notice( + "netra.dashboard: start_time and end_time are required to iterate session stats", + level=NoticeLevel.ERROR, + ) return None cursor: Optional[str] = None @@ -195,10 +200,13 @@ def get_session_summary(self, filter: SessionFilterConfig) -> Any: Dict containing aggregated session metrics. """ if not isinstance(filter, SessionFilterConfig): - logger.error("netra.dashboard: filter must be a SessionFilterConfig") + logger.notice("netra.dashboard: filter must be a SessionFilterConfig", level=NoticeLevel.ERROR) return None if not filter.start_time or not filter.end_time: - logger.error("netra.dashboard: start_time and end_time are required to fetch session summary") + logger.notice( + "netra.dashboard: start_time and end_time are required to fetch session summary", + level=NoticeLevel.ERROR, + ) return None result = self._client.get_session_summary( @@ -231,7 +239,7 @@ def get_session_details( straddles a bound is reported from its in-window spans only. """ if not session_id: - logger.error("netra.dashboard: session_id is required to fetch session detail") + logger.notice("netra.dashboard: session_id is required to fetch session detail", level=NoticeLevel.ERROR) return None result = self._client.get_session_details( diff --git a/netra/dashboard/client.py b/netra/dashboard/client.py index 0957962a..d90215bc 100644 --- a/netra/dashboard/client.py +++ b/netra/dashboard/client.py @@ -1,4 +1,3 @@ -import logging import os from typing import Any, Dict, List, Optional @@ -16,9 +15,10 @@ SortField, SortOrder, ) +from netra.netra_logger import NoticeLevel, get_logger from netra.utils import extract_error_message -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class DashboardHttpClient: @@ -55,7 +55,9 @@ def _create_client(self, config: Config) -> Optional[httpx.Client]: try: return httpx.Client(base_url=base_url, headers=headers, timeout=timeout) except Exception as exc: - logger.error("netra.dashboard: Failed to initialize dashboard HTTP client: %s", exc) + logger.notice( + "netra.dashboard: Failed to initialize dashboard HTTP client: %s", exc, level=NoticeLevel.ERROR + ) return None def _resolve_base_url(self, endpoint: str) -> str: @@ -103,7 +105,7 @@ def _get_timeout(self) -> float: try: return float(timeout_env) except ValueError: - logger.warning( + logger.notice( "netra.dashboard: Invalid NETRA_DASHBOARD_TIMEOUT value '%s', using default 30.0", timeout_env, ) @@ -131,7 +133,9 @@ def query_data( The query response data or None on error. """ if not self._client: - logger.error("netra.dashboard: Dashboard client is not initialized; cannot execute query") + logger.notice( + "netra.dashboard: Dashboard client is not initialized; cannot execute query", level=NoticeLevel.ERROR + ) return None response: Optional[httpx.Response] = None @@ -179,9 +183,10 @@ def query_data( data = response.json() return data except Exception as exc: - logger.error( + logger.notice( "netra.dashboard: Failed to execute dashboard query: %s", extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return None @@ -211,7 +216,10 @@ def get_session_stats( The session stats response data or None on error. """ if not self._client: - logger.error("netra.dashboard: Dashboard client is not initialized; cannot fetch session stats") + logger.notice( + "netra.dashboard: Dashboard client is not initialized; cannot fetch session stats", + level=NoticeLevel.ERROR, + ) return None response: Optional[httpx.Response] = None @@ -249,9 +257,10 @@ def get_session_stats( data = response.json() return data except Exception as exc: - logger.error( + logger.notice( "netra.dashboard: Failed to fetch session stats: %s", extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return None @@ -268,7 +277,9 @@ def get_session_summary(self, start_time: str, end_time: str, filters: Optional[ The session summary response data or None on error. """ if not self._client: - logger.error("netra.dashboard: Dashboard client is not initialized; cannot execute query") + logger.notice( + "netra.dashboard: Dashboard client is not initialized; cannot execute query", level=NoticeLevel.ERROR + ) return None response: Optional[httpx.Response] = None @@ -297,9 +308,10 @@ def get_session_summary(self, start_time: str, end_time: str, filters: Optional[ data = response.json() return data except Exception as exc: - logger.error( + logger.notice( "netra.dashboard: Failed to fetch session summary: %s", extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return None @@ -321,7 +333,10 @@ def get_session_details( The session detail response data or None on error. """ if not self._client: - logger.error("netra.dashboard: Dashboard client is not initialized; cannot fetch session detail") + logger.notice( + "netra.dashboard: Dashboard client is not initialized; cannot fetch session detail", + level=NoticeLevel.ERROR, + ) return None response: Optional[httpx.Response] = None @@ -339,9 +354,10 @@ def get_session_details( data = response.json() return data except Exception as exc: - logger.error( + logger.notice( "netra.dashboard: Failed to fetch session detail '%s': %s", session_id, extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return None diff --git a/netra/decorators.py b/netra/decorators.py index c495eb29..562ef365 100644 --- a/netra/decorators.py +++ b/netra/decorators.py @@ -1,7 +1,6 @@ import functools import inspect import json -import logging from typing import ( Any, AsyncGenerator, @@ -25,11 +24,13 @@ from opentelemetry import trace +from netra.netra_logger import NoticeLevel, get_logger + from .config import Config from .session_manager import SessionManager from .span_wrapper import SpanType -logger = logging.getLogger(__name__) +logger = get_logger(__name__) P = ParamSpec("P") R = TypeVar("R") @@ -455,7 +456,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: # time (same result on every call), so failing here avoids pushing an # entity or starting a span we would only have to unwind. if not isinstance(as_type, SpanType): - logger.error("Invalid span type: %s", as_type) + logger.notice("netra: Invalid span type: %s", as_type, level=NoticeLevel.ERROR) return # Push entity before span starts so processors can capture it @@ -519,7 +520,7 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # entity or starting a span we would only have to unwind. ``None`` is # allowed (the span simply carries no type attribute). if as_type is not None and not isinstance(as_type, SpanType): - logger.error("Invalid span type: %s", as_type) + logger.notice("netra: Invalid span type: %s", as_type, level=NoticeLevel.ERROR) return # Push entity before span starts so processors can capture it diff --git a/netra/evaluation/api.py b/netra/evaluation/api.py index 64bb2caa..38ae2290 100644 --- a/netra/evaluation/api.py +++ b/netra/evaluation/api.py @@ -1,6 +1,5 @@ import asyncio import concurrent.futures -import logging from typing import Any, Callable, Dict, List, Optional from netra.config import Config @@ -31,9 +30,10 @@ run_single_evaluator, validate_run_inputs, ) +from netra.netra_logger import NoticeLevel, get_logger from netra.span_wrapper import SpanWrapper -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class Evaluation: @@ -68,7 +68,9 @@ def create_dataset( A backend JSON response containing dataset info (id, name, tags, etc.) on success, """ if not name: - logger.error("netra.evaluation: Failed to create dataset: dataset name is required") + logger.notice( + "netra.evaluation: Failed to create dataset: dataset name is required", level=NoticeLevel.ERROR + ) return None response = self._client.create_dataset(name=name, dataset_type=dataset_type, turn_type=turn_type, tags=tags) @@ -107,7 +109,7 @@ def add_dataset_item( """ if not item.input: - logger.error("netra.evaluation: Skipping dataset item without required 'input'") + logger.notice("netra.evaluation: Skipping dataset item without required 'input'", level=NoticeLevel.ERROR) return None response = self._client.add_dataset_item(dataset_id=dataset_id, item=item) @@ -178,7 +180,7 @@ def get_dataset(self, dataset_id: str) -> Any: A backend JSON response containing dataset info (id, input, expected_output etc.) on success, """ if not dataset_id: - logger.error("netra.evaluation: Failed to get dataset: dataset id is required") + logger.notice("netra.evaluation: Failed to get dataset: dataset id is required", level=NoticeLevel.ERROR) return None response = self._client.get_dataset(dataset_id) if not response: @@ -189,8 +191,19 @@ def get_dataset(self, dataset_id: str) -> Any: item_input = item.get("input") item_dataset_id = item.get("datasetId") item_expected_output = item.get("expectedOutput", "") - if item_id is None or item_dataset_id is None or item_input is None: - logger.warning("netra.evaluation: Skipping dataset item with missing required fields: %s", item) + # Notices name the item, never its payload: input often holds prompts or user data. + item_label = item_id if item_id is not None else "(no id)" + missing_fields = [ + field_name + for field_name, value in (("id", item_id), ("datasetId", item_dataset_id), ("input", item_input)) + if value is None + ] + if missing_fields: + logger.notice( + "netra.evaluation: Skipping dataset item %s: missing required field(s): %s", + item_label, + ", ".join(missing_fields), + ) continue try: dataset_items.append( @@ -202,7 +215,12 @@ def get_dataset(self, dataset_id: str) -> Any: ) ) except Exception as exc: - logger.error("netra.evaluation: Failed to parse dataset item: %s", exc) + logger.notice( + "netra.evaluation: Skipping dataset item %s: failed to parse: %s", + item_label, + exc, + level=NoticeLevel.ERROR, + ) return GetDatasetItemsResponse(items=dataset_items) def create_run( @@ -224,7 +242,7 @@ def create_run( """ if not name: - logger.error("netra.evaluation: Failed to create run: run name is required") + logger.notice("netra.evaluation: Failed to create run: run name is required", level=NoticeLevel.ERROR) return None evaluators_config_dicts: Optional[List[Dict[str, Any]]] = None @@ -245,7 +263,7 @@ def get_run_results(self, run_id: str) -> Any: The JSON response containing run results. """ if not run_id: - logger.error("netra.evaluation: Failed to get run: run_id is required") + logger.notice("netra.evaluation: Failed to get run: run_id is required", level=NoticeLevel.ERROR) return None response = self._client.get_run_results(run_id) return response @@ -283,7 +301,7 @@ def run_test_suite( evaluators_config=evaluators_config, ) if not run_id: - logger.error("netra.evaluation: Failed to create run") + logger.notice("netra.evaluation: Failed to create run", level=NoticeLevel.ERROR) return None logger.info("netra.evaluation: Initiated test run") diff --git a/netra/evaluation/client.py b/netra/evaluation/client.py index 2c34e50e..eb100966 100644 --- a/netra/evaluation/client.py +++ b/netra/evaluation/client.py @@ -1,5 +1,4 @@ import asyncio -import logging import os import time from typing import Any, Dict, List, Optional @@ -8,9 +7,10 @@ from netra.config import Config from netra.evaluation.models import DatasetItem, DatasetType, TurnType +from netra.netra_logger import NoticeLevel, get_logger from netra.utils import extract_error_message -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class EvaluationHttpClient: @@ -49,7 +49,9 @@ def _create_client(self, config: Config) -> Optional[httpx.Client]: try: return httpx.Client(base_url=base_url, headers=headers, timeout=timeout) except Exception as exc: - logger.error("netra.evaluation: Failed to initialize evaluation HTTP client: %s", exc) + logger.notice( + "netra.evaluation: Failed to initialize evaluation HTTP client: %s", exc, level=NoticeLevel.ERROR + ) return None def _resolve_base_url(self, endpoint: str) -> str: @@ -96,7 +98,7 @@ def _get_timeout(self) -> float: try: return float(timeout_env) except ValueError: - logger.warning( + logger.notice( "netra.evaluation: Invalid NETRA_EVALUATION_TIMEOUT value '%s', using default 10.0", timeout_env, ) @@ -122,7 +124,9 @@ def create_dataset( or None if creation fails. """ if not self._client: - logger.error("netra.evaluation: Evaluation client is not initialized; cannot create dataset") + logger.notice( + "netra.evaluation: Evaluation client is not initialized; cannot create dataset", level=NoticeLevel.ERROR + ) return None response: Optional[httpx.Response] = None try: @@ -140,7 +144,11 @@ def create_dataset( logger.info("netra.evaluation: Dataset created successfully") return data.get("data", {}) except Exception as exc: - logger.error("netra.evaluation: Failed to create dataset: %s", extract_error_message(response, exc)) + logger.notice( + "netra.evaluation: Failed to create dataset: %s", + extract_error_message(response, exc), + level=NoticeLevel.ERROR, + ) return None def add_dataset_item(self, dataset_id: str, item: DatasetItem) -> Any: @@ -155,7 +163,10 @@ def add_dataset_item(self, dataset_id: str, item: DatasetItem) -> Any: A backend JSON response on success or {"success": False} on error. """ if not self._client: - logger.error("netra.evaluation: Evaluation client is not initialized; cannot add item to dataset") + logger.notice( + "netra.evaluation: Evaluation client is not initialized; cannot add item to dataset", + level=NoticeLevel.ERROR, + ) return {"success": False} response: Optional[httpx.Response] = None try: @@ -173,10 +184,11 @@ def add_dataset_item(self, dataset_id: str, item: DatasetItem) -> Any: logger.info("netra.evaluation: Dataset item added successfully") return data.get("data", {}) except Exception as exc: - logger.error( + logger.notice( "netra.evaluation: Failed to add item to dataset '%s': %s", dataset_id, extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return None @@ -191,7 +203,9 @@ def get_all_datasets(self, tag: Optional[str] = None) -> Any: A list of dataset objects on success, or None on failure. """ if not self._client: - logger.error("netra.evaluation: Evaluation client is not initialized; cannot fetch datasets") + logger.notice( + "netra.evaluation: Evaluation client is not initialized; cannot fetch datasets", level=NoticeLevel.ERROR + ) return None response: Optional[httpx.Response] = None try: @@ -206,9 +220,10 @@ def get_all_datasets(self, tag: Optional[str] = None) -> Any: logger.info("netra.evaluation: Datasets fetched successfully") return data.get("data", []) except Exception as exc: - logger.error( + logger.notice( "netra.evaluation: Failed to fetch datasets: %s", extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return None @@ -223,7 +238,9 @@ def get_dataset(self, dataset_id: str) -> Any: A list of dataset items. """ if not self._client: - logger.error("netra.evaluation: Evaluation client is not initialized; cannot fetch dataset") + logger.notice( + "netra.evaluation: Evaluation client is not initialized; cannot fetch dataset", level=NoticeLevel.ERROR + ) return {"success": False} response: Optional[httpx.Response] = None try: @@ -235,10 +252,11 @@ def get_dataset(self, dataset_id: str) -> Any: logger.info("netra.evaluation: Dataset fetched successfully") return data.get("data", []) except Exception as exc: - logger.error( + logger.notice( "netra.evaluation: Failed to fetch dataset '%s': %s", dataset_id, extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return None @@ -260,7 +278,9 @@ def create_run( A backend JSON response containing run_id """ if not self._client: - logger.error("netra.evaluation: Evaluation client is not initialized; cannot create run") + logger.notice( + "netra.evaluation: Evaluation client is not initialized; cannot create run", level=NoticeLevel.ERROR + ) return {"success": False} response: Optional[httpx.Response] = None try: @@ -276,7 +296,12 @@ def create_run( if isinstance(data, dict) and "data" in data: return data.get("data", {}) except Exception as exc: - logger.error("netra.evaluation: Failed to create run '%s': %s", name, extract_error_message(response, exc)) + logger.notice( + "netra.evaluation: Failed to create run '%s': %s", + name, + extract_error_message(response, exc), + level=NoticeLevel.ERROR, + ) return {"success": False} def post_run_item(self, run_id: str, payload: Dict[str, Any]) -> Any: @@ -291,7 +316,9 @@ def post_run_item(self, run_id: str, payload: Dict[str, Any]) -> Any: A backend JSON response on success or {"success": False} on error. """ if not self._client: - logger.error("netra.evaluation: Evaluation client is not initialized; cannot post run item") + logger.notice( + "netra.evaluation: Evaluation client is not initialized; cannot post run item", level=NoticeLevel.ERROR + ) return {"success": False} response: Optional[httpx.Response] = None try: @@ -305,10 +332,11 @@ def post_run_item(self, run_id: str, payload: Dict[str, Any]) -> Any: return run_item_id return data except Exception as exc: - logger.error( + logger.notice( "netra.evaluation: Failed to post run item for run '%s': %s", run_id, extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return {"success": False} @@ -327,7 +355,10 @@ def submit_local_evaluations( A backend JSON response containing confirmation of the submission. """ if not self._client: - logger.error("netra.evaluation: Evaluation client is not initialized; cannot submit local evaluations") + logger.notice( + "netra.evaluation: Evaluation client is not initialized; cannot submit local evaluations", + level=NoticeLevel.ERROR, + ) return {"success": False} response: Optional[httpx.Response] = None try: @@ -340,11 +371,12 @@ def submit_local_evaluations( return data.get("data", {}) return data except Exception as exc: - logger.error( + logger.notice( "netra.evaluation: Failed to submit local evaluations for run '%s', item '%s': %s", run_id, test_run_item_id, extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return {"success": False} @@ -360,7 +392,10 @@ def post_run_status(self, run_id: str, status: str) -> Any: A backend JSON response containing confirmation of the submission. """ if not self._client: - logger.error("netra.evaluation: Evaluation client is not initialized; cannot post run status") + logger.notice( + "netra.evaluation: Evaluation client is not initialized; cannot post run status", + level=NoticeLevel.ERROR, + ) return {"success": False} response: Optional[httpx.Response] = None try: @@ -374,10 +409,11 @@ def post_run_status(self, run_id: str, status: str) -> Any: return data.get("data", {}) return data except Exception as exc: - logger.error( + logger.notice( "netra.evaluation: Failed to post run status for run '%s': %s", run_id, extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return {"success": False} @@ -392,7 +428,9 @@ def get_run_results(self, run_id: str) -> Any: A JSON response containing run results. """ if not self._client: - logger.error("netra.evaluation: Evaluation client is not initialized; cannot fetch run") + logger.notice( + "netra.evaluation: Evaluation client is not initialized; cannot fetch run", level=NoticeLevel.ERROR + ) return None response: Optional[httpx.Response] = None try: @@ -405,10 +443,11 @@ def get_run_results(self, run_id: str) -> Any: return data.get("data", {}) return data except Exception as exc: - logger.error( + logger.notice( "netra.evaluation: Failed to fetch run results for run '%s': %s", run_id, extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return None @@ -423,7 +462,9 @@ def get_span_by_id(self, span_id: str) -> Any: The span data if found, None otherwise. """ if not self._client: - logger.error("netra.evaluation: Evaluation client is not initialized; cannot get span") + logger.notice( + "netra.evaluation: Evaluation client is not initialized; cannot get span", level=NoticeLevel.ERROR + ) return None response: Optional[httpx.Response] = None try: diff --git a/netra/evaluation/utils.py b/netra/evaluation/utils.py index a40aeebf..ba3889bf 100644 --- a/netra/evaluation/utils.py +++ b/netra/evaluation/utils.py @@ -1,5 +1,4 @@ import asyncio -import logging import threading from typing import Any, Awaitable, Callable, Dict, List, Optional, TypeVar @@ -7,8 +6,9 @@ from opentelemetry import context as otel_context from netra.evaluation.models import DatasetRecord, EvaluatorConfig, EvaluatorContext, ItemContext +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) T = TypeVar("T") diff --git a/netra/exporters/filtering_span_exporter.py b/netra/exporters/filtering_span_exporter.py index f730d67e..24a2cd70 100644 --- a/netra/exporters/filtering_span_exporter.py +++ b/netra/exporters/filtering_span_exporter.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Dict, List, Sequence, Set from opentelemetry.sdk.trace import ReadableSpan @@ -17,9 +16,10 @@ read_local_block_patterns, reparent_spans, ) +from netra.netra_logger import get_logger from netra.processors.local_filtering_span_processor import BLOCKED_LOCAL_PARENT_MAP -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class FilteringSpanExporter(SpanExporter): # type: ignore[misc] diff --git a/netra/exporters/trial_aware_otlp_exporter.py b/netra/exporters/trial_aware_otlp_exporter.py index fa1decde..a167de52 100644 --- a/netra/exporters/trial_aware_otlp_exporter.py +++ b/netra/exporters/trial_aware_otlp_exporter.py @@ -9,8 +9,9 @@ ) from netra.exporters.utils import set_trial_blocked +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) HTTP_STATUS_PAYLOAD_TOO_LARGE = 413 # Longest excerpt of a non-JSON error body quoted in the export-failure debug message. @@ -18,6 +19,35 @@ PAYLOAD_TOO_LARGE_HINT = ( "Lower OTEL_BSP_MAX_EXPORT_BATCH_SIZE or reduce large span attributes such as prompts and completions." ) +# Module-level logger of opentelemetry-exporter-otlp-proto-http's OTLPSpanExporter. +OTEL_TRACE_EXPORTER_LOGGER_NAME = "opentelemetry.exporter.otlp.proto.http.trace_exporter" + +# Set while the current thread is inside TrialAwareOTLPExporter.export(). OTLPSpanExporter exports +# synchronously on the calling thread, so its log records for our batches are emitted under this flag. +_netra_export_scope = threading.local() + + +class _SuppressOtelErrorsDuringNetraExport(logging.Filter): + """Drop OTLPSpanExporter's ERROR records for batches Netra exports; Netra's own notice reports them. + + Without this, each failed batch prints twice: OpenTelemetry's "Failed to export span batch ..." and + Netra's notice. Only records emitted inside a Netra export are dropped, so failures of an exporter the + application runs itself stay visible. Retry WARNINGs are kept. + """ + + def filter(self, record: logging.LogRecord) -> bool: + """Return False for an ERROR (or above) record emitted inside a Netra export.""" + return record.levelno < logging.ERROR or not getattr(_netra_export_scope, "active", False) + + +_SUPPRESS_OTEL_ERRORS_FILTER = _SuppressOtelErrorsDuringNetraExport() + + +def _install_otel_error_filter() -> None: + """Attach the filter to the OpenTelemetry exporter logger once per process.""" + otel_logger = logging.getLogger(OTEL_TRACE_EXPORTER_LOGGER_NAME) + if _SUPPRESS_OTEL_ERRORS_FILTER not in otel_logger.filters: + otel_logger.addFilter(_SUPPRESS_OTEL_ERRORS_FILTER) def _describe_server_message(response: Any) -> str: @@ -41,6 +71,11 @@ def _describe_server_message(response: Any) -> str: return f'"{excerpt}"' if excerpt else "(empty response body)" +def _format_unanswered_export(error: BaseException, span_count: int) -> str: + """Message for a batch that got no HTTP response (connection refused, DNS, TLS, timeout).""" + return f"netra: Span export failed: {error}. Dropped a batch of {span_count} spans." + + def _format_export_failure(response: Any, span_count: int) -> str: """Debug message for a batch the backend rejected. @@ -65,15 +100,15 @@ def __init__( self, session: Any, callback: Callable[[Any], None], - on_request_error: Callable[[], None], + on_request_error: Callable[[Exception], None], ) -> None: """Initialize the interceptor. Args: session: The requests.Session to wrap callback: Callable invoked with each response - on_request_error: Callable invoked when a request raises instead of returning a response - (connection error, timeout), before the exception propagates + on_request_error: Callable invoked with the exception when a request raises instead of + returning a response (connection error, timeout), before the exception propagates """ self._session = session self._callback = callback @@ -87,8 +122,8 @@ def _wrapped_request(self, *args: Any, **kwargs: Any) -> Any: """Wrapper around session.request that intercepts responses.""" try: response = self._original_request(*args, **kwargs) - except Exception: - self._on_request_error() + except Exception as error: + self._on_request_error(error) raise try: self._callback(response) @@ -112,6 +147,7 @@ def __init__(self, wrapped_exporter: SpanExporter) -> None: # thread-local carries the last HTTP response back to export() without racing other exports. self._export_state = threading.local() self._setup_response_interception() + _install_otel_error_filter() def _setup_response_interception(self) -> None: """Setup interception of HTTP responses from the OTLP exporter. @@ -137,16 +173,21 @@ def _on_http_response(self, response: Any) -> None: """ try: self._export_state.last_response = response + self._export_state.last_request_error = None self._check_response_for_trial_blocking(response) except Exception as e: logger.debug("Error processing response in callback: %s", e) - def _on_http_request_error(self) -> None: - """Forget the previous attempt's response when a retry gets no response at all. + def _on_http_request_error(self, error: Exception) -> None: + """Remember why an attempt got no response, and forget the previous attempt's response. Otherwise a 503 followed by a timeout would be reported as the 503. + + Args: + error: The exception the HTTP request raised """ self._export_state.last_response = None + self._export_state.last_request_error = error def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: """Export spans and monitor response for trial status. @@ -158,33 +199,44 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: SpanExportResult from the wrapped exporter """ self._export_state.last_response = None + self._export_state.last_request_error = None + _netra_export_scope.active = True try: try: result = self._exporter.export(spans) except Exception as e: + # No backend answer (connection refused, DNS, TLS, timeout). Older OTLPSpanExporter + # versions (e.g. 1.38) let the request error propagate instead of logging it. + logger.notice(_format_unanswered_export(e, len(spans))) logger.debug("Error during OTLP export: %s", e, exc_info=True) return SpanExportResult.FAILURE if result == SpanExportResult.FAILURE: - self._log_rejected_batch(len(spans)) + self._log_failed_batch(len(spans)) return result finally: + _netra_export_scope.active = False # The response references the serialized batch (response.request.body); don't keep it # alive on this thread until its next export. self._export_state.last_response = None + self._export_state.last_request_error = None - def _log_rejected_batch(self, span_count: int) -> None: - """Report the backend's final answer for a batch OTLPSpanExporter gave up on. + def _log_failed_batch(self, span_count: int) -> None: + """Report why OTLPSpanExporter gave up on a batch: the backend's final answer, or the request error. - Visible only in debug mode: outside it the ``netra`` logger is silenced. Logged once per batch, - after OTLPSpanExporter's own retries (408 and 5xx), so only the last response is shown. + Logged once per batch, after OTLPSpanExporter's own retries (408, 5xx and, on newer versions, + connection errors), so only the last attempt is shown. Always visible, whether or not debug_mode + is on. OTLPSpanExporter's own ERROR for the batch is filtered out, so this is the only line. """ response = getattr(self._export_state, "last_response", None) status_code = getattr(response, "status_code", None) - if not isinstance(status_code, int) or 200 <= status_code < 300: - return # no backend answer (connection error, timeout): OTLPSpanExporter logs those itself - if not logger.isEnabledFor(logging.WARNING): + if isinstance(status_code, int) and not 200 <= status_code < 300: + logger.notice("netra: %s", _format_export_failure(response, span_count)) return - logger.warning("%s", _format_export_failure(response, span_count)) + request_error = getattr(self._export_state, "last_request_error", None) + if request_error is not None: + # Newer OTLPSpanExporter versions (e.g. 1.41) catch request errors and return FAILURE. + logger.notice(_format_unanswered_export(request_error, span_count)) + # Neither: no request was sent (exporter already shut down); OTLPSpanExporter's WARNING says so. def _check_response_for_trial_blocking(self, response: Any) -> None: """Check HTTP response for trial blocking indicators. diff --git a/netra/exporters/utils.py b/netra/exporters/utils.py index 534995d9..55de9de1 100644 --- a/netra/exporters/utils.py +++ b/netra/exporters/utils.py @@ -1,4 +1,3 @@ -import logging import threading import time from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, Union, cast @@ -7,13 +6,14 @@ from opentelemetry.trace import INVALID_SPAN_ID, Span, SpanContext from netra.config import get_trial_block_duration_seconds +from netra.netra_logger import get_logger from netra.processors.root_instrument_filter_processor import ( ROOT_BLOCK_CANDIDATE_FIELD, get_root_block_candidates, root_block_candidates_contains, ) -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Span attributes set by LocalFilteringSpanProcessor and read back here to # decide per-span local blocking (kept in sync with that processor's keys). @@ -41,8 +41,8 @@ def set_trial_blocked(blocked: bool) -> None: if _trial_blocked_at is None: # Start a new 15-minute blocking period _trial_blocked_at = time.time() - logger.warning( - "Trial/quota exhausted: blocking span export for %d seconds (15 minutes)", + logger.notice( + "netra: Trial/quota exhausted: blocking span export for %d seconds (15 minutes)", get_trial_block_duration_seconds(), ) else: diff --git a/netra/instrumentation/__init__.py b/netra/instrumentation/__init__.py index 0c86d887..4a0b7c35 100644 --- a/netra/instrumentation/__init__.py +++ b/netra/instrumentation/__init__.py @@ -27,7 +27,6 @@ needs it, so the cost is paid on first *activation* instead. """ -import logging import os from typing import AbstractSet, Optional @@ -48,6 +47,7 @@ ) from netra.instrumentation.wiring.deferral import register_lazy_instrumentations from netra.instrumentation.wiring.selection import select_instrumentations +from netra.netra_logger import get_logger __all__ = [ "CustomInstruments", @@ -57,7 +57,7 @@ "init_instrumentations", ] -logger = logging.getLogger(__name__) +logger = get_logger(__name__) def init_instrumentations( diff --git a/netra/instrumentation/capture/stream_utils.py b/netra/instrumentation/capture/stream_utils.py index c2fe0666..60225f63 100644 --- a/netra/instrumentation/capture/stream_utils.py +++ b/netra/instrumentation/capture/stream_utils.py @@ -28,10 +28,11 @@ warning log. """ -import logging from typing import Any, AsyncIterator, Callable, Generator, Iterator, List, Union -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) # Extractors โ€” injected at construction time, kept stateless diff --git a/netra/instrumentation/http/hooks.py b/netra/instrumentation/http/hooks.py index 35d8edbd..9dc45698 100644 --- a/netra/instrumentation/http/hooks.py +++ b/netra/instrumentation/http/hooks.py @@ -17,7 +17,6 @@ factories below by string and they are only imported on activation. """ -import logging import threading from typing import Any, Callable, Optional @@ -29,8 +28,9 @@ guard_redirect_baggage, register_hook_destination, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _urllib_redirect_guard_lock = threading.Lock() _urllib_redirect_guarded = False diff --git a/netra/instrumentation/http/propagation.py b/netra/instrumentation/http/propagation.py index b4f28a30..463371df 100644 --- a/netra/instrumentation/http/propagation.py +++ b/netra/instrumentation/http/propagation.py @@ -49,8 +49,9 @@ ) from netra.config import get_active_config +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # OpenTelemetry's W3CBaggagePropagator always writes this (lowercase) header. # We match case-insensitively when stripping, to be safe against carriers that diff --git a/netra/instrumentation/libraries/agno/__init__.py b/netra/instrumentation/libraries/agno/__init__.py index ef38fae3..0f6f072a 100644 --- a/netra/instrumentation/libraries/agno/__init__.py +++ b/netra/instrumentation/libraries/agno/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Collection, Optional, Tuple from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -29,8 +28,9 @@ workflow_arun_wrapper, workflow_run_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("agno >= 1.0.0",) diff --git a/netra/instrumentation/libraries/agno/utils.py b/netra/instrumentation/libraries/agno/utils.py index bcda0fa7..6b6427cc 100644 --- a/netra/instrumentation/libraries/agno/utils.py +++ b/netra/instrumentation/libraries/agno/utils.py @@ -1,6 +1,5 @@ import inspect import json -import logging from typing import Any, Dict, List, Optional, Tuple from opentelemetry import context as context_api @@ -9,9 +8,10 @@ from opentelemetry.trace import Span from netra.instrumentation.http.headers import sanitize_asgi_headers +from netra.netra_logger import get_logger from netra.span_wrapper import SpanType -logger = logging.getLogger(__name__) +logger = get_logger(__name__) NETRA_SPAN_TYPE = "netra.span.type" LLM_SYSTEM_AGNO = "agno" diff --git a/netra/instrumentation/libraries/agno/wrappers.py b/netra/instrumentation/libraries/agno/wrappers.py index 0f262d2c..89b91f96 100644 --- a/netra/instrumentation/libraries/agno/wrappers.py +++ b/netra/instrumentation/libraries/agno/wrappers.py @@ -1,5 +1,4 @@ import json -import logging import re import time from collections.abc import Awaitable @@ -42,9 +41,10 @@ update_active_span_with_system_prompt, ) from netra.instrumentation.span_utils import record_span_timing +from netra.netra_logger import get_logger from netra.span_wrapper import SpanType -logger = logging.getLogger(__name__) +logger = get_logger(__name__) AGENT_RUN_SPAN = "agno.agent.run" AGENT_CONTINUE_RUN_SPAN = "agno.agent.continue_run" diff --git a/netra/instrumentation/libraries/aiohttp/__init__.py b/netra/instrumentation/libraries/aiohttp/__init__.py index 8261ba10..62364820 100644 --- a/netra/instrumentation/libraries/aiohttp/__init__.py +++ b/netra/instrumentation/libraries/aiohttp/__init__.py @@ -2,7 +2,6 @@ import asyncio import functools -import logging import types from timeit import default_timer from typing import Any, Awaitable, Callable, Collection, Dict, Optional, Union @@ -59,8 +58,9 @@ from netra.instrumentation.http.propagation import inject_context from netra.instrumentation.libraries.aiohttp.version import __version__ +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Package info for aiohttp instrumentation _instruments = ("aiohttp >= 3.0.0",) @@ -165,7 +165,7 @@ async def instrumented_request(self: ClientSession, method: str, url: Any, **kwa _set_http_peer_port_client(span_attributes, parsed_url.port, sem_conv_opt_in_mode) span_attributes[NETWORK_PEER_PORT] = parsed_url.port except ValueError as error: - logger.error(error) + logger.error("%s", error) with ( tracer.start_as_current_span(span_name, kind=SpanKind.CLIENT, attributes=span_attributes) as span, diff --git a/netra/instrumentation/libraries/cartesia/__init__.py b/netra/instrumentation/libraries/cartesia/__init__.py index b803e603..1dff7880 100644 --- a/netra/instrumentation/libraries/cartesia/__init__.py +++ b/netra/instrumentation/libraries/cartesia/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Collection from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -14,8 +13,9 @@ tts_ws_wrapper, voice_changer_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("cartesia >= 2.0.17",) diff --git a/netra/instrumentation/libraries/cartesia/utils.py b/netra/instrumentation/libraries/cartesia/utils.py index bf0eb77d..f19ea509 100644 --- a/netra/instrumentation/libraries/cartesia/utils.py +++ b/netra/instrumentation/libraries/cartesia/utils.py @@ -1,11 +1,12 @@ -import logging from typing import Any, Dict from opentelemetry import context as context_api from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY from opentelemetry.trace import Span -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) def should_suppress_instrumentation() -> bool: diff --git a/netra/instrumentation/libraries/cartesia/wrappers.py b/netra/instrumentation/libraries/cartesia/wrappers.py index 2e7cde2f..f631ba88 100644 --- a/netra/instrumentation/libraries/cartesia/wrappers.py +++ b/netra/instrumentation/libraries/cartesia/wrappers.py @@ -1,4 +1,3 @@ -import logging import time from typing import Any, Callable, Dict, Iterator, Literal, Tuple @@ -11,8 +10,9 @@ set_response_attributes, should_suppress_instrumentation, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) TTS_SPAN_NAME = "cartesia.tts" STT_SPAN_NAME = "cartesia.stt" diff --git a/netra/instrumentation/libraries/cerebras/__init__.py b/netra/instrumentation/libraries/cerebras/__init__.py index 2ec5884c..44f40ad4 100644 --- a/netra/instrumentation/libraries/cerebras/__init__.py +++ b/netra/instrumentation/libraries/cerebras/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Collection from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -13,8 +12,9 @@ chat_wrapper, completions_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("cerebras-cloud-sdk >= 1.0.0",) diff --git a/netra/instrumentation/libraries/cerebras/utils.py b/netra/instrumentation/libraries/cerebras/utils.py index 0597ccfd..27d65ae5 100644 --- a/netra/instrumentation/libraries/cerebras/utils.py +++ b/netra/instrumentation/libraries/cerebras/utils.py @@ -1,4 +1,3 @@ -import logging from typing import Any from opentelemetry import context as context_api @@ -7,7 +6,9 @@ from opentelemetry.semconv_ai import SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, LLMRequestTypeValues, SpanAttributes from opentelemetry.trace import Span -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) GEN_AI_SYSTEM = "gen_ai.system" GEN_AI_SYSTEM_CEREBRAS = "cerebras" diff --git a/netra/instrumentation/libraries/cerebras/wrappers.py b/netra/instrumentation/libraries/cerebras/wrappers.py index f7906df2..ce7e30e5 100644 --- a/netra/instrumentation/libraries/cerebras/wrappers.py +++ b/netra/instrumentation/libraries/cerebras/wrappers.py @@ -1,4 +1,3 @@ -import logging import time from typing import Any, AsyncIterator, Callable, Dict, Iterator, Tuple @@ -15,8 +14,9 @@ should_suppress_instrumentation, ) from netra.instrumentation.span_utils import record_span_timing +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) CHAT_SPAN_NAME = "cerebras.chat.completions" COMPLETION_SPAN_NAME = "cerebras.completions" diff --git a/netra/instrumentation/libraries/claude_agent_sdk/__init__.py b/netra/instrumentation/libraries/claude_agent_sdk/__init__.py index ff4461f3..6d31983f 100644 --- a/netra/instrumentation/libraries/claude_agent_sdk/__init__.py +++ b/netra/instrumentation/libraries/claude_agent_sdk/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Any import wrapt @@ -12,8 +11,9 @@ client_response_wrapper, query_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("claude_agent_sdk >= 0.1.0",) diff --git a/netra/instrumentation/libraries/claude_agent_sdk/utils.py b/netra/instrumentation/libraries/claude_agent_sdk/utils.py index 5647eccb..3275968a 100644 --- a/netra/instrumentation/libraries/claude_agent_sdk/utils.py +++ b/netra/instrumentation/libraries/claude_agent_sdk/utils.py @@ -1,5 +1,4 @@ import json -import logging import threading import time from typing import Any, Optional @@ -23,9 +22,10 @@ from netra.config import Config from netra.instrumentation.span_utils import record_span_timing +from netra.netra_logger import get_logger from netra.span_wrapper import SpanType -logger = logging.getLogger(__name__) +logger = get_logger(__name__) TIME_TO_FIRST_TOKEN = "gen_ai.performance.time_to_first_token" RELATIVE_TIME_TO_FIRST_TOKEN = "gen_ai.performance.relative_time_to_first_token" diff --git a/netra/instrumentation/libraries/claude_agent_sdk/wrappers.py b/netra/instrumentation/libraries/claude_agent_sdk/wrappers.py index ac0a1041..41ee8c60 100644 --- a/netra/instrumentation/libraries/claude_agent_sdk/wrappers.py +++ b/netra/instrumentation/libraries/claude_agent_sdk/wrappers.py @@ -1,4 +1,3 @@ -import logging import time from typing import Any, AsyncIterator, Callable, Tuple @@ -16,8 +15,9 @@ set_user_message_attributes, ) from netra.instrumentation.span_utils import record_span_timing +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) QUERY_SPAN_NAME = "claude-agent.query" AGENT_CONVERSATION_SPAN_NAME = "claude-agent.conversation" diff --git a/netra/instrumentation/libraries/codex/__init__.py b/netra/instrumentation/libraries/codex/__init__.py index 1f8625fb..ece1d5f1 100644 --- a/netra/instrumentation/libraries/codex/__init__.py +++ b/netra/instrumentation/libraries/codex/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Callable import wrapt @@ -15,8 +14,9 @@ thread_response_wrapper, write_message_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("openai-codex >= 0.162.0",) diff --git a/netra/instrumentation/libraries/codex/utils.py b/netra/instrumentation/libraries/codex/utils.py index 14c9b68c..d8dc7bbc 100644 --- a/netra/instrumentation/libraries/codex/utils.py +++ b/netra/instrumentation/libraries/codex/utils.py @@ -1,5 +1,4 @@ import json -import logging import os import threading import time @@ -16,10 +15,11 @@ from netra.config import Config from netra.instrumentation.span_utils import record_span_timing +from netra.netra_logger import get_logger from netra.span_wrapper import SpanType from netra.utils import truncate_string -logger = logging.getLogger(__name__) +logger = get_logger(__name__) TURN_SPAN_NAME = "codex.turn" JOIN_SPAN_NAME = "codex.turn.join" diff --git a/netra/instrumentation/libraries/codex/wrappers.py b/netra/instrumentation/libraries/codex/wrappers.py index 0f86c9cf..a0899fc1 100644 --- a/netra/instrumentation/libraries/codex/wrappers.py +++ b/netra/instrumentation/libraries/codex/wrappers.py @@ -1,4 +1,3 @@ -import logging from typing import Any, AsyncIterator, Callable, Iterator, Tuple from opentelemetry import trace @@ -17,8 +16,9 @@ release_turn, start_turn, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Only W3C trace context goes to the app-server's JSON-RPC `trace` field; baggage stays in-process. _trace_context_propagator = TraceContextTextMapPropagator() diff --git a/netra/instrumentation/libraries/cohere/__init__.py b/netra/instrumentation/libraries/cohere/__init__.py index 59a3c2a3..1ada3f46 100644 --- a/netra/instrumentation/libraries/cohere/__init__.py +++ b/netra/instrumentation/libraries/cohere/__init__.py @@ -1,4 +1,3 @@ -import logging import os from typing import Any, Collection, Dict, Generator, Iterator, List, Optional, Union @@ -21,7 +20,9 @@ from opentelemetry.trace.status import Status, StatusCode from wrapt import wrap_function_wrapper -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) _instruments = ("cohere >=4.2.7, <6",) diff --git a/netra/instrumentation/libraries/deepgram/__init__.py b/netra/instrumentation/libraries/deepgram/__init__.py index f39147a3..44e75ed2 100644 --- a/netra/instrumentation/libraries/deepgram/__init__.py +++ b/netra/instrumentation/libraries/deepgram/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Collection from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -22,8 +21,9 @@ wrap_connect_async, wrap_sync, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("deepgram-sdk >= 5.0.0",) diff --git a/netra/instrumentation/libraries/deepgram/utils.py b/netra/instrumentation/libraries/deepgram/utils.py index 11eaefb7..56c1ec78 100644 --- a/netra/instrumentation/libraries/deepgram/utils.py +++ b/netra/instrumentation/libraries/deepgram/utils.py @@ -1,12 +1,13 @@ import json -import logging from typing import Any, Dict, Optional from opentelemetry import context as context_api from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY from opentelemetry.trace import Span -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) def should_suppress_instrumentation() -> bool: diff --git a/netra/instrumentation/libraries/deepgram/wrappers.py b/netra/instrumentation/libraries/deepgram/wrappers.py index 31203590..4854ebfb 100644 --- a/netra/instrumentation/libraries/deepgram/wrappers.py +++ b/netra/instrumentation/libraries/deepgram/wrappers.py @@ -1,4 +1,3 @@ -import logging import time from typing import Any, Callable, Dict, Tuple, cast @@ -12,8 +11,9 @@ set_response_attributes, should_suppress_instrumentation, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) TRANSCRIBE_URL_SPAN_NAME = "deepgram.transcribe_url" TRANSCRIBE_FILE_SPAN_NAME = "deepgram.transcribe_file" diff --git a/netra/instrumentation/libraries/dspy/__init__.py b/netra/instrumentation/libraries/dspy/__init__.py index 61a96088..092381a9 100644 --- a/netra/instrumentation/libraries/dspy/__init__.py +++ b/netra/instrumentation/libraries/dspy/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Collection from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -17,8 +16,9 @@ ToolAsyncCallWrapper, ToolCallWrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("dspy >= 2.0.0",) diff --git a/netra/instrumentation/libraries/dspy/wrappers.py b/netra/instrumentation/libraries/dspy/wrappers.py index 50838aef..2048325b 100644 --- a/netra/instrumentation/libraries/dspy/wrappers.py +++ b/netra/instrumentation/libraries/dspy/wrappers.py @@ -1,4 +1,3 @@ -import logging from contextvars import ContextVar from copy import copy, deepcopy from typing import Any, Callable, Mapping, Set, Tuple @@ -28,8 +27,9 @@ safe_json_dumps, should_suppress_instrumentation, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class CopyableBoundFunctionWrapper(BoundFunctionWrapper): # type: ignore diff --git a/netra/instrumentation/libraries/elevenlabs/__init__.py b/netra/instrumentation/libraries/elevenlabs/__init__.py index 950cdcec..45ad1f51 100644 --- a/netra/instrumentation/libraries/elevenlabs/__init__.py +++ b/netra/instrumentation/libraries/elevenlabs/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Collection from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -33,8 +32,9 @@ voice_changer_stream_wrapper, voice_changer_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("elevenlabs >= 2.15.0",) diff --git a/netra/instrumentation/libraries/elevenlabs/utils.py b/netra/instrumentation/libraries/elevenlabs/utils.py index a033c6f9..4771a898 100644 --- a/netra/instrumentation/libraries/elevenlabs/utils.py +++ b/netra/instrumentation/libraries/elevenlabs/utils.py @@ -1,11 +1,12 @@ -import logging from typing import Any, Dict from opentelemetry import context as context_api from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY from opentelemetry.trace import Span -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) def should_suppress_instrumentation() -> bool: diff --git a/netra/instrumentation/libraries/elevenlabs/wrappers.py b/netra/instrumentation/libraries/elevenlabs/wrappers.py index 0efde7f7..c01ab034 100644 --- a/netra/instrumentation/libraries/elevenlabs/wrappers.py +++ b/netra/instrumentation/libraries/elevenlabs/wrappers.py @@ -1,4 +1,3 @@ -import logging import time from typing import Any, AsyncIterator, Callable, Dict, Iterator, Tuple @@ -12,8 +11,9 @@ should_suppress_instrumentation, ) from netra.instrumentation.span_utils import record_span_timing +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) CREATE_SPEECH_SPAN_NAME = "elevenlabs.create_speech" CREATE_SPEECH_WITH_TIMESTAMPS_SPAN_NAME = "elevenlabs.create_speech_with_timestamp" diff --git a/netra/instrumentation/libraries/fastapi/__init__.py b/netra/instrumentation/libraries/fastapi/__init__.py index 9fc75e5a..4ec94258 100644 --- a/netra/instrumentation/libraries/fastapi/__init__.py +++ b/netra/instrumentation/libraries/fastapi/__init__.py @@ -8,7 +8,6 @@ from __future__ import annotations import functools -import logging import types import weakref from typing import Any, Collection, Dict, Iterable, Optional, Union @@ -26,8 +25,9 @@ get_route_details, ) from netra.instrumentation.libraries.fastapi.version import __version__ +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("fastapi >= 0.58.0",) _excluded_urls_from_env = get_excluded_urls("FASTAPI") diff --git a/netra/instrumentation/libraries/fastapi/middleware.py b/netra/instrumentation/libraries/fastapi/middleware.py index 43370142..af914178 100644 --- a/netra/instrumentation/libraries/fastapi/middleware.py +++ b/netra/instrumentation/libraries/fastapi/middleware.py @@ -5,7 +5,6 @@ attributes matching the conventions used by the httpx instrumentation. """ -import logging from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union from fastapi import HTTPException @@ -23,8 +22,9 @@ set_span_output, should_suppress_instrumentation, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) DEFAULT_ERROR_STATUS_CODE_RANGE = range(400, 600) diff --git a/netra/instrumentation/libraries/fastapi/utils.py b/netra/instrumentation/libraries/fastapi/utils.py index a995cc42..a42a9072 100644 --- a/netra/instrumentation/libraries/fastapi/utils.py +++ b/netra/instrumentation/libraries/fastapi/utils.py @@ -7,7 +7,6 @@ and :mod:`netra.instrumentation.http.body`. """ -import logging from typing import Any, Dict, List, Optional, Tuple, Union from opentelemetry import context as context_api @@ -18,8 +17,9 @@ from netra.instrumentation.http.body import build_response_output from netra.instrumentation.http.headers import sanitize_asgi_headers +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) def should_suppress_instrumentation() -> bool: diff --git a/netra/instrumentation/libraries/google_adk/__init__.py b/netra/instrumentation/libraries/google_adk/__init__.py index dc2937df..fa108060 100644 --- a/netra/instrumentation/libraries/google_adk/__init__.py +++ b/netra/instrumentation/libraries/google_adk/__init__.py @@ -1,4 +1,3 @@ -import logging import sys from typing import Any, Collection @@ -14,8 +13,9 @@ call_tool_async_wrapper, run_and_handle_error_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("google-adk >= 0.1.0",) diff --git a/netra/instrumentation/libraries/google_adk/utils.py b/netra/instrumentation/libraries/google_adk/utils.py index fcb5a524..765e7f4c 100644 --- a/netra/instrumentation/libraries/google_adk/utils.py +++ b/netra/instrumentation/libraries/google_adk/utils.py @@ -1,10 +1,11 @@ import json -import logging from typing import Any, Dict, List from opentelemetry.semconv_ai import SpanAttributes -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) NETRA_SPAN_TYPE = "netra.span.type" diff --git a/netra/instrumentation/libraries/google_adk/wrappers.py b/netra/instrumentation/libraries/google_adk/wrappers.py index 1603fca3..5ac95582 100644 --- a/netra/instrumentation/libraries/google_adk/wrappers.py +++ b/netra/instrumentation/libraries/google_adk/wrappers.py @@ -1,5 +1,4 @@ import json -import logging import time from contextlib import contextmanager from typing import Any, AsyncIterator, Callable, Dict, Generator, List, Tuple, cast @@ -18,12 +17,13 @@ extract_llm_response_attributes, ) from netra.instrumentation.span_utils import record_span_timing +from netra.netra_logger import get_logger from netra.span_wrapper import SpanType TIME_TO_FIRST_TOKEN = "gen_ai.performance.time_to_first_token" RELATIVE_TIME_TO_FIRST_TOKEN = "gen_ai.performance.relative_time_to_first_token" -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class NoOpSpan: diff --git a/netra/instrumentation/libraries/google_genai/__init__.py b/netra/instrumentation/libraries/google_genai/__init__.py index 2d16903d..24ef5892 100644 --- a/netra/instrumentation/libraries/google_genai/__init__.py +++ b/netra/instrumentation/libraries/google_genai/__init__.py @@ -1,6 +1,5 @@ """OpenTelemetry Google GenAI API instrumentation""" -import logging import os from typing import Any, Collection @@ -20,8 +19,9 @@ images_wrapper, videos_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("google-genai >= 0.1.0",) diff --git a/netra/instrumentation/libraries/google_genai/wrappers.py b/netra/instrumentation/libraries/google_genai/wrappers.py index 1b0b5291..3c4330ae 100644 --- a/netra/instrumentation/libraries/google_genai/wrappers.py +++ b/netra/instrumentation/libraries/google_genai/wrappers.py @@ -1,4 +1,3 @@ -import logging import time from typing import Any, AsyncIterator, Callable, Dict, Iterator, Tuple @@ -12,8 +11,9 @@ should_suppress_instrumentation, ) from netra.instrumentation.span_utils import record_span_timing +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) CONTENT_SPAN_NAME = "genai.generate_content" diff --git a/netra/instrumentation/libraries/groq/__init__.py b/netra/instrumentation/libraries/groq/__init__.py index dc4d355b..98fc00e6 100644 --- a/netra/instrumentation/libraries/groq/__init__.py +++ b/netra/instrumentation/libraries/groq/__init__.py @@ -14,9 +14,10 @@ achat_wrapper, chat_wrapper, ) +from netra.netra_logger import get_logger logging.getLogger("opentelemetry.context").setLevel(logging.CRITICAL) -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments: Tuple[str, ...] = ("groq >= 0.9.0",) diff --git a/netra/instrumentation/libraries/groq/utils.py b/netra/instrumentation/libraries/groq/utils.py index 7f55745c..e93710ae 100644 --- a/netra/instrumentation/libraries/groq/utils.py +++ b/netra/instrumentation/libraries/groq/utils.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Dict from opentelemetry import context as context_api @@ -6,7 +5,9 @@ from opentelemetry.semconv_ai import SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, SpanAttributes from opentelemetry.trace import Span -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) def should_suppress_instrumentation() -> bool: diff --git a/netra/instrumentation/libraries/groq/wrappers.py b/netra/instrumentation/libraries/groq/wrappers.py index aeea6267..5d0529f9 100644 --- a/netra/instrumentation/libraries/groq/wrappers.py +++ b/netra/instrumentation/libraries/groq/wrappers.py @@ -1,4 +1,3 @@ -import logging import time from typing import Any, AsyncIterator, Callable, Dict, Iterator, Tuple @@ -14,8 +13,9 @@ should_suppress_instrumentation, ) from netra.instrumentation.span_utils import record_span_timing +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) CHAT_SPAN_NAME = "groq.chat" TIME_TO_FIRST_TOKEN = "gen_ai.performance.time_to_first_token" diff --git a/netra/instrumentation/libraries/hermes_agent/__init__.py b/netra/instrumentation/libraries/hermes_agent/__init__.py index 7ecef06c..f63a2f59 100644 --- a/netra/instrumentation/libraries/hermes_agent/__init__.py +++ b/netra/instrumentation/libraries/hermes_agent/__init__.py @@ -1,4 +1,3 @@ -import logging from pathlib import Path from typing import Any @@ -21,8 +20,9 @@ title_generation_wrapper, tool_execution_middleware_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("hermes-agent >= 0.17.0",) diff --git a/netra/instrumentation/libraries/hermes_agent/utils.py b/netra/instrumentation/libraries/hermes_agent/utils.py index c3a47f9e..2f5887c6 100644 --- a/netra/instrumentation/libraries/hermes_agent/utils.py +++ b/netra/instrumentation/libraries/hermes_agent/utils.py @@ -1,5 +1,4 @@ import json -import logging import os from typing import Any, Optional @@ -10,9 +9,10 @@ from netra.config import Config from netra.instrumentation.span_utils import _safe_set_attribute +from netra.netra_logger import get_logger from netra.span_wrapper import SpanType -logger = logging.getLogger(__name__) +logger = get_logger(__name__) TURN_SPAN_NAME = "hermes-agent.turn" SUBAGENT_TURN_SPAN_NAME = "hermes-agent.subagent.turn" diff --git a/netra/instrumentation/libraries/hermes_agent/wrappers.py b/netra/instrumentation/libraries/hermes_agent/wrappers.py index d4ac7c2f..49ea8e5d 100644 --- a/netra/instrumentation/libraries/hermes_agent/wrappers.py +++ b/netra/instrumentation/libraries/hermes_agent/wrappers.py @@ -1,4 +1,3 @@ -import logging from contextvars import ContextVar, Token from typing import Any, Callable, Optional, Tuple @@ -23,8 +22,9 @@ set_turn_request_attributes, set_turn_response_attributes, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Tool call ids currently being traced on this execution context. Hermes's # handle_function_call re-enters itself when the tool_search bridge unwraps a diff --git a/netra/instrumentation/libraries/honcho/__init__.py b/netra/instrumentation/libraries/honcho/__init__.py index d401962e..c2579d1f 100644 --- a/netra/instrumentation/libraries/honcho/__init__.py +++ b/netra/instrumentation/libraries/honcho/__init__.py @@ -1,4 +1,3 @@ -import logging from dataclasses import dataclass, field from typing import Any, Callable, Collection @@ -56,8 +55,9 @@ make_chat_stream_sync_wrapper, make_sync_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("honcho-ai >= 2.0.0",) diff --git a/netra/instrumentation/libraries/honcho/utils.py b/netra/instrumentation/libraries/honcho/utils.py index 8ce7afc8..7cab3968 100644 --- a/netra/instrumentation/libraries/honcho/utils.py +++ b/netra/instrumentation/libraries/honcho/utils.py @@ -1,5 +1,4 @@ import json -import logging from typing import Any, Callable, Dict, List, Optional, Tuple from opentelemetry import context as context_api @@ -8,9 +7,10 @@ from netra.config import get_attribute_max_len from netra.instrumentation.libraries.honcho import constants as attrs +from netra.netra_logger import get_logger from netra.utils import truncate_string -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Type aliases for attribute-setter callbacks used by wrappers and PatchSpec. RequestAttrFn = Callable[[Span, Any, Tuple[Any, ...], Dict[str, Any]], None] diff --git a/netra/instrumentation/libraries/honcho/wrappers.py b/netra/instrumentation/libraries/honcho/wrappers.py index e068c953..0816f84c 100644 --- a/netra/instrumentation/libraries/honcho/wrappers.py +++ b/netra/instrumentation/libraries/honcho/wrappers.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Callable, Dict, Optional, Tuple from opentelemetry.trace import Span, SpanKind, Tracer @@ -10,8 +9,9 @@ ResponseAttrFn, should_suppress_instrumentation, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) def _safe_set_request_attrs( diff --git a/netra/instrumentation/libraries/httpx/__init__.py b/netra/instrumentation/libraries/httpx/__init__.py index 39a49dd7..60a81085 100644 --- a/netra/instrumentation/libraries/httpx/__init__.py +++ b/netra/instrumentation/libraries/httpx/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Collection from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -13,8 +12,9 @@ build_redirect_request_wrapper, send_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("httpx >= 0.18.0",) diff --git a/netra/instrumentation/libraries/httpx/utils.py b/netra/instrumentation/libraries/httpx/utils.py index 56c3fa9b..7f75be89 100644 --- a/netra/instrumentation/libraries/httpx/utils.py +++ b/netra/instrumentation/libraries/httpx/utils.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Dict, Optional import httpx @@ -10,8 +9,9 @@ from netra.instrumentation.capture.bounded_capture import BoundedStreamBuffer from netra.instrumentation.http.body import build_response_output, build_streaming_output from netra.instrumentation.http.headers import sanitize_header_mapping +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) def should_suppress_instrumentation() -> bool: diff --git a/netra/instrumentation/libraries/httpx/wrappers.py b/netra/instrumentation/libraries/httpx/wrappers.py index d979a0d1..e699dbe5 100644 --- a/netra/instrumentation/libraries/httpx/wrappers.py +++ b/netra/instrumentation/libraries/httpx/wrappers.py @@ -1,4 +1,3 @@ -import logging from collections.abc import AsyncIterator, Awaitable, Iterator from typing import Any, Callable, Dict, Tuple @@ -18,8 +17,9 @@ set_streaming_span_output, should_suppress_instrumentation, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class _BaseStreamingWrapper(ObjectProxy): # type: ignore[misc] diff --git a/netra/instrumentation/libraries/litellm/__init__.py b/netra/instrumentation/libraries/litellm/__init__.py index 6b762d58..991b852f 100644 --- a/netra/instrumentation/libraries/litellm/__init__.py +++ b/netra/instrumentation/libraries/litellm/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Collection from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -18,8 +17,9 @@ image_generation_wrapper, responses_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("litellm >= 1.0.0",) diff --git a/netra/instrumentation/libraries/litellm/utils.py b/netra/instrumentation/libraries/litellm/utils.py index ada3804f..d635a960 100644 --- a/netra/instrumentation/libraries/litellm/utils.py +++ b/netra/instrumentation/libraries/litellm/utils.py @@ -1,5 +1,4 @@ import json -import logging from typing import Any, Dict from opentelemetry import context as context_api @@ -7,7 +6,9 @@ from opentelemetry.semconv_ai import SpanAttributes from opentelemetry.trace import Span -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) def should_suppress_instrumentation() -> bool: diff --git a/netra/instrumentation/libraries/litellm/wrappers.py b/netra/instrumentation/libraries/litellm/wrappers.py index 61ebd018..f79a4e63 100644 --- a/netra/instrumentation/libraries/litellm/wrappers.py +++ b/netra/instrumentation/libraries/litellm/wrappers.py @@ -1,4 +1,3 @@ -import logging import time from collections.abc import Awaitable from typing import Any, AsyncIterator, Callable, Dict, Iterator, Tuple @@ -15,8 +14,9 @@ should_suppress_instrumentation, ) from netra.instrumentation.span_utils import record_span_timing +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) COMPLETION_SPAN_NAME = "litellm.completion" EMBEDDING_SPAN_NAME = "litellm.embedding" diff --git a/netra/instrumentation/libraries/livekit/__init__.py b/netra/instrumentation/libraries/livekit/__init__.py index 99894b01..36d7dc96 100644 --- a/netra/instrumentation/libraries/livekit/__init__.py +++ b/netra/instrumentation/libraries/livekit/__init__.py @@ -1,6 +1,5 @@ """LiveKit voice-agent instrumentation for Netra.""" -import logging import threading from typing import Any, Collection, Optional @@ -15,8 +14,9 @@ from netra.instrumentation.libraries.livekit.provider_binding import bind_livekit_tracer from netra.instrumentation.libraries.livekit.trace_processor import SpanMappingProcessor from netra.instrumentation.libraries.livekit.wrappers import wrap_aclose, wrap_start +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("livekit-agents >= 1.6.0, < 2.0.0",) diff --git a/netra/instrumentation/libraries/livekit/audio_capture.py b/netra/instrumentation/libraries/livekit/audio_capture.py index 53bafeba..724776e9 100644 --- a/netra/instrumentation/libraries/livekit/audio_capture.py +++ b/netra/instrumentation/libraries/livekit/audio_capture.py @@ -22,7 +22,6 @@ import asyncio import functools -import logging import threading import time from concurrent.futures import TimeoutError as FuturesTimeoutError @@ -42,6 +41,7 @@ NETRA_AUDIO_SENT_CHUNKS, SpeakerRole, ) +from netra.netra_logger import get_logger if TYPE_CHECKING: from livekit.agents import AgentSession @@ -51,7 +51,7 @@ from netra.config import Config -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Nominal PCM bytes in one captured frame: 20ms of 24kHz mono 16-bit audio, what # livekit-agents delivers by default. Used only to turn the byte budget diff --git a/netra/instrumentation/libraries/livekit/audio_processor.py b/netra/instrumentation/libraries/livekit/audio_processor.py index f7bfb73f..268281be 100644 --- a/netra/instrumentation/libraries/livekit/audio_processor.py +++ b/netra/instrumentation/libraries/livekit/audio_processor.py @@ -19,7 +19,6 @@ from __future__ import annotations -import logging from typing import NamedTuple, Optional, Union from opentelemetry import context as otel_context @@ -28,8 +27,9 @@ from netra.instrumentation.libraries.livekit.audio_capture import SessionAudioCoordinator, audio_coordinators from netra.instrumentation.libraries.livekit.audio_types import SPEAKING_SPAN_ROLES, TTS_NODE_SPAN_NAME, SpeakerRole +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _TRACE_ID_HEX_DIGITS = "032x" _SPAN_ID_HEX_DIGITS = "016x" diff --git a/netra/instrumentation/libraries/livekit/audio_sender.py b/netra/instrumentation/libraries/livekit/audio_sender.py index 7b6c3e58..3c211deb 100644 --- a/netra/instrumentation/libraries/livekit/audio_sender.py +++ b/netra/instrumentation/libraries/livekit/audio_sender.py @@ -23,7 +23,6 @@ from __future__ import annotations import asyncio -import logging import random import time from dataclasses import dataclass, field @@ -56,11 +55,12 @@ SpeakerRole, pcm_byte_offset_at, ) +from netra.netra_logger import get_logger if TYPE_CHECKING: from livekit.rtc import AudioFrame -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Defaults for the knobs ``Config`` does not resolve. Every other limit reaches # the sender from ``Config`` โ€” see ``audio_capture.start_audio_capture``. diff --git a/netra/instrumentation/libraries/livekit/call_span.py b/netra/instrumentation/libraries/livekit/call_span.py index 39e01bce..fb55c885 100644 --- a/netra/instrumentation/libraries/livekit/call_span.py +++ b/netra/instrumentation/libraries/livekit/call_span.py @@ -58,7 +58,6 @@ from __future__ import annotations -import logging import threading from collections import OrderedDict from contextlib import contextmanager @@ -81,9 +80,10 @@ NETRA_SPAN_TYPE, ) from netra.instrumentation.libraries.livekit.version import __version__ +from netra.netra_logger import get_logger from netra.processors.root_span_processor import RootSpanProcessor -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # The tracer scope for ``livekit-call``. Spelled out rather than derived from # ``__name__``: the instrument name is the *last* dotted component of the scope diff --git a/netra/instrumentation/libraries/livekit/provider_binding.py b/netra/instrumentation/libraries/livekit/provider_binding.py index 62b3d88d..d7e6beaf 100644 --- a/netra/instrumentation/libraries/livekit/provider_binding.py +++ b/netra/instrumentation/libraries/livekit/provider_binding.py @@ -12,7 +12,6 @@ ``_ShieldedTracerProvider`` delegates the reads LiveKit needs and absorbs both. """ -import logging from typing import Any from opentelemetry import trace as trace_api @@ -20,7 +19,9 @@ from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import SpanProcessor -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) # Set on the *delegate* once bound, mirroring ``_netra_processors_installed`` # in netra/tracer.py, so repeat instrument() calls are idempotent. diff --git a/netra/instrumentation/libraries/livekit/trace_processor.py b/netra/instrumentation/libraries/livekit/trace_processor.py index 9c9d26ae..8b5cf16c 100644 --- a/netra/instrumentation/libraries/livekit/trace_processor.py +++ b/netra/instrumentation/libraries/livekit/trace_processor.py @@ -16,7 +16,6 @@ from __future__ import annotations import itertools -import logging import threading import weakref from typing import Any, Callable, Dict, Iterator, Mapping, Optional, Tuple @@ -79,8 +78,9 @@ stt_pricing_attributes_from, tts_pricing_attributes_from, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) SetAttributeFunc = Callable[[str, Any], None] diff --git a/netra/instrumentation/libraries/livekit/wrappers.py b/netra/instrumentation/libraries/livekit/wrappers.py index 468b3bfc..2e63ec54 100644 --- a/netra/instrumentation/libraries/livekit/wrappers.py +++ b/netra/instrumentation/libraries/livekit/wrappers.py @@ -23,7 +23,6 @@ from __future__ import annotations -import logging from contextlib import ExitStack from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Mapping, Optional, Tuple @@ -48,13 +47,14 @@ record_user_transcript, ) from netra.instrumentation.libraries.livekit.utils import NETRA_CLOSE_REASON, STT_METRICS_TYPE +from netra.netra_logger import get_logger from netra.session_manager import SessionManager if TYPE_CHECKING: from livekit.agents import AgentSession from opentelemetry.trace import Span -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # The wrapt quadruple is (wrapped, instance, args, kwargs). ``instance`` is a # livekit AgentSession, which cannot be imported at module scope โ€” the SDK must diff --git a/netra/instrumentation/libraries/mistralai/__init__.py b/netra/instrumentation/libraries/mistralai/__init__.py index cc01343b..07a77c54 100644 --- a/netra/instrumentation/libraries/mistralai/__init__.py +++ b/netra/instrumentation/libraries/mistralai/__init__.py @@ -1,7 +1,6 @@ """OpenTelemetry Mistral AI instrumentation""" import json -import logging import os from typing import Any, AsyncGenerator, Callable, Collection, Dict, Generator, Optional, Tuple, Union @@ -26,8 +25,9 @@ from netra.instrumentation.libraries.mistralai.config import Config from netra.instrumentation.libraries.mistralai.utils import dont_throw from netra.instrumentation.libraries.mistralai.version import __version__ +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("mistralai >= 1.0.0",) diff --git a/netra/instrumentation/libraries/mistralai/utils.py b/netra/instrumentation/libraries/mistralai/utils.py index aee05344..0c208c2b 100644 --- a/netra/instrumentation/libraries/mistralai/utils.py +++ b/netra/instrumentation/libraries/mistralai/utils.py @@ -1,8 +1,8 @@ -import logging import traceback from typing import Any, Callable from netra.instrumentation.libraries.mistralai.config import Config +from netra.netra_logger import get_logger def dont_throw(func: Callable[..., Any]) -> Callable[..., Any]: @@ -13,7 +13,7 @@ def dont_throw(func: Callable[..., Any]) -> Callable[..., Any]: @return: The wrapper function """ # Obtain a logger specific to the function's module - logger = logging.getLogger(func.__module__) + logger = get_logger(func.__module__) def wrapper(*args: Any, **kwargs: Any) -> Any: try: diff --git a/netra/instrumentation/libraries/openai/__init__.py b/netra/instrumentation/libraries/openai/__init__.py index acccfdd9..6288b5d1 100644 --- a/netra/instrumentation/libraries/openai/__init__.py +++ b/netra/instrumentation/libraries/openai/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Collection from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -15,8 +14,9 @@ embeddings_wrapper, responses_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("openai >= 1.0.0",) diff --git a/netra/instrumentation/libraries/openai/utils.py b/netra/instrumentation/libraries/openai/utils.py index d8a5f932..493b69f9 100644 --- a/netra/instrumentation/libraries/openai/utils.py +++ b/netra/instrumentation/libraries/openai/utils.py @@ -1,5 +1,4 @@ import json -import logging import math from typing import Any, Dict, List, Optional, TypeGuard @@ -11,9 +10,10 @@ from opentelemetry.trace import Span from netra.config import Config +from netra.netra_logger import get_logger from netra.span_wrapper import ATTRIBUTE, UsageModel -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # A provider-reported cost covers the whole call (prompt, completion, cache read and write). PROVIDER_REPORTED_USAGE_TYPE = "total" diff --git a/netra/instrumentation/libraries/openai/wrappers.py b/netra/instrumentation/libraries/openai/wrappers.py index cbfc9b42..a6e0e07a 100644 --- a/netra/instrumentation/libraries/openai/wrappers.py +++ b/netra/instrumentation/libraries/openai/wrappers.py @@ -1,4 +1,3 @@ -import logging import time from collections.abc import Awaitable from typing import Any, AsyncIterator, Callable, Dict, Iterator, Tuple @@ -15,8 +14,9 @@ should_suppress_instrumentation, ) from netra.instrumentation.span_utils import record_span_timing +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Span names CHAT_SPAN_NAME = "openai.chat" diff --git a/netra/instrumentation/libraries/pydantic_ai/__init__.py b/netra/instrumentation/libraries/pydantic_ai/__init__.py index 20b1c903..883c0567 100644 --- a/netra/instrumentation/libraries/pydantic_ai/__init__.py +++ b/netra/instrumentation/libraries/pydantic_ai/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Collection from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -14,8 +13,9 @@ agent_run_wrapper, tool_function_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("pydantic-ai >= 0.0.1",) diff --git a/netra/instrumentation/libraries/pydantic_ai/wrappers.py b/netra/instrumentation/libraries/pydantic_ai/wrappers.py index fb62785b..2499bf7a 100644 --- a/netra/instrumentation/libraries/pydantic_ai/wrappers.py +++ b/netra/instrumentation/libraries/pydantic_ai/wrappers.py @@ -1,4 +1,3 @@ -import logging import time from typing import Any, AsyncIterator, Callable, Dict @@ -21,8 +20,9 @@ set_pydantic_response_attributes, should_suppress_instrumentation, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class InstrumentedAgentRun: diff --git a/netra/instrumentation/libraries/pydantic_ai_slim/__init__.py b/netra/instrumentation/libraries/pydantic_ai_slim/__init__.py index ef3b5f79..439ab363 100644 --- a/netra/instrumentation/libraries/pydantic_ai_slim/__init__.py +++ b/netra/instrumentation/libraries/pydantic_ai_slim/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Collection from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -14,8 +13,9 @@ agent_run_wrapper, tool_function_wrapper, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("pydantic-ai-slim >= 0.5.1",) diff --git a/netra/instrumentation/libraries/pydantic_ai_slim/wrappers.py b/netra/instrumentation/libraries/pydantic_ai_slim/wrappers.py index fb62785b..2499bf7a 100644 --- a/netra/instrumentation/libraries/pydantic_ai_slim/wrappers.py +++ b/netra/instrumentation/libraries/pydantic_ai_slim/wrappers.py @@ -1,4 +1,3 @@ -import logging import time from typing import Any, AsyncIterator, Callable, Dict @@ -21,8 +20,9 @@ set_pydantic_response_attributes, should_suppress_instrumentation, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class InstrumentedAgentRun: diff --git a/netra/instrumentation/libraries/requests/__init__.py b/netra/instrumentation/libraries/requests/__init__.py index 24b7d4f7..57a566d0 100644 --- a/netra/instrumentation/libraries/requests/__init__.py +++ b/netra/instrumentation/libraries/requests/__init__.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Collection from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -8,8 +7,9 @@ from netra.instrumentation.libraries.requests.version import __version__ from netra.instrumentation.libraries.requests.wrappers import send_wrapper +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments = ("requests >= 2.0.0",) diff --git a/netra/instrumentation/libraries/requests/utils.py b/netra/instrumentation/libraries/requests/utils.py index ee405461..c32964ef 100644 --- a/netra/instrumentation/libraries/requests/utils.py +++ b/netra/instrumentation/libraries/requests/utils.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Dict, Union import requests as requests_lib # type: ignore[import-untyped] @@ -10,8 +9,9 @@ from netra.instrumentation.capture.bounded_capture import BoundedStreamBuffer from netra.instrumentation.http.body import build_response_output, build_streaming_output from netra.instrumentation.http.headers import sanitize_header_mapping +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # A ``PreparedRequest`` body may be a generator or file object, and a streaming # response's body has not arrived yet. Both are recorded as a description diff --git a/netra/instrumentation/libraries/requests/wrappers.py b/netra/instrumentation/libraries/requests/wrappers.py index 86217e99..ea51e1a5 100644 --- a/netra/instrumentation/libraries/requests/wrappers.py +++ b/netra/instrumentation/libraries/requests/wrappers.py @@ -1,4 +1,3 @@ -import logging from collections.abc import Iterator from typing import Any, Callable, Dict, Tuple @@ -18,8 +17,9 @@ set_streaming_span_output, should_suppress_instrumentation, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class StreamingWrapper(ObjectProxy): # type: ignore[misc] diff --git a/netra/instrumentation/libraries/subprocess/__init__.py b/netra/instrumentation/libraries/subprocess/__init__.py index 2d60bdfb..d36e1c43 100644 --- a/netra/instrumentation/libraries/subprocess/__init__.py +++ b/netra/instrumentation/libraries/subprocess/__init__.py @@ -1,5 +1,4 @@ import inspect -import logging import subprocess from typing import Any, Callable, Collection, Dict, Tuple @@ -8,8 +7,9 @@ from wrapt import wrap_function_wrapper from netra.instrumentation.libraries.subprocess.utils import inject_subprocess_context +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _instruments: tuple[()] = () diff --git a/netra/instrumentation/libraries/subprocess/utils.py b/netra/instrumentation/libraries/subprocess/utils.py index 824a64cf..dba39d21 100644 --- a/netra/instrumentation/libraries/subprocess/utils.py +++ b/netra/instrumentation/libraries/subprocess/utils.py @@ -1,4 +1,3 @@ -import logging import os from typing import Any, Dict, Mapping, Optional @@ -6,8 +5,9 @@ from opentelemetry import propagate, trace from netra.instrumentation.http.propagation import inject_local +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) def _log_traceparent(traceparent: Optional[str]) -> None: diff --git a/netra/instrumentation/libraries/weaviate/__init__.py b/netra/instrumentation/libraries/weaviate/__init__.py index 0b0a295c..5c2e3ce0 100644 --- a/netra/instrumentation/libraries/weaviate/__init__.py +++ b/netra/instrumentation/libraries/weaviate/__init__.py @@ -12,7 +12,9 @@ from opentelemetry.trace import get_tracer from wrapt import wrap_function_wrapper -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) _instruments = ("weaviate-client >= 3.26.0, <5",) diff --git a/netra/instrumentation/span_utils.py b/netra/instrumentation/span_utils.py index 80312b6a..92631da8 100644 --- a/netra/instrumentation/span_utils.py +++ b/netra/instrumentation/span_utils.py @@ -1,14 +1,14 @@ -import logging import time from datetime import datetime, timezone from typing import Any, Optional -logger = logging.getLogger(__name__) - from opentelemetry.trace import Span +from netra.netra_logger import get_logger from netra.processors.root_span_processor import RootSpanProcessor +logger = get_logger(__name__) + def _safe_set_attribute(span: Span, key: str, value: Any, max_length: Optional[int] = None) -> bool: """Safely set a span attribute with optional truncation and null checks. diff --git a/netra/instrumentation/wiring/activation.py b/netra/instrumentation/wiring/activation.py index 7c6fb942..982c8c3d 100644 --- a/netra/instrumentation/wiring/activation.py +++ b/netra/instrumentation/wiring/activation.py @@ -10,7 +10,6 @@ deferring that import is the point of the whole arrangement. """ -import logging import re import sys import threading @@ -24,12 +23,13 @@ from netra.instrumentation.instruments import InstrumentSet from netra.instrumentation.wiring.registry import CUSTOM_INSTRUMENTORS, SUBPROCESS_INSTRUMENTOR, InstrumentorSpec from netra.instrumentation.wiring.selection import InstrumentationSelection +from netra.netra_logger import get_logger if TYPE_CHECKING: # Type-only: importing traceloop.sdk at runtime costs ~620 ms. from traceloop.sdk.instruments import Instruments -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Guards the depth counter below โ€” never held across an activation, only across # the counter update. Holding a lock while an instrumentor imports its library diff --git a/netra/instrumentation/wiring/deferral.py b/netra/instrumentation/wiring/deferral.py index cbe58b0f..97612bdf 100644 --- a/netra/instrumentation/wiring/deferral.py +++ b/netra/instrumentation/wiring/deferral.py @@ -53,15 +53,15 @@ already outlive shutdown before this change; only the patching is new. """ -import logging import threading from collections import defaultdict from typing import Callable, Sequence from netra.instrumentation.wiring.activation import Activation, run_activation from netra.instrumentation.wiring.triggers import INSTRUMENT_TRIGGERS +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Trigger lookup is by member *name* rather than by ``InstrumentSet`` member, # because a traceloop instrumentation is carried through activation as the name diff --git a/netra/instrumentation/wiring/registry.py b/netra/instrumentation/wiring/registry.py index 08ab06c4..411551a6 100644 --- a/netra/instrumentation/wiring/registry.py +++ b/netra/instrumentation/wiring/registry.py @@ -16,14 +16,14 @@ order. Instrumentations absent from this table are never activated. """ -import logging from dataclasses import dataclass, field from types import MappingProxyType from typing import Any, Mapping, Optional from netra.instrumentation.instruments import InstrumentSet +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _NO_KWARGS: Mapping[str, Any] = MappingProxyType({}) diff --git a/netra/instrumentation/wiring/selection.py b/netra/instrumentation/wiring/selection.py index e3f705eb..1432d073 100644 --- a/netra/instrumentation/wiring/selection.py +++ b/netra/instrumentation/wiring/selection.py @@ -9,7 +9,6 @@ with one deliberate exception documented on :func:`_select_traceloop_names`. """ -import logging from dataclasses import dataclass from typing import AbstractSet, Optional @@ -20,8 +19,9 @@ NetraInstruments, _Origin, ) +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # An instrumentation Netra implements itself must never also be delegated to # traceloop, or the same call sites are patched twice. That is enforced by diff --git a/netra/logging_utils.py b/netra/logging_utils.py index d5561d6e..64bfe520 100644 --- a/netra/logging_utils.py +++ b/netra/logging_utils.py @@ -48,10 +48,11 @@ def configure_package_logging(debug_mode: bool) -> None: otel_instr_logger.propagate = True otel_instr_instrumentor_logger.propagate = True else: - # Netra: completely silent unless user opts in + # Netra: silent unless user opts in, except the "netra.notice" child logger, which sets its own + # WARNING level (see netra/notice.py). No NullHandler and propagation on, so those notices + # reach the application's root handlers, or Python's last-resort stderr handler if it has none. pkg_logger.setLevel(logging.CRITICAL) - pkg_logger.addHandler(logging.NullHandler()) - pkg_logger.propagate = False + pkg_logger.propagate = True # HTTP clients: keep their own warnings local, do not bubble via Netra httpx_logger.setLevel(logging.WARNING) diff --git a/netra/meter.py b/netra/meter.py index 538386a0..7cb531e5 100644 --- a/netra/meter.py +++ b/netra/meter.py @@ -1,5 +1,4 @@ import json -import logging import threading from typing import Any, List, Optional @@ -25,8 +24,9 @@ from opentelemetry.sdk.resources import Resource from netra.config import Config +from netra.netra_logger import NoticeLevel, get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _provider_install_lock = threading.Lock() @@ -104,10 +104,11 @@ def export( if resp.ok: return MetricExportResult.SUCCESS - logger.error( - "Failed to export metrics batch code: %s, reason: %s", + logger.notice( + "netra: Failed to export metrics batch code: %s, reason: %s", resp.status_code, resp.text, + level=NoticeLevel.ERROR, ) return MetricExportResult.FAILURE diff --git a/netra/models/api.py b/netra/models/api.py index ab1a746f..5cf15c9a 100644 --- a/netra/models/api.py +++ b/netra/models/api.py @@ -1,11 +1,11 @@ -import logging from typing import Any, List, Optional from netra.cache import TTLCache from netra.config import Config from netra.models.client import ModelsHttpClient +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) MODEL_PRICING_CACHE_TTL_SECONDS = 300 diff --git a/netra/models/client.py b/netra/models/client.py index ce47d08a..37d07a58 100644 --- a/netra/models/client.py +++ b/netra/models/client.py @@ -1,13 +1,13 @@ -import logging import os from typing import Any, Dict, Optional import httpx from netra.config import Config +from netra.netra_logger import NoticeLevel, get_logger from netra.utils import extract_error_message -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class ModelsHttpClient: @@ -44,7 +44,7 @@ def _create_client(self, config: Config) -> Optional[httpx.Client]: try: return httpx.Client(base_url=base_url, headers=headers, timeout=timeout) except Exception as exc: - logger.error("netra.models: Failed to initialize models HTTP client: %s", exc) + logger.notice("netra.models: Failed to initialize models HTTP client: %s", exc, level=NoticeLevel.ERROR) return None def _resolve_base_url(self, endpoint: str) -> str: @@ -91,7 +91,7 @@ def _get_timeout(self) -> float: try: return float(timeout_env) except ValueError: - logger.warning( + logger.notice( "netra.models: Invalid NETRA_MODELS_TIMEOUT value '%s', using default 10.0", timeout_env, ) @@ -108,7 +108,7 @@ def get_model_pricing(self, name: Optional[str] = None) -> Any: Raw JSON response dict, or empty dict on failure """ if not self._client: - logger.error("netra.models: Models client is not initialized.") + logger.notice("netra.models: Models client is not initialized.", level=NoticeLevel.ERROR) return {} response: Optional[httpx.Response] = None @@ -120,5 +120,9 @@ def get_model_pricing(self, name: Optional[str] = None) -> Any: response.raise_for_status() return response.json() except Exception as exc: - logger.error("netra.models: Failed to fetch models: %s", extract_error_message(response, exc)) + logger.notice( + "netra.models: Failed to fetch models: %s", + extract_error_message(response, exc), + level=NoticeLevel.ERROR, + ) return {} diff --git a/netra/netra_logger.py b/netra/netra_logger.py new file mode 100644 index 00000000..b4079882 --- /dev/null +++ b/netra/netra_logger.py @@ -0,0 +1,332 @@ +"""The logger every module of the Netra SDK uses. + +Get one per module with ``logger = get_logger(__name__)``. + +``debug``, ``info``, ``warning``, ``error`` and ``exception`` follow ``debug_mode``: outside it they +are silenced (``configure_package_logging`` sets the ``netra`` package logger to CRITICAL), because +most of what the SDK logs is internal detail a developer cannot act on. + +``notice`` is the exception. It is for problems the developer has to fix: a missing endpoint, a +rejected export, an invalid argument to a public method, a failed API call. It is shown whether or +not ``debug_mode`` is on, every time it is called. Notices go through the ``netra.notice`` logger +and propagate to the root logger, so they flow through the application's own logging setup; when +the application has configured none, Python's last-resort handler prints them to stderr. +To silence them: ``logging.getLogger("netra.notice").setLevel(logging.CRITICAL)``. +""" + +import logging +from enum import IntEnum +from typing import Any, Mapping, Optional + +NOTICE_LOGGER_NAME = "netra.notice" + + +class NoticeLevel(IntEnum): + """Severities a notice can carry. Nothing below WARNING: the notice logger would drop it.""" + + WARNING = logging.WARNING + ERROR = logging.ERROR + + +_notice_logger = logging.getLogger(NOTICE_LOGGER_NAME) +# Without an explicit level this logger would inherit the CRITICAL level that silences the rest of +# the "netra" package outside debug mode. Only set it when unset, so a level chosen by the +# application before importing netra is kept. +if _notice_logger.level == logging.NOTSET: + _notice_logger.setLevel(logging.WARNING) + +# Each method below calls logging.Logger.log itself, so one extra frame sits between the SDK call +# site and the logging module; skipping it keeps a record's filename/lineno at the call site. +_WRAPPER_FRAMES = 1 + +# Any: the logging module accepts a bool, an exception instance or an exc_info tuple here, and its +# own stubs type the parameter loosely too. +_ExcInfo = Any + + +class NetraLogger: + """Per-module SDK logger: stdlib-compatible level methods plus an always-visible ``notice``.""" + + __slots__ = ("_logger",) + + def __init__(self, name: str) -> None: + """Wrap the standard library logger called ``name``. + + Args: + name: Logger name, normally the calling module's ``__name__`` (e.g. + ``"netra.tracer"``). Names under ``netra`` inherit the package's debug_mode level. + + Returns: + None. + """ + self._logger = logging.getLogger(name) + + @property + def name(self) -> str: + """The wrapped logger's name. + + Returns: + The name passed to the constructor. + """ + return self._logger.name + + def debug( + self, + msg: str, + *args: object, + exc_info: _ExcInfo = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None, + ) -> None: + """Log at DEBUG. Shown only when debug_mode is on. + + Args: + msg: Message, %-format style; formatted with ``args`` only if the record is emitted. + *args: Values substituted into ``msg``. + exc_info: Exception information to attach: ``True`` for the exception being handled, + an exception instance, or an ``(type, value, traceback)`` tuple. + stack_info: Attach the current call stack to the record. + stacklevel: How many frames above the caller to attribute the record to; 1 is the + caller of this method. + extra: Additional attributes set on the ``LogRecord``. + + Returns: + None. + """ + self._logger.log( + logging.DEBUG, + msg, + *args, + exc_info=exc_info, + stack_info=stack_info, + stacklevel=stacklevel + _WRAPPER_FRAMES, + extra=extra, + ) + + def info( + self, + msg: str, + *args: object, + exc_info: _ExcInfo = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None, + ) -> None: + """Log at INFO. Shown only when debug_mode is on. + + Args: + msg: Message, %-format style; formatted with ``args`` only if the record is emitted. + *args: Values substituted into ``msg``. + exc_info: Exception information to attach: ``True`` for the exception being handled, + an exception instance, or an ``(type, value, traceback)`` tuple. + stack_info: Attach the current call stack to the record. + stacklevel: How many frames above the caller to attribute the record to; 1 is the + caller of this method. + extra: Additional attributes set on the ``LogRecord``. + + Returns: + None. + """ + self._logger.log( + logging.INFO, + msg, + *args, + exc_info=exc_info, + stack_info=stack_info, + stacklevel=stacklevel + _WRAPPER_FRAMES, + extra=extra, + ) + + def warning( + self, + msg: str, + *args: object, + exc_info: _ExcInfo = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None, + ) -> None: + """Log at WARNING. Shown only when debug_mode is on. + + Args: + msg: Message, %-format style; formatted with ``args`` only if the record is emitted. + *args: Values substituted into ``msg``. + exc_info: Exception information to attach: ``True`` for the exception being handled, + an exception instance, or an ``(type, value, traceback)`` tuple. + stack_info: Attach the current call stack to the record. + stacklevel: How many frames above the caller to attribute the record to; 1 is the + caller of this method. + extra: Additional attributes set on the ``LogRecord``. + + Returns: + None. + """ + self._logger.log( + logging.WARNING, + msg, + *args, + exc_info=exc_info, + stack_info=stack_info, + stacklevel=stacklevel + _WRAPPER_FRAMES, + extra=extra, + ) + + def error( + self, + msg: str, + *args: object, + exc_info: _ExcInfo = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None, + ) -> None: + """Log at ERROR. Shown only when debug_mode is on. + + Args: + msg: Message, %-format style; formatted with ``args`` only if the record is emitted. + *args: Values substituted into ``msg``. + exc_info: Exception information to attach: ``True`` for the exception being handled, + an exception instance, or an ``(type, value, traceback)`` tuple. + stack_info: Attach the current call stack to the record. + stacklevel: How many frames above the caller to attribute the record to; 1 is the + caller of this method. + extra: Additional attributes set on the ``LogRecord``. + + Returns: + None. + """ + self._logger.log( + logging.ERROR, + msg, + *args, + exc_info=exc_info, + stack_info=stack_info, + stacklevel=stacklevel + _WRAPPER_FRAMES, + extra=extra, + ) + + def exception( + self, + msg: str, + *args: object, + exc_info: _ExcInfo = True, + stack_info: bool = False, + stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None, + ) -> None: + """Log at ERROR with the current exception's traceback. Shown only when debug_mode is on. + + Call it from an ``except`` block: ``exc_info`` defaults to ``True``. + + Args: + msg: Message, %-format style; formatted with ``args`` only if the record is emitted. + *args: Values substituted into ``msg``. + exc_info: Exception information to attach: ``True`` for the exception being handled, + an exception instance, or an ``(type, value, traceback)`` tuple. + stack_info: Attach the current call stack to the record. + stacklevel: How many frames above the caller to attribute the record to; 1 is the + caller of this method. + extra: Additional attributes set on the ``LogRecord``. + + Returns: + None. + """ + self._logger.log( + logging.ERROR, + msg, + *args, + exc_info=exc_info, + stack_info=stack_info, + stacklevel=stacklevel + _WRAPPER_FRAMES, + extra=extra, + ) + + def log( + self, + level: int, + msg: str, + *args: object, + exc_info: _ExcInfo = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None, + ) -> None: + """Log at an explicit ``level``. Shown only when debug_mode is on. + + Args: + level: A ``logging`` level, e.g. ``logging.WARNING``. + msg: Message, %-format style; formatted with ``args`` only if the record is emitted. + *args: Values substituted into ``msg``. + exc_info: Exception information to attach: ``True`` for the exception being handled, + an exception instance, or an ``(type, value, traceback)`` tuple. + stack_info: Attach the current call stack to the record. + stacklevel: How many frames above the caller to attribute the record to; 1 is the + caller of this method. + extra: Additional attributes set on the ``LogRecord``. + + Returns: + None. + """ + self._logger.log( + level, + msg, + *args, + exc_info=exc_info, + stack_info=stack_info, + stacklevel=stacklevel + _WRAPPER_FRAMES, + extra=extra, + ) + + def notice( + self, + msg: str, + *args: object, + level: NoticeLevel = NoticeLevel.WARNING, + exc_info: _ExcInfo = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None, + ) -> None: + """Log a problem the developer has to fix; shown whether or not debug_mode is on. + + The record goes to the ``netra.notice`` logger, not this logger's own name, so an application + can route or silence notices separately. Every call is logged: repeats are not collapsed. + + Args: + msg: Message, %-format style; formatted with ``args`` only if the record is emitted. + *args: Values substituted into ``msg``. + exc_info: Exception information to attach: ``True`` for the exception being handled, + an exception instance, or an ``(type, value, traceback)`` tuple. + stack_info: Attach the current call stack to the record. + stacklevel: How many frames above the caller to attribute the record to; 1 is the + caller of this method. + extra: Additional attributes set on the ``LogRecord``. + level: ``NoticeLevel.WARNING`` (default) for something to fix, ``NoticeLevel.ERROR`` + for a failure such as a rejected API call. + + Returns: + None. + """ + _notice_logger.log( + level, + msg, + *args, + exc_info=exc_info, + stack_info=stack_info, + stacklevel=stacklevel + _WRAPPER_FRAMES, + extra=extra, + ) + + +def get_logger(name: str) -> NetraLogger: + """Return the SDK logger for a module. + + Args: + name: Logger name; pass the calling module's ``__name__``. + + Returns: + A ``NetraLogger`` wrapping ``logging.getLogger(name)``. Loggers are cheap wrappers: calling + this twice with one name returns two objects that log through the same stdlib logger. + """ + return NetraLogger(name) diff --git a/netra/processors/instrumentation_span_processor.py b/netra/processors/instrumentation_span_processor.py index 1b134832..fc47efb8 100644 --- a/netra/processors/instrumentation_span_processor.py +++ b/netra/processors/instrumentation_span_processor.py @@ -1,6 +1,5 @@ """Span processor for instrumentation name recording, attribute truncation, and header redaction.""" -import logging import os from typing import Any, Callable, FrozenSet, Mapping, Optional, Set, Union @@ -10,8 +9,9 @@ from netra.config import Config, get_attribute_max_len from netra.instrumentation.http.headers import REDACTED, get_sensitive_headers from netra.instrumentation.instruments import THIRD_PARTY_INSTRUMENTATION_SCOPES, InstrumentSet +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Type aliases TruncatableValue = Union[str, bytes, bytearray] diff --git a/netra/processors/llm_trace_identifier_span_processor.py b/netra/processors/llm_trace_identifier_span_processor.py index 50b4d518..a76a2b21 100644 --- a/netra/processors/llm_trace_identifier_span_processor.py +++ b/netra/processors/llm_trace_identifier_span_processor.py @@ -1,4 +1,3 @@ -import logging import threading from typing import Optional, Set @@ -6,9 +5,10 @@ from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor from opentelemetry.trace import Span, SpanContext +from netra.netra_logger import get_logger from netra.processors.root_span_processor import RootSpanProcessor -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class LlmTraceIdentifierSpanProcessor(SpanProcessor): # type: ignore[misc] diff --git a/netra/processors/local_filtering_span_processor.py b/netra/processors/local_filtering_span_processor.py index 89f3d4c0..463ff2a1 100644 --- a/netra/processors/local_filtering_span_processor.py +++ b/netra/processors/local_filtering_span_processor.py @@ -1,5 +1,4 @@ import json -import logging from contextlib import contextmanager from typing import List, Optional, Sequence @@ -8,7 +7,9 @@ from opentelemetry import trace from opentelemetry.sdk.trace import SpanProcessor -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) # Baggage key to carry local blocked span patterns in the active context _LOCAL_BLOCKED_SPANS_BAGGAGE_KEY = "netra.local_blocked_spans" diff --git a/netra/processors/root_instrument_filter_processor.py b/netra/processors/root_instrument_filter_processor.py index 2ef46154..8ea4386a 100644 --- a/netra/processors/root_instrument_filter_processor.py +++ b/netra/processors/root_instrument_filter_processor.py @@ -1,4 +1,3 @@ -import logging import threading import time from collections import OrderedDict @@ -9,8 +8,9 @@ from opentelemetry.trace import INVALID_SPAN_ID, SpanContext from netra.instrumentation.instruments import THIRD_PARTY_INSTRUMENTATION_SCOPES +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _INSTRUMENTATION_PREFIXES = ("opentelemetry.instrumentation.", "netra.instrumentation.") diff --git a/netra/processors/root_span_processor.py b/netra/processors/root_span_processor.py index 08fd4722..82f022ca 100644 --- a/netra/processors/root_span_processor.py +++ b/netra/processors/root_span_processor.py @@ -1,4 +1,3 @@ -import logging import threading from typing import Dict, Optional @@ -7,7 +6,9 @@ from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor from opentelemetry.trace import Span -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) class RootSpanProcessor(SpanProcessor): # type: ignore[misc] diff --git a/netra/processors/scrubbing_span_processor.py b/netra/processors/scrubbing_span_processor.py index abeeea3c..2b8f0a03 100644 --- a/netra/processors/scrubbing_span_processor.py +++ b/netra/processors/scrubbing_span_processor.py @@ -1,4 +1,3 @@ -import logging import re from typing import Any, Dict, Optional, Union @@ -6,7 +5,9 @@ from opentelemetry import trace from opentelemetry.sdk.trace import SpanProcessor -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) class ScrubbingSpanProcessor(SpanProcessor): # type: ignore[misc] diff --git a/netra/processors/session_span_processor.py b/netra/processors/session_span_processor.py index c879d5cd..6d80049f 100644 --- a/netra/processors/session_span_processor.py +++ b/netra/processors/session_span_processor.py @@ -1,4 +1,3 @@ -import logging from typing import Optional from opentelemetry import baggage @@ -7,9 +6,10 @@ from opentelemetry.sdk.trace import SpanProcessor from netra.config import Config +from netra.netra_logger import get_logger from netra.session_manager import ATTR_SESSION_ID, ATTR_TENANT_ID, ATTR_USER_ID, SessionManager -logger = logging.getLogger(__name__) +logger = get_logger(__name__) def _resolve_baggage( diff --git a/netra/processors/span_io_processor.py b/netra/processors/span_io_processor.py index 7bf76d1d..bf5941f5 100644 --- a/netra/processors/span_io_processor.py +++ b/netra/processors/span_io_processor.py @@ -1,12 +1,13 @@ import json -import logging import re from typing import Any, Callable, Dict, Mapping, Optional from opentelemetry import context as otel_context from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) # Patterns for gen_ai indexed attributes _PROMPT_RE = re.compile(r"^gen_ai\.prompts?\.(\d+)\.(role|content)$") diff --git a/netra/prompts/api.py b/netra/prompts/api.py index 81f15747..f1f80b5e 100644 --- a/netra/prompts/api.py +++ b/netra/prompts/api.py @@ -1,11 +1,11 @@ -import logging from typing import Any, Optional from netra.cache import TTLCache from netra.config import Config +from netra.netra_logger import NoticeLevel, get_logger from netra.prompts.client import PromptsHttpClient -logger = logging.getLogger(__name__) +logger = get_logger(__name__) PROMPT_CACHE_TTL_SECONDS = 60 @@ -50,7 +50,7 @@ def get_prompt( Prompt version data or None/empty dict if not found """ if not name: - logger.error("netra.prompts: name is required to fetch a prompt") + logger.notice("netra.prompts: name is required to fetch a prompt", level=NoticeLevel.ERROR) return None cache_key = f"prompt:{name}:{label}" diff --git a/netra/prompts/client.py b/netra/prompts/client.py index 5d475089..d302311d 100644 --- a/netra/prompts/client.py +++ b/netra/prompts/client.py @@ -1,13 +1,13 @@ -import logging import os from typing import Any, Dict, Optional import httpx from netra.config import Config +from netra.netra_logger import NoticeLevel, get_logger from netra.utils import extract_error_message -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class PromptsHttpClient: @@ -46,7 +46,7 @@ def _create_client(self, config: Config) -> Optional[httpx.Client]: try: return httpx.Client(base_url=base_url, headers=headers, timeout=timeout) except Exception as exc: - logger.error("netra.prompts: Failed to initialize prompts HTTP client: %s", exc) + logger.notice("netra.prompts: Failed to initialize prompts HTTP client: %s", exc, level=NoticeLevel.ERROR) return None def _resolve_base_url(self, endpoint: str) -> str: @@ -93,7 +93,7 @@ def _get_timeout(self) -> float: try: return float(timeout_env) except ValueError: - logger.warning( + logger.notice( "netra.prompts: Invalid NETRA_PROMPTS_TIMEOUT value '%s', using default 10.0", timeout_env, ) @@ -111,9 +111,10 @@ def get_prompt_version(self, prompt_name: str, label: str) -> Any: Prompt version data or empty dict if not found """ if not self._client: - logger.error( + logger.notice( "netra.prompts: Prompts client is not initialized; cannot fetch prompt version for '%s'", prompt_name, + level=NoticeLevel.ERROR, ) return {} @@ -128,10 +129,11 @@ def get_prompt_version(self, prompt_name: str, label: str) -> Any: return data.get("data", {}) return data except Exception as exc: - logger.error( + logger.notice( "netra.prompts: Failed to fetch prompt version for '%s' (label=%s): %s", prompt_name, label, extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return {} diff --git a/netra/session_manager.py b/netra/session_manager.py index 47060fab..a8080e8b 100644 --- a/netra/session_manager.py +++ b/netra/session_manager.py @@ -1,5 +1,4 @@ import contextvars -import logging import threading import weakref from contextlib import contextmanager @@ -12,9 +11,10 @@ from opentelemetry import trace from netra.config import Config, get_conversation_max_len +from netra.netra_logger import NoticeLevel, get_logger from netra.utils import process_content_for_max_len, serialize_value -logger = logging.getLogger(__name__) +logger = get_logger(__name__) NETRA_USER_INPUT = "netra.user.input" @@ -644,26 +644,27 @@ def add_conversation(cls, conversation_type: ConversationType, role: str, conten # Hard runtime validation of input types and values if not isinstance(conversation_type, ConversationType): - logger.error( - "add_conversation: conversation_type must be a ConversationType enum value (input, output, system)" + logger.notice( + "netra: add_conversation: conversation_type must be a ConversationType enum value (input, output, system)", + level=NoticeLevel.ERROR, ) return normalized_type = conversation_type.value if not isinstance(role, str): - logger.error("add_conversation: role must be a string") + logger.notice("netra: add_conversation: role must be a string", level=NoticeLevel.ERROR) return if not isinstance(content, (str, dict)): - logger.error("add_conversation: content must be a string or dict") + logger.notice("netra: add_conversation: content must be a string or dict", level=NoticeLevel.ERROR) return if not role: - logger.error("add_conversation: role must be a non-empty string") + logger.notice("netra: add_conversation: role must be a non-empty string", level=NoticeLevel.ERROR) return if not content: - logger.error("add_conversation: content must not be empty") + logger.notice("netra: add_conversation: content must not be empty", level=NoticeLevel.ERROR) return try: diff --git a/netra/simulation/api.py b/netra/simulation/api.py index c9f213ff..57bd918f 100644 --- a/netra/simulation/api.py +++ b/netra/simulation/api.py @@ -1,10 +1,10 @@ import asyncio import concurrent.futures -import logging import time from typing import Any, Optional from netra.config import Config +from netra.netra_logger import NoticeLevel, get_logger from netra.simulation.client import SimulationHttpClient from netra.simulation.constants import DEFAULT_MAX_TURNS, LOG_PREFIX, SPAN_NAME from netra.simulation.hooks import ( @@ -26,7 +26,7 @@ ) from netra.span_wrapper import SpanWrapper -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class Simulation: @@ -118,7 +118,7 @@ def run_simulation( items: list[dict[str, str]] = init_result["items"] if not items: - logger.error("%s: No items returned from initialize_run", LOG_PREFIX) + logger.notice("%s: No items returned from initialize_run", LOG_PREFIX, level=NoticeLevel.ERROR) return None # --- Phase 2: Run before_all hook (no LLM cost yet) --- @@ -127,11 +127,12 @@ def run_simulation( try: shared_context = run_async_safely(run_before_all(hooks)) except Exception as exc: - logger.error( + logger.notice( "%s: before_all hook failed: %s โ€” aborting run (no LLM spent)", LOG_PREFIX, exc, exc_info=True, + level=NoticeLevel.ERROR, ) self._report_failures( run_id, @@ -187,12 +188,13 @@ async def _setup_item_async(item: dict[str, str]) -> Optional[SimulationItem]: setup_contexts[run_item_id] = setup_context except Exception as exc: error_msg = f"before hook failed: {exc}" - logger.error( + logger.notice( "%s: %s for run_item_id=%s", LOG_PREFIX, error_msg, run_item_id, exc_info=True, + level=NoticeLevel.ERROR, ) self._client.report_failure( run_id=run_id, @@ -216,7 +218,7 @@ async def _setup_item_async(item: dict[str, str]) -> Optional[SimulationItem]: run_item_id=run_item_id, ) if sim_item is None: - logger.warning( + logger.notice( "%s: Failed to generate first turn for item %s, marking failed", LOG_PREFIX, run_item_id, @@ -248,7 +250,7 @@ async def _run_all_setup() -> list[Optional[SimulationItem]]: simulation_items = [item for item in setup_results if item is not None] if not simulation_items: - logger.error("%s: All items failed during setup/generation", LOG_PREFIX) + logger.notice("%s: All items failed during setup/generation", LOG_PREFIX, level=NoticeLevel.ERROR) results: dict[str, Any] = { "success": False, "completed": [], @@ -260,7 +262,7 @@ async def _run_all_setup() -> list[Optional[SimulationItem]]: except Exception as exc: # All items already failed during setup โ€” do not overwrite status. error_msg = f"after_all hook failed: {exc}" - logger.error("%s: %s", LOG_PREFIX, error_msg, exc_info=True) + logger.notice("%s: %s", LOG_PREFIX, error_msg, exc_info=True, level=NoticeLevel.ERROR) self._client.post_run_status(run_id, "completed") return results @@ -286,7 +288,7 @@ async def _run_all_setup() -> list[Optional[SimulationItem]]: self._client.post_run_status(run_id, "completed") return result except Exception: - logger.error("%s: Run simulation failed", LOG_PREFIX, exc_info=True) + logger.notice("%s: Run simulation failed", LOG_PREFIX, exc_info=True, level=NoticeLevel.ERROR) self._client.post_run_status(run_id, "failed") return None @@ -391,7 +393,7 @@ async def process_item(run_item: SimulationItem) -> None: await run_after_all(hooks, results, shared_context) except Exception as exc: error_msg = f"after_all hook failed: {exc}" - logger.error("%s: %s", LOG_PREFIX, error_msg, exc_info=True) + logger.notice("%s: %s", LOG_PREFIX, error_msg, exc_info=True, level=NoticeLevel.ERROR) # Only mark successfully completed items โ€” do not overwrite real failures. completed = results.get("completed", []) successful_ids = [item["run_item_id"] for item in completed if "run_item_id" in item] @@ -464,12 +466,13 @@ async def _run_after_hooks( await run_after(hooks, dataset_item_id, item_result, setup_context) except Exception as exc: error_msg = f"after hook failed: {exc}" - logger.error( + logger.notice( "%s: %s for run_item_id=%s", LOG_PREFIX, error_msg, run_item_id, exc_info=True, + level=NoticeLevel.ERROR, ) errors.append(error_msg) @@ -477,12 +480,13 @@ async def _run_after_hooks( await run_after_each(hooks, dataset_item_id, item_result, setup_context) except Exception as exc: error_msg = f"after_each hook failed: {exc}" - logger.error( + logger.notice( "%s: %s for run_item_id=%s", LOG_PREFIX, error_msg, run_item_id, exc_info=True, + level=NoticeLevel.ERROR, ) errors.append(error_msg) @@ -595,12 +599,13 @@ async def _execute_conversation( except Exception as exc: error_msg = str(exc) - logger.error( + logger.notice( "%s: Task failed run_item_id=%s, turn_id=%s: %s", LOG_PREFIX, run_item_id, turn_id, error_msg, + level=NoticeLevel.ERROR, ) self._client.report_failure(run_id=run_id, run_item_id=run_item_id, error=error_msg) item_result = { @@ -613,7 +618,7 @@ async def _execute_conversation( return item_result error_msg = f"Exceeded maximum turns ({max_turns}) for run_item_id={run_item_id}" - logger.error("%s: %s", LOG_PREFIX, error_msg) + logger.notice("%s: %s", LOG_PREFIX, error_msg, level=NoticeLevel.ERROR) self._client.report_failure(run_id=run_id, run_item_id=run_item_id, error=error_msg) item_result = { "run_item_id": run_item_id, diff --git a/netra/simulation/client.py b/netra/simulation/client.py index 5f71ae1d..231ee60b 100644 --- a/netra/simulation/client.py +++ b/netra/simulation/client.py @@ -1,9 +1,9 @@ -import logging from typing import Any, Optional import httpx from netra.config import Config +from netra.netra_logger import NoticeLevel, get_logger from netra.simulation.constants import ( DEFAULT_TIMEOUT, ENV_TIMEOUT, @@ -19,7 +19,7 @@ from netra.simulation.utils import parse_env_float from netra.utils import extract_error_message -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class SimulationHttpClient: @@ -56,7 +56,7 @@ def _ensure_client(self) -> Optional[httpx.Client]: The httpx client, or None if not available. """ if not self._client: - logger.error("%s: Client not initialized", LOG_PREFIX) + logger.notice("%s: Client not initialized", LOG_PREFIX, level=NoticeLevel.ERROR) return self._client def _create_client(self, config: Config) -> Optional[httpx.Client]: @@ -80,7 +80,7 @@ def _create_client(self, config: Config) -> Optional[httpx.Client]: try: return httpx.Client(base_url=base_url, headers=headers, timeout=timeout) except Exception as exc: - logger.error("%s: Failed to create HTTP client: %s", LOG_PREFIX, exc) + logger.notice("%s: Failed to create HTTP client: %s", LOG_PREFIX, exc, level=NoticeLevel.ERROR) return None def _resolve_base_url(self, endpoint: str) -> str: @@ -161,7 +161,7 @@ def initialize_run( } except Exception as exc: error_msg = extract_error_message(response, exc) - logger.error("%s: Failed to initialize run: %s", LOG_PREFIX, error_msg) + logger.notice("%s: Failed to initialize run: %s", LOG_PREFIX, error_msg, level=NoticeLevel.ERROR) return None def generate_first_turn( @@ -193,11 +193,12 @@ def generate_first_turn( ) except Exception as exc: error_msg = extract_error_message(response, exc) - logger.error( + logger.notice( "%s: Failed to generate first turn for item %s: %s", LOG_PREFIX, run_item_id, error_msg, + level=NoticeLevel.ERROR, ) return None @@ -261,7 +262,7 @@ def trigger_conversation( except Exception as exc: error_msg = extract_error_message(response, exc) - logger.error("%s: Failed to trigger conversation: %s", LOG_PREFIX, error_msg) + logger.notice("%s: Failed to trigger conversation: %s", LOG_PREFIX, error_msg, level=NoticeLevel.ERROR) raise def report_failure( @@ -297,7 +298,7 @@ def report_failure( logger.info("%s: Reported failure - %s", LOG_PREFIX, error) except Exception as exc: error_msg = extract_error_message(response, exc) - logger.error("%s: Failed to report failure: %s", LOG_PREFIX, error_msg) + logger.notice("%s: Failed to report failure: %s", LOG_PREFIX, error_msg, level=NoticeLevel.ERROR) def post_run_status(self, run_id: str, status: str) -> Any: """Submit the run status. @@ -325,7 +326,9 @@ def post_run_status(self, run_id: str, status: str) -> Any: return data except Exception as exc: error_msg = extract_error_message(response, exc) - logger.error("%s: Failed to post run status for run '%s': %s", LOG_PREFIX, run_id, error_msg) + logger.notice( + "%s: Failed to post run status for run '%s': %s", LOG_PREFIX, run_id, error_msg, level=NoticeLevel.ERROR + ) return {"success": False} @staticmethod diff --git a/netra/simulation/hooks.py b/netra/simulation/hooks.py index ccf82420..137677c1 100644 --- a/netra/simulation/hooks.py +++ b/netra/simulation/hooks.py @@ -32,11 +32,12 @@ """ import asyncio -import logging from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, Optional -logger = logging.getLogger(__name__) +from netra.netra_logger import get_logger + +logger = get_logger(__name__) # Hook function type aliases BeforeAllFn = Callable[[], Any | Awaitable[Any]] @@ -224,7 +225,7 @@ async def run_before_all(hooks: Optional[SimulationHooks]) -> Optional[dict[str, logger.info("netra.simulation: running before_all hook") result = await _call_hook(hooks.before_all) if result is not None and not isinstance(result, dict): - logger.warning( + logger.notice( "netra.simulation: before_all returned %s (expected dict or None); ignoring value", type(result).__name__, ) @@ -263,7 +264,7 @@ async def run_before( if result is not None and isinstance(result, dict): base.update(result) elif result is not None: - logger.warning( + logger.notice( "netra.simulation: before hook returned %s (expected dict or None); ignoring value", type(result).__name__, ) @@ -305,7 +306,7 @@ async def run_before_each( if result is not None and isinstance(result, dict): base.update(result) elif result is not None: - logger.warning( + logger.notice( "netra.simulation: before_each hook returned %s (expected dict or None); ignoring value", type(result).__name__, ) diff --git a/netra/simulation/utils.py b/netra/simulation/utils.py index 49f32dc1..21078081 100644 --- a/netra/simulation/utils.py +++ b/netra/simulation/utils.py @@ -4,13 +4,13 @@ import base64 import concurrent.futures import inspect -import logging import os import threading from typing import Any, Awaitable, Optional, TypeVar import httpx +from netra.netra_logger import NoticeLevel, get_logger from netra.simulation.constants import ( DEFAULT_FILE_DOWNLOAD_TIMEOUT, ENV_FILE_DOWNLOAD_TIMEOUT, @@ -20,7 +20,7 @@ from netra.simulation.models import FileData, ProcessedFile, TaskResult from netra.simulation.task import BaseTask -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _T = TypeVar("_T") @@ -41,7 +41,7 @@ def parse_env_float(env_var: str, default: float) -> float: try: return float(raw) except ValueError: - logger.warning( + logger.notice( "%s: Invalid value '%s' for %s, using default %.1f", LOG_PREFIX, raw, @@ -77,10 +77,10 @@ def validate_simulation_inputs( True if inputs are valid, False otherwise. """ if not dataset_id: - logger.error("netra.simulation: dataset_id is required") + logger.notice("netra.simulation: dataset_id is required", level=NoticeLevel.ERROR) return False if not isinstance(task, BaseTask): - logger.error("netra.simulation: task must be a BaseTask instance") + logger.notice("netra.simulation: task must be a BaseTask instance", level=NoticeLevel.ERROR) return False return True diff --git a/netra/span_wrapper.py b/netra/span_wrapper.py index 0cc8c7e2..79449d20 100644 --- a/netra/span_wrapper.py +++ b/netra/span_wrapper.py @@ -1,5 +1,4 @@ import json -import logging import time from datetime import datetime from enum import Enum @@ -12,9 +11,10 @@ from pydantic import BaseModel from netra.config import Config +from netra.netra_logger import NoticeLevel, get_logger from netra.session_manager import SessionManager -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Baggage key for local-only blocked spans patterns _LOCAL_BLOCKED_SPANS_BAGGAGE_KEY = "netra.local_blocked_spans" @@ -118,7 +118,7 @@ def __init__( self.attributes["netra.span.type"] = as_type.value self._entity_type: Optional[str] = SPAN_TYPE_TO_ENTITY_TYPE.get(as_type) else: - logger.error("Invalid span type: %s", as_type) + logger.notice("netra: Invalid span type: %s", as_type, level=NoticeLevel.ERROR) self._entity_type = None return @@ -289,7 +289,7 @@ def update_span_name(self, new_name: str) -> "SpanWrapper": The span wrapper (for chaining). """ if not isinstance(new_name, str) or not new_name: - logger.warning("update_span_name: new_name must be a non-empty string; ignoring") + logger.notice("netra: update_span_name: new_name must be a non-empty string; ignoring") return self if self.span: diff --git a/netra/tracer.py b/netra/tracer.py index 4da2d7ae..4f02623d 100644 --- a/netra/tracer.py +++ b/netra/tracer.py @@ -1,4 +1,3 @@ -import logging import threading from typing import Any, Dict, Optional, Set @@ -15,11 +14,18 @@ from netra.config import Config from netra.exporters import FilteringSpanExporter, TrialAwareOTLPExporter +from netra.netra_logger import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) _provider_install_lock = threading.Lock() +MISSING_ENDPOINT_NOTICE = ( + "netra: NETRA_OTLP_ENDPOINT is not set, so traces will NOT be sent to Netra (spans are printed to " + "the console instead) and Netra API calls are disabled. Fix: set NETRA_OTLP_ENDPOINT to " + "https://api.getnetra.ai/telemetry (or https://api.eu.getnetra.ai/telemetry for EU projects)." +) + class Tracer: """ @@ -73,7 +79,7 @@ def _setup_tracer(self) -> None: return if not self.cfg.otlp_endpoint: - logger.warning("OTLP endpoint not provided, falling back to console exporter") + logger.notice(MISSING_ENDPOINT_NOTICE) exporter = ConsoleSpanExporter() else: otlp_exporter = OTLPSpanExporter( diff --git a/netra/usage/api.py b/netra/usage/api.py index 0b2cf805..c0b41140 100644 --- a/netra/usage/api.py +++ b/netra/usage/api.py @@ -1,11 +1,11 @@ -import logging from typing import Any, Iterator, Literal, Optional from netra.config import Config +from netra.netra_logger import NoticeLevel, get_logger from netra.usage.client import UsageHttpClient from netra.usage.models import SessionUsageData, SpansPage, TenantUsageData, TracesPage, TraceSpan, TraceSummary -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class Usage: @@ -39,10 +39,12 @@ def get_session_usage( SessionUsageData: Session usage data """ if not session_id: - logger.error("netra.usage: session_id is required to fetch session usage") + logger.notice("netra.usage: session_id is required to fetch session usage", level=NoticeLevel.ERROR) return None if not start_time or not end_time: - logger.error("netra.usage: start_time and end_time are required to fetch session usage") + logger.notice( + "netra.usage: start_time and end_time are required to fetch session usage", level=NoticeLevel.ERROR + ) return None result = self._client.get_session_usage(session_id, start_time=start_time, end_time=end_time) session_id = result.get("session_id", "") @@ -77,10 +79,12 @@ def get_tenant_usage( TenantUsageData: Tenant usage data """ if not tenant_id: - logger.error("netra.usage: tenant_id is required to fetch tenant usage") + logger.notice("netra.usage: tenant_id is required to fetch tenant usage", level=NoticeLevel.ERROR) return None if not start_time or not end_time: - logger.error("netra.usage: start_time and end_time are required to fetch tenant usage") + logger.notice( + "netra.usage: start_time and end_time are required to fetch tenant usage", level=NoticeLevel.ERROR + ) return None result = self._client.get_tenant_usage(tenant_id, start_time=start_time, end_time=end_time) tenant_id = result.get("tenant_id", "") @@ -134,7 +138,7 @@ def list_traces( TracesPage: Traces page """ if not start_time or not end_time: - logger.error("netra.usage: start_time and end_time are required to list traces") + logger.notice("netra.usage: start_time and end_time are required to list traces", level=NoticeLevel.ERROR) return None result = self._client.list_traces( @@ -189,7 +193,7 @@ def list_spans_by_trace_id( SpansPage: Spans page """ if not trace_id: - logger.error("netra.usage: trace_id is required to list spans") + logger.notice("netra.usage: trace_id is required to list spans", level=NoticeLevel.ERROR) return None result = self._client.list_spans_by_trace_id( @@ -253,7 +257,9 @@ def iter_traces( Iterator[TraceSummary]: Iterator over TraceSummary items """ if not start_time or not end_time: - logger.error("netra.usage: start_time and end_time are required to iterate traces") + logger.notice( + "netra.usage: start_time and end_time are required to iterate traces", level=NoticeLevel.ERROR + ) return current_cursor = cursor @@ -311,7 +317,7 @@ def iter_spans_by_trace_id( """ if not trace_id: - logger.error("netra.usage: trace_id is required to iterate spans") + logger.notice("netra.usage: trace_id is required to iterate spans", level=NoticeLevel.ERROR) return current_cursor = cursor diff --git a/netra/usage/client.py b/netra/usage/client.py index 5d3e5dfa..7e0b129e 100644 --- a/netra/usage/client.py +++ b/netra/usage/client.py @@ -1,13 +1,13 @@ -import logging import os from typing import Any, Dict, Optional import httpx from netra.config import Config +from netra.netra_logger import NoticeLevel, get_logger from netra.utils import extract_error_message -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class UsageHttpClient: @@ -35,7 +35,7 @@ def _create_client(self, config: Config) -> Optional[httpx.Client]: try: return httpx.Client(base_url=base_url, headers=headers, timeout=timeout) except Exception as exc: - logger.error("netra.usage: Failed to initialize usage HTTP client: %s", exc) + logger.notice("netra.usage: Failed to initialize usage HTTP client: %s", exc, level=NoticeLevel.ERROR) return None def _resolve_base_url(self, endpoint: str) -> str: @@ -58,7 +58,7 @@ def _get_timeout(self) -> float: try: return float(timeout_env) except ValueError: - logger.warning( + logger.notice( "netra.usage: Invalid NETRA_USAGE_TIMEOUT value '%s', using default 10.0", timeout_env, ) @@ -75,9 +75,10 @@ def get_session_usage(self, session_id: str, start_time: str | None = None, end_ Any: Session usage data """ if not self._client: - logger.error( + logger.notice( "netra.usage: Usage client is not initialized; cannot fetch session usage '%s'", session_id, + level=NoticeLevel.ERROR, ) return {} @@ -96,10 +97,11 @@ def get_session_usage(self, session_id: str, start_time: str | None = None, end_ return data.get("data", {}) return data except Exception as exc: - logger.error( + logger.notice( "netra.usage: Failed to fetch session usage '%s': %s", session_id, extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return {} @@ -114,9 +116,10 @@ def get_tenant_usage(self, tenant_id: str, start_time: str | None = None, end_ti Any: Tenant usage data """ if not self._client: - logger.error( + logger.notice( "netra.usage: Usage client is not initialized; cannot fetch tenant usage '%s'", tenant_id, + level=NoticeLevel.ERROR, ) return {} @@ -135,10 +138,11 @@ def get_tenant_usage(self, tenant_id: str, start_time: str | None = None, end_ti return data.get("data", {}) return data except Exception as exc: - logger.error( + logger.notice( "netra.usage: Failed to fetch tenant usage '%s': %s", tenant_id, extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return {} @@ -176,7 +180,7 @@ def list_traces( Any: Traces data """ if not self._client: - logger.error("netra.usage: Usage client is not initialized; cannot list traces") + logger.notice("netra.usage: Usage client is not initialized; cannot list traces", level=NoticeLevel.ERROR) return {} response: Optional[httpx.Response] = None @@ -222,7 +226,9 @@ def list_traces( data = response.json() return data except Exception as exc: - logger.error("netra.usage: Failed to list traces: %s", extract_error_message(response, exc)) + logger.notice( + "netra.usage: Failed to list traces: %s", extract_error_message(response, exc), level=NoticeLevel.ERROR + ) return {} def list_spans_by_trace_id( @@ -247,7 +253,11 @@ def list_spans_by_trace_id( Any: Spans data """ if not self._client: - logger.error("netra.usage: Usage client is not initialized; cannot list spans for trace '%s'", trace_id) + logger.notice( + "netra.usage: Usage client is not initialized; cannot list spans for trace '%s'", + trace_id, + level=NoticeLevel.ERROR, + ) return {} response: Optional[httpx.Response] = None @@ -268,9 +278,10 @@ def list_spans_by_trace_id( data = response.json() return data except Exception as exc: - logger.error( + logger.notice( "netra.usage: Failed to list spans for trace '%s': %s", trace_id, extract_error_message(response, exc), + level=NoticeLevel.ERROR, ) return {} diff --git a/netra/utils.py b/netra/utils.py index f03ac0fc..16ced8e7 100644 --- a/netra/utils.py +++ b/netra/utils.py @@ -5,7 +5,6 @@ from __future__ import annotations -import logging from typing import AbstractSet, Any, Optional, Set import httpx @@ -17,8 +16,9 @@ InstrumentSet, NetraInstruments, ) +from netra.netra_logger import NoticeLevel, get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) def extract_error_message(response: Optional[httpx.Response], exc: Exception) -> str: @@ -166,9 +166,10 @@ def resolve_root_instruments( if block_has_all: if root_has_all: - logger.error( - "root_instruments=ALL is contradicted by " - "block_instruments=ALL; all root instrumentation is disabled." + logger.notice( + "netra: root_instruments=ALL is contradicted by " + "block_instruments=ALL; all root instrumentation is disabled.", + level=NoticeLevel.ERROR, ) else: logger.warning("block_instruments contains ALL; all instrumentation will be disabled.") diff --git a/tests/test_netra_init.py b/tests/test_netra_init.py index d1850bcf..c783232b 100644 --- a/tests/test_netra_init.py +++ b/tests/test_netra_init.py @@ -132,7 +132,7 @@ def test_init_with_custom_parameters( def test_init_called_multiple_times_logs_warning( self, mock_logger: MagicMock, mock_tracer: MagicMock, mock_init_instrumentations: MagicMock ) -> None: - """Test that calling init multiple times logs a warning and doesn't reinitialize.""" + """Test that calling init multiple times logs an always-visible warning and doesn't reinitialize.""" # First initialization Netra.init() @@ -143,7 +143,7 @@ def test_init_called_multiple_times_logs_warning( # Second initialization should log warning and not reinitialize Netra.init() - mock_logger.warning.assert_called_once_with("Netra.init() called more than once; ignoring subsequent calls.") + mock_logger.notice.assert_called_once_with("Netra.init() called more than once; ignoring subsequent calls.") # Tracer and instrumentations should not be called again mock_tracer.assert_not_called() diff --git a/tests/test_netra_logger.py b/tests/test_netra_logger.py new file mode 100644 index 00000000..6afbf5fe --- /dev/null +++ b/tests/test_netra_logger.py @@ -0,0 +1,459 @@ +"""NetraLogger: level methods follow debug_mode, notice() is always visible.""" + +import logging +import os +import subprocess +import sys +from typing import Any, Callable, Generator, Optional, cast +from unittest.mock import MagicMock + +import pytest +import requests +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.trace import ReadableSpan, TracerProvider +from opentelemetry.sdk.trace.export import SpanExportResult + +from netra import Netra +from netra.config import Config +from netra.evaluation.api import Evaluation +from netra.evaluation.client import EvaluationHttpClient +from netra.evaluation.models import GetDatasetItemsResponse +from netra.exporters.trial_aware_otlp_exporter import TrialAwareOTLPExporter +from netra.logging_utils import configure_package_logging +from netra.netra_logger import NOTICE_LOGGER_NAME, NoticeLevel, get_logger + +ENDPOINT_ENV_VARS = ("NETRA_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_ENDPOINT", "NETRA_DEBUG", "NETRA_API_KEY") +MISSING_ENDPOINT_TEXT = "NETRA_OTLP_ENDPOINT is not set" +# Port 9 (discard) is closed locally; nothing in these scripts exports, so it is never dialled. +UNUSED_LOCAL_ENDPOINT = "http://127.0.0.1:9/telemetry" +MODULE_LOGGER_NAME = "netra.tests.module" +OTEL_TRACE_EXPORTER_LOGGER_NAME = "opentelemetry.exporter.otlp.proto.http.trace_exporter" + + +@pytest.fixture(autouse=True) +def restore_netra_logging() -> Generator[None, None, None]: + pkg_logger = logging.getLogger("netra") + saved = (list(pkg_logger.handlers), pkg_logger.level, pkg_logger.propagate) + yield + pkg_logger.handlers[:] = saved[0] + pkg_logger.setLevel(saved[1]) + pkg_logger.propagate = saved[2] + + +def _notices(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecord]: + return [record for record in caplog.records if record.name == NOTICE_LOGGER_NAME] + + +def _log_one_of_each(logger_name: str) -> None: + logger = get_logger(logger_name) + logger.debug("debug line") + logger.info("info line") + logger.warning("warning line") + logger.error("error line") + try: + raise RuntimeError("boom") + except RuntimeError: + logger.exception("exception line") + logger.log(logging.WARNING, "log line") + + +def _run_init_script(extra_env: dict[str, str]) -> subprocess.CompletedProcess[str]: + """Run Netra.init() with debug_mode off in a fresh interpreter with no logging configured.""" + env = {key: value for key, value in os.environ.items() if key not in ENDPOINT_ENV_VARS} + env.update(extra_env) + script = "from netra import Netra\nNetra.init(app_name='notice-test', debug_mode=False)\nNetra.shutdown()\n" + return subprocess.run( + [sys.executable, "-c", script], env=env, capture_output=True, text=True, timeout=120, check=True + ) + + +class TestLevelMethods: + def test_every_level_method_is_silent_when_debug_mode_off(self, caplog: pytest.LogCaptureFixture) -> None: + configure_package_logging(debug_mode=False) + + with caplog.at_level(logging.DEBUG): + _log_one_of_each(MODULE_LOGGER_NAME) + + assert caplog.records == [] + + def test_every_level_method_is_visible_when_debug_mode_on(self, capsys: pytest.CaptureFixture[str]) -> None: + configure_package_logging(debug_mode=True) + + _log_one_of_each(MODULE_LOGGER_NAME) + + err = capsys.readouterr().err + for level, text in [ + ("DEBUG", "debug line"), + ("INFO", "info line"), + ("WARNING", "warning line"), + ("ERROR", "error line"), + ("ERROR", "exception line"), + ("WARNING", "log line"), + ]: + assert f"| {level} | {MODULE_LOGGER_NAME} | {text}" in err + assert "RuntimeError: boom" in err # exception() attaches the traceback + + def test_record_points_at_the_caller_not_the_wrapper(self, capsys: pytest.CaptureFixture[str]) -> None: + configure_package_logging(debug_mode=True) + pkg_logger = logging.getLogger("netra") + pkg_logger.handlers[0].setFormatter(logging.Formatter("%(filename)s:%(funcName)s:%(message)s")) + + get_logger(MODULE_LOGGER_NAME).warning("where am I") + get_logger(MODULE_LOGGER_NAME).notice("where is the notice") + + err = capsys.readouterr().err + assert "test_netra_logger.py:test_record_points_at_the_caller_not_the_wrapper:where am I" in err + assert "test_netra_logger.py:test_record_points_at_the_caller_not_the_wrapper:where is the notice" in err + + def test_name_is_the_module_name(self) -> None: + assert get_logger(MODULE_LOGGER_NAME).name == MODULE_LOGGER_NAME + + +class TestNotice: + def test_reaches_root_logger_when_debug_mode_off(self, caplog: pytest.LogCaptureFixture) -> None: + configure_package_logging(debug_mode=False) + + with caplog.at_level(logging.DEBUG): + get_logger(MODULE_LOGGER_NAME).notice("netra: something to fix") + + assert [(r.levelno, r.getMessage()) for r in _notices(caplog)] == [(logging.WARNING, "netra: something to fix")] + + def test_error_level_is_kept(self, caplog: pytest.LogCaptureFixture) -> None: + configure_package_logging(debug_mode=False) + + with caplog.at_level(logging.DEBUG): + get_logger(MODULE_LOGGER_NAME).notice("netra: call failed: %s", "401", level=NoticeLevel.ERROR) + + assert [(r.levelno, r.getMessage()) for r in _notices(caplog)] == [(logging.ERROR, "netra: call failed: 401")] + + def test_every_repeat_is_logged(self, caplog: pytest.LogCaptureFixture) -> None: + configure_package_logging(debug_mode=False) + logger = get_logger(MODULE_LOGGER_NAME) + + with caplog.at_level(logging.DEBUG): + for _ in range(3): + logger.notice("netra: same problem") + + assert [r.getMessage() for r in _notices(caplog)] == ["netra: same problem"] * 3 + + def test_visible_in_debug_mode(self, capsys: pytest.CaptureFixture[str]) -> None: + configure_package_logging(debug_mode=True) + + get_logger(MODULE_LOGGER_NAME).notice("netra: something to fix") + + assert f"| WARNING | {NOTICE_LOGGER_NAME} | netra: something to fix" in capsys.readouterr().err + + def test_application_can_silence_the_notice_logger(self, caplog: pytest.LogCaptureFixture) -> None: + configure_package_logging(debug_mode=False) + notice_logger = logging.getLogger(NOTICE_LOGGER_NAME) + saved_level = notice_logger.level + notice_logger.setLevel(logging.CRITICAL) + try: + with caplog.at_level(logging.DEBUG): + get_logger(MODULE_LOGGER_NAME).notice("netra: muted", level=NoticeLevel.ERROR) + finally: + notice_logger.setLevel(saved_level) + + assert _notices(caplog) == [] + + +class TestMissingEndpointNotice: + def test_printed_to_stderr_when_debug_mode_off_and_logging_unconfigured(self) -> None: + result = _run_init_script({}) + + assert MISSING_ENDPOINT_TEXT in result.stderr + assert "Fix: set NETRA_OTLP_ENDPOINT" in result.stderr + + def test_not_printed_when_endpoint_is_set(self) -> None: + result = _run_init_script({"NETRA_OTLP_ENDPOINT": UNUSED_LOCAL_ENDPOINT}) + + assert MISSING_ENDPOINT_TEXT not in result.stderr + + +class TestMissingApiKeyNotice: + def test_logged_as_error_for_netra_endpoint_without_key( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("NETRA_OTLP_ENDPOINT", "https://api.getnetra.ai/telemetry") + monkeypatch.delenv("NETRA_API_KEY", raising=False) + + with caplog.at_level(logging.DEBUG): + Config(app_name="notice-test") + + api_key_notices = [r for r in _notices(caplog) if "NETRA_API_KEY is not set" in r.getMessage()] + assert [r.levelno for r in api_key_notices] == [logging.ERROR] + + @pytest.mark.parametrize("header_name", ["x-api-key", "X-Api-Key"]) + def test_not_logged_when_key_is_passed_in_headers_argument( + self, header_name: str, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("NETRA_OTLP_ENDPOINT", "https://api.getnetra.ai/telemetry") + monkeypatch.delenv("NETRA_API_KEY", raising=False) + monkeypatch.delenv("NETRA_HEADERS", raising=False) + + with caplog.at_level(logging.DEBUG): + Config(app_name="notice-test", headers={header_name: "test-key"}) + + assert [r for r in _notices(caplog) if "NETRA_API_KEY is not set" in r.getMessage()] == [] + + def test_not_logged_when_key_is_passed_in_netra_headers_env( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("NETRA_OTLP_ENDPOINT", "https://api.getnetra.ai/telemetry") + monkeypatch.delenv("NETRA_API_KEY", raising=False) + monkeypatch.setenv("NETRA_HEADERS", "x-api-key=test-key") + + with caplog.at_level(logging.DEBUG): + Config(app_name="notice-test") + + assert [r for r in _notices(caplog) if "NETRA_API_KEY is not set" in r.getMessage()] == [] + + def test_logged_when_headers_carry_only_an_authorization_header( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + # The Netra backend authenticates with x-api-key only; a bearer token is not a Netra credential. + monkeypatch.setenv("NETRA_OTLP_ENDPOINT", "https://api.getnetra.ai/telemetry") + monkeypatch.delenv("NETRA_API_KEY", raising=False) + monkeypatch.delenv("NETRA_HEADERS", raising=False) + + with caplog.at_level(logging.DEBUG): + Config(app_name="notice-test", headers={"Authorization": "Bearer test-key"}) + + api_key_notices = [r for r in _notices(caplog) if "NETRA_API_KEY is not set" in r.getMessage()] + assert [r.levelno for r in api_key_notices] == [logging.ERROR] + + +def _fake_response(status_code: int, reason: str, message: str) -> MagicMock: + response = MagicMock() + response.status_code = status_code + response.reason = reason + response.json.return_value = {"error": {"message": message}} + response.request.body = b"0123456789" + return response + + +class TestRejectedSpanExportNotice: + def _exporter_answering(self, responses: list[Optional[Any]]) -> TrialAwareOTLPExporter: + wrapped = MagicMock() + wrapped._session = None # skip response interception; the test sets last_response itself + exporter = TrialAwareOTLPExporter(wrapped) + pending = iter(responses) + + def export(_spans: Any) -> SpanExportResult: + exporter._export_state.last_response = next(pending) + return SpanExportResult.FAILURE + + wrapped.export.side_effect = export + return exporter + + def test_every_rejected_batch_is_visible_when_debug_mode_off(self, caplog: pytest.LogCaptureFixture) -> None: + configure_package_logging(debug_mode=False) + bad_key = _fake_response(401, "Unauthorized", "Invalid API key") + too_large = _fake_response(413, "Payload Too Large", "Request body too large") + exporter = self._exporter_answering([bad_key, bad_key, too_large]) + + with caplog.at_level(logging.DEBUG): + for _ in range(3): + exporter.export([MagicMock()]) + + bad_key_line = ( + 'netra: Span export failed with HTTP 401 Unauthorized: "Invalid API key". ' + "Dropped a batch of 1 spans (10 bytes)." + ) + assert [r.getMessage() for r in _notices(caplog)] == [ + bad_key_line, + bad_key_line, + 'netra: Span export failed with HTTP 413 Payload Too Large: "Request body too large". ' + "Dropped a batch of 1 spans (10 bytes). Lower OTEL_BSP_MAX_EXPORT_BATCH_SIZE or reduce large " + "span attributes such as prompts and completions.", + ] + + def test_no_rejection_notice_when_export_failed_without_a_response(self, caplog: pytest.LogCaptureFixture) -> None: + configure_package_logging(debug_mode=False) + exporter = self._exporter_answering([None]) + + with caplog.at_level(logging.DEBUG): + exporter.export([MagicMock()]) + + assert _notices(caplog) == [] + + def test_unreachable_endpoint_is_visible_when_debug_mode_off(self, caplog: pytest.LogCaptureFixture) -> None: + configure_package_logging(debug_mode=False) + wrapped = MagicMock() + wrapped._session = None + wrapped.export.side_effect = ConnectionError("Connection refused") + exporter = TrialAwareOTLPExporter(wrapped) + + with caplog.at_level(logging.DEBUG): + result = exporter.export([MagicMock(), MagicMock()]) + + assert result == SpanExportResult.FAILURE + assert [(r.levelno, r.getMessage()) for r in _notices(caplog)] == [ + (logging.WARNING, "netra: Span export failed: Connection refused. Dropped a batch of 2 spans.") + ] + + +def _otlp_exporter_over(fake_request: Callable[..., requests.Response]) -> TrialAwareOTLPExporter: + """A real OTLPSpanExporter whose HTTP session is replaced by ``fake_request`` (no network).""" + otlp_exporter = OTLPSpanExporter(endpoint=UNUSED_LOCAL_ENDPOINT) + # Swapped before TrialAwareOTLPExporter wraps session.request, so its interceptor sees the fake. + otlp_exporter._session.request = fake_request + return TrialAwareOTLPExporter(otlp_exporter) + + +def _finished_span() -> ReadableSpan: + span = TracerProvider().get_tracer("notice-test").start_span("notice-test-span") + span.end() + return cast(ReadableSpan, span) + + +def _visible_records(caplog: pytest.LogCaptureFixture) -> list[tuple[str, str]]: + return [(r.name, r.getMessage()) for r in caplog.records if r.levelno >= logging.WARNING] + + +class TestSpanExportFailureReportedOnce: + """Each failed batch yields exactly one visible line: Netra's notice, not also OpenTelemetry's error.""" + + def test_rejected_batch_is_reported_once(self, caplog: pytest.LogCaptureFixture) -> None: + configure_package_logging(debug_mode=False) + + def reject_with_401(method: str, url: str, **kwargs: Any) -> requests.Response: + response = requests.Response() + response.status_code = 401 + response.reason = "Unauthorized" + response._content = b'{"error": {"message": "Invalid API key"}}' + response.request = requests.Request(method, url, data=kwargs.get("data")).prepare() + return response + + exporter = _otlp_exporter_over(reject_with_401) + + with caplog.at_level(logging.DEBUG): + result = exporter.export([_finished_span()]) + + assert result == SpanExportResult.FAILURE + assert len(_visible_records(caplog)) == 1 + assert _visible_records(caplog)[0][0] == NOTICE_LOGGER_NAME + assert _visible_records(caplog)[0][1].startswith( + 'netra: Span export failed with HTTP 401 Unauthorized: "Invalid API key". Dropped a batch of 1 spans' + ) + + def test_batch_without_a_response_is_reported_once(self, caplog: pytest.LogCaptureFixture) -> None: + """OpenTelemetry raises a request error on some versions and logs it on others; one notice either way.""" + configure_package_logging(debug_mode=False) + + def time_out(method: str, url: str, **kwargs: Any) -> requests.Response: + raise requests.exceptions.ReadTimeout("read timed out") + + exporter = _otlp_exporter_over(time_out) + + with caplog.at_level(logging.DEBUG): + result = exporter.export([_finished_span()]) + + assert result == SpanExportResult.FAILURE + assert _visible_records(caplog) == [ + (NOTICE_LOGGER_NAME, "netra: Span export failed: read timed out. Dropped a batch of 1 spans.") + ] + + def test_opentelemetry_errors_outside_netra_exports_are_kept(self, caplog: pytest.LogCaptureFixture) -> None: + configure_package_logging(debug_mode=False) + _otlp_exporter_over(MagicMock()) # installs Netra's filter on the OpenTelemetry exporter logger + + with caplog.at_level(logging.DEBUG): + logging.getLogger(OTEL_TRACE_EXPORTER_LOGGER_NAME).error("app-owned exporter failed") + + assert _visible_records(caplog) == [(OTEL_TRACE_EXPORTER_LOGGER_NAME, "app-owned exporter failed")] + + +class TestPublicApiMisuseNotice: + @pytest.mark.parametrize( + ("call", "expected_level", "expected_text"), + [ + ( + lambda: Netra.set_session_id(123), # type: ignore[arg-type] + logging.ERROR, + "set_session_id: session_id must be a string", + ), + ( + lambda: Netra.set_user_id(None), # type: ignore[arg-type] + logging.ERROR, + "set_user_id: user_id must be a string", + ), + (lambda: Netra.set_tenant_id(""), logging.WARNING, "set_tenant_id: Tenant ID must be provided"), + (lambda: Netra.set_custom_attributes("", None), logging.WARNING, "Both key and value must be provided"), + ], + ) + def test_invalid_argument_is_visible_when_debug_mode_off( + self, call: Any, expected_level: int, expected_text: str, caplog: pytest.LogCaptureFixture + ) -> None: + configure_package_logging(debug_mode=False) + + with caplog.at_level(logging.DEBUG): + call() + + assert [(r.levelno, expected_text in r.getMessage()) for r in _notices(caplog)] == [(expected_level, True)] + + +class TestSkippedDatasetItemNotice: + """get_dataset() drops items it cannot use; each drop is a notice, naming the item but not its payload.""" + + def _get_dataset_with_items( + self, items: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch + ) -> GetDatasetItemsResponse: + monkeypatch.setattr(EvaluationHttpClient, "get_dataset", lambda _self, _dataset_id: items) + return cast(GetDatasetItemsResponse, Evaluation(Config(app_name="notice-test")).get_dataset("dataset-1")) + + def test_item_missing_required_fields_is_visible_when_debug_mode_off( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + configure_package_logging(debug_mode=False) + items = [ + {"id": "item-1", "datasetId": "dataset-1", "input": "kept"}, + {"id": "item-2", "datasetId": "dataset-1"}, + {"input": "secret prompt text"}, + ] + + with caplog.at_level(logging.DEBUG): + result = self._get_dataset_with_items(items, monkeypatch) + + assert [record.id for record in result.items] == ["item-1"] + assert [(r.levelno, r.getMessage()) for r in _notices(caplog)] == [ + (logging.WARNING, "netra.evaluation: Skipping dataset item item-2: missing required field(s): input"), + ( + logging.WARNING, + "netra.evaluation: Skipping dataset item (no id): missing required field(s): id, datasetId", + ), + ] + + def test_item_failing_validation_is_visible_when_debug_mode_off( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + configure_package_logging(debug_mode=False) + items = [{"id": 42, "datasetId": "dataset-1", "input": "secret prompt text"}] + + with caplog.at_level(logging.DEBUG): + result = self._get_dataset_with_items(items, monkeypatch) + + assert result.items == [] + assert [ + (r.levelno, r.getMessage().startswith("netra.evaluation: Skipping dataset item 42:")) + for r in _notices(caplog) + ] == [(logging.ERROR, True)] + assert "secret prompt text" not in _notices(caplog)[0].getMessage() + + +class TestApiClientFailureNotice: + def test_call_on_unconfigured_client_is_visible_when_debug_mode_off( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + for name in ENDPOINT_ENV_VARS: + monkeypatch.delenv(name, raising=False) + configure_package_logging(debug_mode=False) + client = EvaluationHttpClient(Config(app_name="notice-test")) + + with caplog.at_level(logging.DEBUG): + result = client.create_dataset("my-dataset") + + assert result is None + assert [(r.levelno, r.getMessage()) for r in _notices(caplog)] == [ + (logging.ERROR, "netra.evaluation: Evaluation client is not initialized; cannot create dataset") + ] From 904de6d2f30d35aad5598cf7469b82b1f16cb11e Mon Sep 17 00:00:00 2001 From: akash-vijay-kv Date: Wed, 4 Nov 2026 17:55:38 +0530 Subject: [PATCH 2/2] chore: Update changelog for version 1.2.5, enhance logging details, and fix API key error handling --- CHANGELOG.md | 14 +++++++++++++- README.md | 37 +++++++++++++++++++++++++++++++++---- netra/version.py | 2 +- pyproject.toml | 2 +- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eda395d..14a2a02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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. +## [1.2.5] - 2026-11-04 + +### Changed + +- **Problems you have to fix are now logged outside debug mode** - Outside `debug_mode` the `netra` logger is set to `CRITICAL`, so until now every SDK message was hidden, including the ones that say the app is misconfigured. Those messages now go to a separate `netra.notice` logger at `WARNING` or `ERROR` and are logged whether or not `debug_mode` is on. They cover: `NETRA_OTLP_ENDPOINT` not set (spans fall back to the console exporter and Netra API calls are disabled), `NETRA_API_KEY` missing for a Netra endpoint, span batches that were dropped, out-of-range `NETRA_AUDIO_*` settings, `root_instruments=ALL` contradicted by `block_instruments=ALL`, `Netra.init()` called twice, `Netra.get_meter()` called before `init()`, invalid arguments to public methods (`set_session_id`, `set_user_id`, `set_tenant_id`, `update_span_name`, `set_custom_attributes`, `set_custom_event`, `record_exception`, and the dashboard, evaluation, simulation, usage, prompts and models clients), and failed calls to the Netra APIs. Notices propagate to the root logger, so they go through the application's own logging setup. If the application has configured no logging, Python prints them to stderr. To silence them, use `logging.getLogger("netra.notice").setLevel(logging.CRITICAL)`. Everything else the SDK logs is still shown only in debug mode. + +### Fixed + +- **Missing API key error is logged, and no longer shown when the key is sent as a header** - The missing `NETRA_API_KEY` error was written to stdout with `print()`, so it could not be routed or silenced. It is now an `ERROR` notice. It was also shown when the key was already passed as an `x-api-key` header through `headers` or `NETRA_HEADERS`. Such a header now counts as a key; the header name is matched case-insensitively. + +- **Each dropped span batch is reported once, including batches that got no response** - A batch that got no HTTP response (connection refused, DNS, TLS, timeout) was left to `OTLPSpanExporter` to log. Depending on the `opentelemetry-exporter-otlp-proto-http` version, it logged its own error or let the exception propagate. In debug mode, a rejected batch was also reported twice: once by OpenTelemetry and once by Netra. `TrialAwareOTLPExporter` now logs one notice per dropped batch, after `OTLPSpanExporter` has finished retrying. The notice gives the backend's final response or the request error (`Span export failed: . Dropped a batch of N spans.`). While Netra is exporting, `OTLPSpanExporter`'s own `ERROR` record for that batch is filtered out. Its retry warnings are kept, and errors from exporters the application runs itself are still logged. + ## [1.2.4] - 2026-11-02 ### Fixed @@ -572,4 +584,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 -[1.2.4]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main +[1.2.5]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main diff --git a/README.md b/README.md index 92215174..9ee1a708 100644 --- a/README.md +++ b/README.md @@ -31,20 +31,34 @@ poetry add netra-sdk ## ๐Ÿš€ Quick Start +### Set the Endpoint and API Key + +Set these before you call `Netra.init()`, wherever your app runs (your shell, Docker containers, CI jobs). Without `NETRA_OTLP_ENDPOINT`, the SDK prints spans to the console instead of sending them to Netra, and your dashboard stays empty. + +```bash +# US region +export NETRA_OTLP_ENDPOINT="https://api.getnetra.ai/telemetry" +# EU region: export NETRA_OTLP_ENDPOINT="https://api.eu.getnetra.ai/telemetry" +export NETRA_API_KEY="your-api-key-here" +``` + +If the endpoint is not set, the SDK logs a warning at startup that names `NETRA_OTLP_ENDPOINT`. See [Logging](#-logging). + ### Basic Setup Initialize the Netra SDK at the start of your application: ```python +import os + from netra import Netra from netra.instrumentation.instruments import InstrumentSet -# Initialize with default settings +# Initialize with default settings (reads NETRA_OTLP_ENDPOINT and NETRA_API_KEY from the environment) Netra.init(app_name="Your application name", instruments={InstrumentSet.OPENAI, InstrumentSet.ANTHROPIC}) # Or with custom configuration -api_key = "Your API key" -headers = f"x-api-key={api_key}" +headers = f"x-api-key={os.environ['NETRA_API_KEY']}" Netra.init( app_name="Your application name", headers=headers, @@ -405,8 +419,9 @@ Netra SDK can be configured using the following environment variables: | Variable Name | Description | Default | |---------------|-------------|---------| | `NETRA_APP_NAME` | Logical name for your service | Falls back to `OTEL_SERVICE_NAME` or `llm_tracing_service` | -| `NETRA_OTLP_ENDPOINT` | URL for OTLP collector | Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT` | +| `NETRA_OTLP_ENDPOINT` | URL for OTLP collector. Required to send traces to Netra: `https://api.getnetra.ai/telemetry` (US) or `https://api.eu.getnetra.ai/telemetry` (EU) | Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`; if neither is set, spans are printed to the console | | `NETRA_API_KEY` | API key for authentication | `None` | +| `NETRA_DEBUG` | Enable debug logging (`true`/`false`) | `false` | | `NETRA_HEADERS` | Additional headers in W3C Correlation-Context format | `None` | | `NETRA_DISABLE_BATCH` | Disable batch span processor (`true`/`false`) | `false` | | `NETRA_TRACE_CONTENT` | Whether to capture prompt/completion content (`true`/`false`) | `true` | @@ -438,6 +453,20 @@ This allows you to: - **๐Ÿš€ Future Proof**: Built on industry-standard protocols - **๐Ÿ“ˆ Rich Ecosystem**: Leverage the entire OpenTelemetry ecosystem +## ๐Ÿ“ Logging + +Outside debug mode the SDK keeps its internal logs quiet. Problems you have to fix are still logged, at `WARNING` or `ERROR`, through the `netra.notice` logger. They include a missing `NETRA_OTLP_ENDPOINT` or `NETRA_API_KEY`, span batches the backend rejected or that got no response, invalid arguments to public methods, and failed Netra API calls. + +Notices propagate to the root logger, so they go through your application's logging setup. If your application configures no logging, Python prints them to stderr. To silence them: + +```python +import logging + +logging.getLogger("netra.notice").setLevel(logging.CRITICAL) +``` + +To see the SDK's full internal logs, pass `debug_mode=True` to `Netra.init()` or set `NETRA_DEBUG=true`. + ## ๐Ÿงช Tests diff --git a/netra/version.py b/netra/version.py index b3f9ac7f..b7e19904 100644 --- a/netra/version.py +++ b/netra/version.py @@ -1 +1 @@ -__version__ = "1.2.4" +__version__ = "1.2.5" diff --git a/pyproject.toml b/pyproject.toml index b86eb261..e7bda9c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "netra-sdk" -version = "1.2.4" +version = "1.2.5" 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"}