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
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ 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.0] - 2026-09-29

### Added

- **`update_span_name` renames the active span after it has started** - `Netra.update_span_name(name)` renames the currently active span, and `SpanWrapper.update_span_name(new_name)` renames the span a `Netra.start_span(...)` block holds (it returns the wrapper, so calls chain). This is for spans opened under a placeholder, such as an ID, whose readable name is only known later. If the span belongs to an entity (opened by `@workflow`, `@agent`, `@task` or `@span`, or by `start_span` with `SpanType.AGENT` or `SpanType.TOOL`), its `netra.<entity>.name` attribute changes too, and child spans started after the rename inherit the new name. This holds across asyncio tasks and worker threads that share the entity's context. **Child spans that already started keep the old name**, because entity names are stamped when a span starts. Only the active span is renamed, so calling it inside a child span leaves the enclosing agent unchanged. With no active span, or a name that is empty or not a string, the call logs a warning and does nothing. On a `SpanWrapper` that has not been entered yet, it just sets the name the span will start with.

### Security

- **Session identity no longer leaks to third-party hosts through the `baggage` header** - Netra stores `session_id`, `user_id`, `tenant_id` and custom session keys as W3C baggage. The global OpenTelemetry propagator writes baggage into a `baggage:` header on every outbound request, so Netra's `httpx` and `requests` instrumentations were sending those identifiers to every host called, LLM providers included. Both now remove the `baggage` header unless the destination host is on an allowlist. Trace context (`traceparent` / `tracestate`) is still sent everywhere.

**Baggage is now sent to no host by default.** Services that read session identity from incoming baggage stop receiving it until their hosts are added to `NETRA_PROPAGATE_BAGGAGE_HOSTS`. This is a comma-separated list, case-insensitive, and a leading dot is ignored. An entry matches the host itself and its subdomains: `mycorp.net` matches `api.mycorp.net` but not `notmycorp.net`. The variable can only be set in the environment (there is no `Netra.init()` parameter) and is read when `Netra.init()` builds the config. The whole header is gated, so a `baggage` header your code sets on a request itself is also removed for hosts that are not on the list.

Only the `httpx` and `requests` instrumentations are covered. Requests made through `aiohttp`, `urllib`, `urllib3` or `grpc`, and the trace context passed to child processes through the environment, still carry baggage as before.

## [1.1.0] - 2026-09-25

First stable release of the 1.1.0 line. Everything below shipped across `1.1.0b1`–`1.1.0b4`,
Expand Down Expand Up @@ -514,4 +528,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.1.0]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main
[1.2.0]: https://github.com/KeyValueSoftwareSystems/netra-sdk-py/tree/main
24 changes: 24 additions & 0 deletions netra/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,30 @@ def set_tenant_id(cls, tenant_id: str) -> None:
else:
logger.warning("set_tenant_id: Tenant ID must be provided for setting tenant_id.")

@classmethod
def update_span_name(cls, name: str) -> None:
"""
Rename the currently active span, keeping its entity name in sync.

For a span opened by ``@agent`` / ``@task`` / ``@workflow`` / ``@span`` or by
``start_span`` with an AGENT or TOOL type, this also updates its
``netra.<entity>.name`` and the name inherited by child spans started after
this call. Child spans already started keep the old name. Only the active
span is renamed: called inside a child span, the enclosing agent is untouched.
With no active span it logs a warning and does nothing.

Args:
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")
return
span = trace.get_current_span()
if not span.get_span_context().is_valid:
logger.warning("update_span_name: no active span to rename")
return
SessionManager.update_span_name(span, name)

@classmethod
def set_custom_attributes(cls, key: str, value: Any) -> None:
"""
Expand Down
10 changes: 9 additions & 1 deletion netra/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import json
import logging
import os
from typing import Any, Dict, FrozenSet, List, Optional
from typing import Any, Dict, FrozenSet, List, Optional, Tuple

from opentelemetry.util.re import parse_env_headers

Expand Down Expand Up @@ -121,6 +121,8 @@ def __init__(

self.redact_headers = self._get_redact_headers()

self.propagate_baggage_hosts = self._get_propagate_baggage_hosts()

self._resolve_audio_settings()

self._set_trace_content_env()
Expand Down Expand Up @@ -334,6 +336,12 @@ def _get_redact_headers(self) -> FrozenSet[str]:
env_value = os.getenv("NETRA_REDACT_HEADERS", "")
return frozenset(name.strip().lower() for name in env_value.split(",") if name.strip())

def _get_propagate_baggage_hosts(self) -> Tuple[str, ...]:
"""Resolve the internal-host allowlist from ``NETRA_PROPAGATE_BAGGAGE_HOSTS`` (comma-separated); entries are lowercased with leading dots stripped, empty when unset (fail-closed)."""
raw = os.getenv("NETRA_PROPAGATE_BAGGAGE_HOSTS", "").split(",")
normalized = (host.strip().lstrip(".").lower() for host in raw)
return tuple(host for host in normalized if host)

def _get_int_config(self, param: Optional[int], env_var: str, default: int) -> int:
"""Get integer configuration from parameter or environment variable."""
if param is not None:
Expand Down
2 changes: 2 additions & 0 deletions netra/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:

tracer = trace.get_tracer(module_name)
span = tracer.start_span(span_name)
SessionManager.bind_span_to_entity(span, entity_type, entity_token)
try:
span.set_attribute("netra.span.type", as_type.value)
except Exception:
Expand Down Expand Up @@ -526,6 +527,7 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:

tracer = trace.get_tracer(module_name)
span = tracer.start_span(span_name)
SessionManager.bind_span_to_entity(span, entity_type, entity_token)
# Set span type if provided
if as_type is not None:
try:
Expand Down
81 changes: 81 additions & 0 deletions netra/instrumentation/http/propagation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Host-scoped context propagation for Netra's HTTP instrumentation.

Netra stores the session identity (``session_id``/``user_id``/``tenant_id`` and
any custom session keys) as W3C baggage. The default OpenTelemetry global
propagator serialises baggage into a ``baggage:`` header on *every* outbound
request, so a bare :func:`opentelemetry.propagate.inject` leaks that identity to
third-party APIs (LLM providers included).

:func:`inject_context` is the single injection entry point used by all of
Netra's HTTP client wrappers. It injects the full context as before (trace
context, and whatever else the global propagator carries) and then removes the
``baggage`` header entirely unless the destination host is on an explicit
internal allowlist. Trace context (``traceparent``/``tracestate``) is always
propagated; the whole W3C ``baggage`` header is host-gated -- Netra's session
identity lives there, so gating the header is what closes the leak, and any
other baggage a caller may have set is gated with it rather than being allowed
to reach third parties.

The allowlist is fail-closed: with no hosts configured, baggage propagates to
*nobody*, which closes the leak with zero configuration. Internal services are
opted back in via the ``NETRA_PROPAGATE_BAGGAGE_HOSTS`` environment variable.
"""

import logging
from typing import MutableMapping, Optional
from urllib.parse import urlparse

from opentelemetry.propagate import inject

from netra.config import get_active_config

logger = logging.getLogger(__name__)

# OpenTelemetry's W3CBaggagePropagator always writes this (lowercase) header.
# We match case-insensitively when stripping, to be safe against carriers that
# normalise header casing differently.
_BAGGAGE_HEADER = "baggage"


def _host_allowed(url: Optional[str]) -> bool:
"""Return True when *url*'s host is on the configured internal allowlist.

Fail-closed: returns False when *url* is missing/unparseable, when no
allowlist is configured, or when nothing matches. A host matches an
allowlist entry when it equals the entry or is a subdomain of it (so
``mycorp.net`` matches ``api.mycorp.net`` but not ``notmycorp.net``).
"""
cfg = get_active_config()
allow = getattr(cfg, "propagate_baggage_hosts", ()) if cfg is not None else ()
if not url or not allow:
return False
try:
# rstrip(".") normalizes a fully-qualified trailing-dot host ("svc.corp.net.")
# so it matches the allowlist and cannot dodge the "." + entry suffix boundary.
host = (urlparse(url).hostname or "").lower().rstrip(".")
except ValueError:
return False
if not host:
return False
return any(host == entry or host.endswith("." + entry) for entry in allow)


def inject_context(carrier: MutableMapping[str, str], url: Optional[str] = None) -> None:
"""Inject propagation headers into *carrier*, host-gating session baggage.

Trace context is always injected. The W3C ``baggage`` header -- which
carries Netra's session identity -- is removed unless *url*'s host is on the
internal allowlist (see :func:`_host_allowed`).

Args:
carrier: The header mapping to inject into (mutated in place).
url: The outbound request URL, used to decide whether baggage may be
propagated. When ``None`` the host is treated as untrusted and
baggage is stripped.
"""
inject(carrier)
if _host_allowed(url):
return
# Strip session-identity baggage for untrusted/unknown hosts.
for key in [k for k in carrier if isinstance(k, str) and k.lower() == _BAGGAGE_HEADER]:
carrier.pop(key, None)
15 changes: 13 additions & 2 deletions netra/instrumentation/libraries/aiohttp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
suppress_http_instrumentation,
)
from opentelemetry.metrics import Histogram, get_meter
from opentelemetry.propagate import inject
from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
from opentelemetry.semconv.attributes.network_attributes import (
NETWORK_PEER_ADDRESS,
Expand All @@ -58,6 +57,7 @@
)
from opentelemetry.util.http.httplib import set_ip_on_next_http_connection

from netra.instrumentation.http.propagation import inject_context
from netra.instrumentation.libraries.aiohttp.version import __version__

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -182,7 +182,18 @@ async def instrumented_request(self: ClientSession, method: str, url: Any, **kwa
else:
headers_dict = dict(headers)

inject(headers_dict)
# Resolve against the session base_url so a relative path
# (ClientSession(base_url=...) + session.get("/x")) still exposes the
# real destination host to the baggage-propagation allowlist check.
propagate_url = url
base_url = getattr(self, "_base_url", None)
if base_url is not None:
try:
propagate_url = str(base_url.join(URL(url_str)))
except Exception:
propagate_url = url

inject_context(headers_dict, propagate_url)
kwargs["headers"] = headers_dict

with suppress_http_instrumentation():
Expand Down
18 changes: 5 additions & 13 deletions netra/instrumentation/libraries/httpx/wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

from opentelemetry import context as context_api
from opentelemetry.instrumentation.utils import suppress_http_instrumentation
from opentelemetry.propagate import inject
from opentelemetry.trace import Span, SpanKind, Tracer, set_span_in_context
from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials
from wrapt import ObjectProxy

from netra.instrumentation.http.body import new_body_buffer
from netra.instrumentation.http.propagation import inject_context
from netra.instrumentation.libraries.httpx.utils import (
get_default_span_name,
set_span_input,
Expand Down Expand Up @@ -326,9 +326,7 @@ def wrapper(wrapped: Callable[..., Any], instance: Any, args: Tuple[Any, ...], k
) as span:
try:
set_span_input(span, request)
headers = dict(request.headers)
inject(headers)
request.headers.update(headers)
inject_context(request.headers, url)
except Exception as e:
logger.debug("netra.instrumentation.libraries.httpx: failed to set span input: %s", e)

Expand Down Expand Up @@ -362,9 +360,7 @@ def wrapper(wrapped: Callable[..., Any], instance: Any, args: Tuple[Any, ...], k
context = context_api.attach(set_span_in_context(span))
try:
set_span_input(span, request)
headers = dict(request.headers)
inject(headers)
request.headers.update(headers)
inject_context(request.headers, url)
except Exception as e:
logger.debug("netra.instrumentation.libraries.httpx: failed to set span input: %s", e)

Expand Down Expand Up @@ -444,9 +440,7 @@ async def wrapper(
) as span:
try:
set_span_input(span, request)
headers = dict(request.headers)
inject(headers)
request.headers.update(headers)
inject_context(request.headers, url)
except Exception as e:
logger.debug("netra.instrumentation.libraries.httpx: failed to set span input: %s", e)

Expand Down Expand Up @@ -479,9 +473,7 @@ async def wrapper(
context = context_api.attach(set_span_in_context(span))
try:
set_span_input(span, request)
headers = dict(request.headers)
inject(headers)
request.headers.update(headers)
inject_context(request.headers, url)
except Exception as e:
logger.debug("netra.instrumentation.libraries.httpx: failed to set span input: %s", e)

Expand Down
6 changes: 3 additions & 3 deletions netra/instrumentation/libraries/requests/wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

from opentelemetry import context as context_api
from opentelemetry.instrumentation.utils import suppress_http_instrumentation
from opentelemetry.propagate import inject
from opentelemetry.trace import Span, SpanKind, Tracer, set_span_in_context
from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials
from wrapt import ObjectProxy

from netra.instrumentation.http.body import new_body_buffer
from netra.instrumentation.http.propagation import inject_context
from netra.instrumentation.libraries.requests.utils import (
get_default_span_name,
set_span_input,
Expand Down Expand Up @@ -189,7 +189,7 @@ def wrapper(wrapped: Callable[..., Any], instance: Any, args: Tuple[Any, ...], k
) as span:
try:
set_span_input(span, request)
inject(request.headers)
inject_context(request.headers, url)
except Exception as e:
logger.debug("netra.instrumentation.libraries.requests: failed to set span input: %s", e)

Expand Down Expand Up @@ -223,7 +223,7 @@ def wrapper(wrapped: Callable[..., Any], instance: Any, args: Tuple[Any, ...], k
context = context_api.attach(set_span_in_context(span))
try:
set_span_input(span, request)
inject(request.headers)
inject_context(request.headers, url)
except Exception as e:
logger.debug("netra.instrumentation.libraries.requests: failed to set span input: %s", e)

Expand Down
Loading