Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <error>. 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
Expand Down Expand Up @@ -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
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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

Expand Down
35 changes: 20 additions & 15 deletions netra/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import atexit
import logging
import threading
from typing import AbstractSet, Any, Dict, List, Optional

Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 26 additions & 16 deletions netra/config.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -190,15 +193,15 @@ 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,
)
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,
Expand Down Expand Up @@ -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."
)
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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]:
Expand Down
Loading
Loading