Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
76a6827
[NET-920] feat: Add utility to explicitly set exceptions on a span (#…
akash-vijay-kv Jun 16, 2026
5d4e606
[NET-995] feat: Add utility for adding streaming output on root span …
akash-vijay-kv Jun 16, 2026
08126f1
[NET-920] fix: Add missing escaped param in record_exception() (#322)
akash-vijay-kv Jul 6, 2026
78031f9
[NET-1313] fix: Add support for dataset type in create-dataset utilit…
akash-vijay-kv Jul 10, 2026
a5f4bee
feat: add opt-in TTL caching for get_prompt
AkhileshNair2201 Jul 9, 2026
e3230f8
update doc
AkhileshNair2201 Jul 9, 2026
6b3cc18
remove 0.1.95 changelog
AkhileshNair2201 Jul 10, 2026
596061f
fix: address PR review feedback for get_prompt caching
AkhileshNair2201 Jul 10, 2026
42fa59d
fix
AkhileshNair2201 Jul 10, 2026
695ee6a
fix: align instrumentation tests with current APIs and close LiteLLM/…
AkhileshNair2201 Jul 10, 2026
88c9379
NET-1329 feat: add opt-in TTL caching for get_prompt (#327)
AkhileshNair2201 Jul 10, 2026
9dc2ea0
Merge branch 'develop' of github.com:KeyValueSoftwareSystems/netra-sd…
AkhileshNair2201 Jul 10, 2026
bc2bb78
feat(models): add opt-in TTL caching for get_model_pricing
AkhileshNair2201 Jul 10, 2026
886e58b
chore: remove phase-2-spec from models caching commit
AkhileshNair2201 Jul 10, 2026
eba71ef
docs(models): fix get_model_pricing returns docs and note cache mutation
AkhileshNair2201 Jul 10, 2026
be6fa6d
NET-1329 simplify cache TTL to module const and per-call override (#329)
AkhileshNair2201 Jul 13, 2026
7180a3b
docs: document opt-in TTL caching for get_model_pricing
AkhileshNair2201 Jul 13, 2026
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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog and this project adheres to Semantic Versioning.

## [0.1.96] - 2026-07-09

- **Add opt-in TTL caching for `get_prompt`** - `Netra.prompts.get_prompt` now accepts `use_cache` and `cache_ttl` parameters for in-memory caching. Default TTL is `PROMPT_CACHE_TTL_SECONDS` (60); override per call with `cache_ttl`. Use `Netra.prompts.clear_cache()` to invalidate cached entries.
- **Add opt-in TTL caching for `get_model_pricing`** - `Netra.models.get_model_pricing` now accepts `use_cache` and `cache_ttl` parameters for in-memory caching. Default TTL is `MODEL_PRICING_CACHE_TTL_SECONDS` (300); override per call with `cache_ttl`. Use `Netra.models.clear_cache()` to invalidate cached entries.

## [0.1.95] - 2026-06-26

- **Added get_all_datasets with tag as optional param** - If tag is provided, we get details of all the datasets with that particular tag attached.
Expand Down Expand Up @@ -309,4 +314,4 @@ Users can be now overwrite the input and ouput attributes of spans created by in

- Added utility to set input and output data for any active span in a trace

[0.1.95]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main
[0.1.96]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
- 📈 **Session Management**: Track user sessions and custom attributes
- 🌐 **HTTP Client Instrumentation**: Automatic tracing for aiohttp and httpx
- 💾 **Vector Database Support**: Weaviate, Qdrant, and other vector DB instrumentation
- 📋 **Prompt Management**: Fetch managed prompts from Netra with optional in-memory TTL caching
- 💰 **Model Pricing**: Fetch model pricing from Netra with optional in-memory TTL caching

## 📦 Installation

Expand Down Expand Up @@ -319,6 +321,78 @@ Action tracking follows this schema:
]
```

## 📋 Prompt Management

Fetch managed prompt versions from Netra via `Netra.prompts`. Caching is opt-in and disabled by default. Default TTL is **60 seconds** (`PROMPT_CACHE_TTL_SECONDS`); override per call with `cache_ttl`.

```python
from netra import Netra
from netra.instrumentation.instruments import InstrumentSet

Netra.init(
app_name="My App",
instruments={InstrumentSet.OPENAI},
)

# Fetch a prompt (calls the API on every request by default)
prompt = Netra.prompts.get_prompt("my-prompt", label="production")

# Opt in to in-memory caching to reduce API calls (default TTL: 60s)
prompt = Netra.prompts.get_prompt("my-prompt", label="production", use_cache=True)

# Override TTL for a single call (seconds)
prompt = Netra.prompts.get_prompt("my-prompt", use_cache=True, cache_ttl=300)

# Clear cached entries after updating a prompt
Netra.prompts.clear_cache()
```

Caching notes:

- `use_cache` defaults to `False`; enable it per call when you want caching.
- Default TTL is the module constant `PROMPT_CACHE_TTL_SECONDS` (60); override with `cache_ttl`.
- Cache keys are scoped by prompt `name` and `label`.
- Empty or failed responses are not stored in the cache.
- The prompt cache is cleared automatically when `Netra.shutdown()` is called.

## 💰 Model Pricing

Fetch model details and pricing for your project via `Netra.models`. Caching is opt-in and disabled by default. Default TTL is **300 seconds** (`MODEL_PRICING_CACHE_TTL_SECONDS`); override per call with `cache_ttl`.

```python
from netra import Netra
from netra.instrumentation.instruments import InstrumentSet

Netra.init(
app_name="My App",
instruments={InstrumentSet.OPENAI},
)

# Fetch all model pricing (calls the API on every request by default)
models = Netra.models.get_model_pricing()

# Fetch pricing for a specific model
models = Netra.models.get_model_pricing(name="gpt-4o")

# Opt in to in-memory caching to reduce API calls (default TTL: 300s)
models = Netra.models.get_model_pricing(use_cache=True)

# Override TTL for a single call (seconds)
models = Netra.models.get_model_pricing(use_cache=True, cache_ttl=600)

# Clear cached entries after pricing updates
Netra.models.clear_cache()
```

Caching notes:

- `use_cache` defaults to `False`; enable it per call when you want caching.
- Default TTL is the module constant `MODEL_PRICING_CACHE_TTL_SECONDS` (300); override with `cache_ttl`.
- Cache keys are scoped by model `name` (or `"all"` when no name is passed).
- Empty or failed responses are not stored in the cache.
- When `use_cache` is `True`, do not mutate the returned list or nested dicts — the same objects may be served on later cache hits.
- The model pricing cache is cleared automatically when `Netra.shutdown()` is called.

## 🔧 Advanced Configuration

### Environment Variables
Expand Down
74 changes: 74 additions & 0 deletions netra/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,16 @@ def shutdown(cls) -> None:
cls.simulation.close()
except Exception:
pass
if hasattr(cls, "prompts") and cls.prompts is not None:
try:
cls.prompts.clear_cache()
except Exception:
pass
if hasattr(cls, "models") and cls.models is not None:
try:
cls.models.clear_cache()
except Exception:
pass

@classmethod
def get_meter(cls, name: str = "netra", version: Optional[str] = None) -> otel_metrics.Meter:
Expand Down Expand Up @@ -415,6 +425,48 @@ def set_custom_event(cls, event_name: str, attributes: Any) -> None:
else:
logger.warning("Both event_name and attributes must be provided for custom events.")

@classmethod
def record_exception(
cls,
exception: BaseException,
attributes: Optional[Dict[str, Any]] = None,
escaped: Optional[bool] = False,
) -> None:
"""Record a caught exception on the currently active span.

Use this inside ``except`` blocks to attach exception details to the
current span when the exception is being handled and will not propagate
to the SDK's automatic capture logic.

The method adds a standard OpenTelemetry exception event (with type,
message, and stacktrace), sets the span status to ERROR, and records
the ``netra.error_message`` attribute for consistency with the rest of
the SDK.

Args:
exception: The exception instance to record.
attributes: Optional extra attributes to attach to the exception
event.

Example::

@workflow
def process_order(order_id: str) -> str:
try:
result = call_payment_api(order_id)
except PaymentError as exc:
Netra.record_exception(exc)
return "fallback_result"
return result
"""
if not isinstance(exception, BaseException):
logger.error(
"record_exception: exception must be a BaseException instance, got %s",
type(exception),
)
return
SessionManager.record_exception(exception, attributes=attributes, escaped=escaped)

@classmethod
def add_conversation(cls, conversation_type: ConversationType, role: str, content: Any) -> None:
"""
Expand Down Expand Up @@ -467,6 +519,28 @@ def set_root_output(cls, value: Any) -> None:
"""
SessionManager.set_root_output(value)

@classmethod
def set_root_output_stream(cls, value: Any) -> Any:
"""
Wrap a stream so the accumulated output is set on the root span when iteration ends.

The returned object is a transparent proxy — iterate over it instead of the original::

stream = Netra.set_root_output_stream(stream)
for chunk in stream:
...

Supports both sync and async iterables. Returns *value* unchanged if no active trace
context exists or if *value* is not iterable.

Args:
value: The stream to wrap (Netra-instrumented or any generic iterable).

Returns:
A wrapped stream proxy, or *value* unchanged if wrapping is not possible.
"""
return SessionManager.set_root_output_stream(value)

@classmethod
def start_span(
cls,
Expand Down
41 changes: 41 additions & 0 deletions netra/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import threading
import time
from typing import Dict, Generic, Optional, Tuple, TypeVar

T = TypeVar("T")


class TTLCache(Generic[T]):
"""In-memory TTL cache for SDK read API responses."""

def __init__(self, default_ttl: int = 60) -> None:
self._default_ttl = default_ttl
self._store: Dict[str, Tuple[T, float]] = {}
self._lock = threading.Lock()

def get(self, key: str) -> Optional[T]:
with self._lock:
entry = self._store.get(key)
if entry is None:
return None
value, expires_at = entry
if time.monotonic() > expires_at:
del self._store[key]
return None
return value

def set(self, key: str, value: T, ttl: Optional[int] = None) -> None:
ttl_seconds = self._default_ttl if ttl is None else ttl
if ttl_seconds <= 0:
return
expires_at = time.monotonic() + ttl_seconds
with self._lock:
self._store[key] = (value, expires_at)

def invalidate(self, key: str) -> None:
with self._lock:
self._store.pop(key, None)

def clear(self) -> None:
with self._lock:
self._store.clear()
12 changes: 8 additions & 4 deletions netra/evaluation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@
from netra.evaluation.evaluator import BaseEvaluator
from netra.evaluation.models import (
DatasetItem,
DatasetType,
EvaluatorConfig,
EvaluatorContext,
EvaluatorOutput,
LocalDataset,
ScoreType,
TurnType,
)

__all__ = [
"Evaluation",
"DatasetItem",
"BaseEvaluator",
"DatasetItem",
"DatasetType",
"Evaluation",
"EvaluatorConfig",
"EvaluatorContext",
"EvaluatorOutput",
"EvaluatorConfig",
"ScoreType",
"LocalDataset",
"ScoreType",
"TurnType",
]
11 changes: 9 additions & 2 deletions netra/evaluation/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Dataset,
DatasetItem,
DatasetRecord,
DatasetType,
EvaluatorConfig,
GetAllDatasetsResponse,
GetDatasetItemsResponse,
Expand Down Expand Up @@ -48,7 +49,13 @@ def __init__(self, config: Config) -> None:
self._config = config
self._client = EvaluationHttpClient(config)

def create_dataset(self, name: str, tags: Optional[List[str]] = None, turn_type: TurnType = TurnType.SINGLE) -> Any:
def create_dataset(
self,
name: str,
dataset_type: DatasetType = DatasetType.TEXT,
turn_type: TurnType = TurnType.SINGLE,
tags: Optional[List[str]] = None,
) -> Any:
"""
Create an empty dataset and return its id on success, else None.

Expand All @@ -63,7 +70,7 @@ def create_dataset(self, name: str, tags: Optional[List[str]] = None, turn_type:
if not name:
logger.error("netra.evaluation: Failed to create dataset: dataset name is required")
return None
response = self._client.create_dataset(name=name, tags=tags, turn_type=turn_type)
response = self._client.create_dataset(name=name, dataset_type=dataset_type, turn_type=turn_type, tags=tags)

if not response:
return None
Expand Down
15 changes: 12 additions & 3 deletions netra/evaluation/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import httpx

from netra.config import Config
from netra.evaluation.models import DatasetItem, TurnType
from netra.evaluation.models import DatasetItem, DatasetType, TurnType

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -102,7 +102,11 @@ def _get_timeout(self) -> float:
return 10.0

def create_dataset(
self, name: Optional[str], tags: Optional[List[str]] = None, turn_type: TurnType = TurnType.SINGLE
self,
name: Optional[str],
dataset_type: DatasetType = DatasetType.TEXT,
turn_type: TurnType = TurnType.SINGLE,
tags: Optional[List[str]] = None,
) -> Any:
"""
Create an empty dataset
Expand All @@ -121,7 +125,12 @@ def create_dataset(
return None
try:
url = "/evaluations/dataset"
payload: Dict[str, Any] = {"name": name, "tags": tags if tags else [], "turnType": turn_type.value}
payload: Dict[str, Any] = {
"name": name,
"tags": tags if tags else [],
"turnType": turn_type.value,
"datasetType": dataset_type.value,
}
response = self._client.post(url, json=payload)
response.raise_for_status()
data = response.json()
Expand Down
5 changes: 5 additions & 0 deletions netra/evaluation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ class TurnType(str, Enum):
MULTI = "multi"


class DatasetType(str, Enum):
TEXT = "text"
IMAGE = "image"


@dataclass
class ItemProcessingResult:
"""Result of processing a single dataset item."""
Expand Down
6 changes: 4 additions & 2 deletions netra/instrumentation/agno/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -941,7 +941,7 @@ def set_request_attributes(
span.set_attribute("input", input_content)


def set_response_attributes(span: Span, response: Any) -> None:
def set_response_attributes(span: Span, response: Any) -> Optional[str]:
"""Set response-side span attributes from an Agno response object.

Writes token usage, output content, response ID, and output type.
Expand All @@ -951,7 +951,7 @@ def set_response_attributes(span: Span, response: Any) -> None:
response: The Agno response object (RunResponse, TeamRunOutput, etc.).
"""
if not span.is_recording():
return
return None

usage = extract_token_usage(response)
if usage:
Expand All @@ -965,6 +965,8 @@ def set_response_attributes(span: Span, response: Any) -> None:
if response_id:
span.set_attribute(ATTR_RESPONSE_ID, response_id)

return output


def sanitize_headers(raw_headers: List[Tuple[bytes, bytes]]) -> Dict[str, str]:
"""Convert ASGI raw header pairs to a dict with sensitive values redacted.
Expand Down
Loading