From b24a696315122ffa208be4417eb251b6ca3d1c03 Mon Sep 17 00:00:00 2001 From: RiyaSunil27 Date: Mon, 28 Sep 2026 16:26:52 +0530 Subject: [PATCH 1/3] [NET-1627] fix: Prevent Session Context from Propagating via Baggage Headers (#409) --- netra/config.py | 10 ++- netra/instrumentation/http/propagation.py | 81 +++++++++++++++++++ .../libraries/aiohttp/__init__.py | 15 +++- .../libraries/httpx/wrappers.py | 18 ++--- .../libraries/requests/wrappers.py | 6 +- 5 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 netra/instrumentation/http/propagation.py diff --git a/netra/config.py b/netra/config.py index 668d202c..9d88c0a9 100644 --- a/netra/config.py +++ b/netra/config.py @@ -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 @@ -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() @@ -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: diff --git a/netra/instrumentation/http/propagation.py b/netra/instrumentation/http/propagation.py new file mode 100644 index 00000000..2a7bbb2a --- /dev/null +++ b/netra/instrumentation/http/propagation.py @@ -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) diff --git a/netra/instrumentation/libraries/aiohttp/__init__.py b/netra/instrumentation/libraries/aiohttp/__init__.py index 2db96c39..8261ba10 100644 --- a/netra/instrumentation/libraries/aiohttp/__init__.py +++ b/netra/instrumentation/libraries/aiohttp/__init__.py @@ -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, @@ -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__) @@ -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(): diff --git a/netra/instrumentation/libraries/httpx/wrappers.py b/netra/instrumentation/libraries/httpx/wrappers.py index 1ce5c75d..d84cd3df 100644 --- a/netra/instrumentation/libraries/httpx/wrappers.py +++ b/netra/instrumentation/libraries/httpx/wrappers.py @@ -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, @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/netra/instrumentation/libraries/requests/wrappers.py b/netra/instrumentation/libraries/requests/wrappers.py index a0854749..86217e99 100644 --- a/netra/instrumentation/libraries/requests/wrappers.py +++ b/netra/instrumentation/libraries/requests/wrappers.py @@ -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, @@ -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) @@ -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) From 7dc311de4bc409768d1527dc5656814fabd8963e Mon Sep 17 00:00:00 2001 From: RiyaSunil27 Date: Tue, 29 Sep 2026 16:43:58 +0530 Subject: [PATCH 2/3] [NET-1632] feat: Add utility to update span within the span context (#410) --- netra/__init__.py | 24 ++++++ netra/decorators.py | 2 + netra/session_manager.py | 163 ++++++++++++++++++++++++++++++++------- netra/span_wrapper.py | 37 ++++++++- 4 files changed, 195 insertions(+), 31 deletions(-) diff --git a/netra/__init__.py b/netra/__init__.py index d526485d..9051562e 100644 --- a/netra/__init__.py +++ b/netra/__init__.py @@ -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..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: """ diff --git a/netra/decorators.py b/netra/decorators.py index 7b3496dc..c495eb29 100644 --- a/netra/decorators.py +++ b/netra/decorators.py @@ -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: @@ -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: diff --git a/netra/session_manager.py b/netra/session_manager.py index f3f3481f..a47346cb 100644 --- a/netra/session_manager.py +++ b/netra/session_manager.py @@ -1,5 +1,7 @@ import contextvars import logging +import threading +import weakref from contextlib import contextmanager from datetime import datetime from enum import Enum @@ -35,15 +37,15 @@ # instrumentation re-attaches the active OTel context inside worker threads, # which lets spans created there inherit the parent workflow's entity names. # -# Each key maps to an immutable tuple of frames, where a frame is -# ``(frame_id, entity_name)``. ``push_entity`` appends a frame tagged with a -# unique ``frame_id`` and returns that id as the token; ``pop_entity`` removes -# the frame with the matching id. This is deliberately NOT modelled as OTel -# ``attach``/``detach``: detach requires strict LIFO ordering, which the -# deferred pop of streaming/generator spans cannot guarantee (a generator may -# be finished out of creation order, or abandoned entirely). Removing a frame -# by id is order-independent, so interleaved or partially-consumed generators -# cannot corrupt or leak another entity's context. +# Each key maps to an immutable tuple of ``_EntityFrame`` objects. +# ``push_entity`` appends a fresh frame and returns that frame as the token; +# ``pop_entity`` removes the frame that is the token (matched by identity). +# This is deliberately NOT modelled as OTel ``attach``/``detach``: detach +# requires strict LIFO ordering, which the deferred pop of streaming/generator +# spans cannot guarantee (a generator may be finished out of creation order, or +# abandoned entirely). Removing a frame by identity is order-independent, so +# interleaved or partially-consumed generators cannot corrupt or leak another +# entity's context. _ENTITY_STACK_KEYS: Dict[str, str] = { "workflow": "netra.workflow_stack", "task": "netra.task_stack", @@ -51,8 +53,35 @@ "span": "netra.span_stack", } -# A single frame on an entity stack: an opaque per-push id plus the entity name. -_EntityFrame = Tuple[object, str] + +class _EntityFrame: + """A single frame on an entity stack: a per-push identity holding the entity name. + + ``name`` is mutable on purpose. Every OTel context copy that contains this + frame (the pushing context, contexts restored by a nested scope's detach, + asyncio task copies, worker-thread contexts) shares this one object, so + :meth:`SessionManager.rename_entity` updates the name everywhere at once. + Rebinding the stack in the context instead would be reverted by the next + enclosing detach. A single attribute store is atomic under the GIL, so + concurrent readers see either the old or the new name. + """ + + __slots__ = ("name",) + + def __init__(self, name: str) -> None: + self.name = name + + +# Maps each entity type to the ``netra.`` attribute key that carries its +# name onto spans. Single source of truth shared by +# ``get_current_entity_attributes`` (stamps at span start) and +# ``SessionManager.update_span_name`` (re-stamps on a live span), so the two never drift. +_ENTITY_ATTR_SUFFIXES: Dict[str, str] = { + "workflow": "workflow.name", + "task": "task.name", + "agent": "agent.name", + "span": "span.name", +} # Current span and span registries are per-thread execution bookkeeping that # must NOT be shared across threads. ContextVars give thread-isolated, @@ -68,12 +97,22 @@ "netra_active_spans", default=() ) +# The entity frame each entity span owns, as ``(entity_type, push_entity token)``, +# so renaming a span renames exactly its own frame rather than whatever frame is on +# top of the stack. Unlike the ContextVars above this is process-wide: the +# threading instrumentation makes a span current inside worker threads, and a +# rename there must still resolve. Weak keys drop an entry once its span is +# garbage-collected, so spans ended on deferred paths (generators, streams) +# cannot leak it. +_span_entity_frames: "weakref.WeakKeyDictionary[trace.Span, Tuple[str, object]]" = weakref.WeakKeyDictionary() +_span_entity_frames_lock = threading.Lock() + def _read_entity_frames(entity_type: str) -> Tuple[_EntityFrame, ...]: """Read the current entity-stack frames for ``entity_type`` from the OTel context. The stack is stored under the OTel context key mapped in - ``_ENTITY_STACK_KEYS`` as an immutable tuple of ``(frame_id, name)`` frames. + ``_ENTITY_STACK_KEYS`` as an immutable tuple of ``_EntityFrame`` objects. Reading tolerates a missing or malformed value (returns an empty stack) because the key may be absent in a freshly propagated worker context. @@ -104,7 +143,7 @@ def _current_entity_name(entity_type: str) -> Optional[str]: ``None`` if the stack is empty or ``entity_type`` is unknown. """ frames = _read_entity_frames(entity_type) - return frames[-1][1] if frames else None + return frames[-1].name if frames else None # The baggage keys that carry session identity. ``SessionSpanProcessor.on_start`` @@ -232,6 +271,48 @@ def unregister_span(cls, name: str, span: trace.Span) -> None: except Exception: logger.exception("Failed to unregister span '%s'", name) + @classmethod + def bind_span_to_entity(cls, span: trace.Span, entity_type: str, token: Optional[object]) -> None: + """Record that ``span`` owns the entity frame pushed with ``token``. + + Lets :meth:`update_span_name` rename the span's own entity frame. No-op + when ``token`` is ``None`` (``push_entity`` got an unknown entity type). + + Args: + span: The entity's span. + entity_type: Type of entity (workflow, task, agent, span). + token: The token returned by the ``push_entity`` call for this span. + """ + if token is None: + return + with _span_entity_frames_lock: + _span_entity_frames[span] = (entity_type, token) + + @classmethod + def update_span_name(cls, span: trace.Span, new_name: str) -> None: + """Rename ``span``, keeping its entity name in sync. + + Always updates the OpenTelemetry span name. If ``span`` is an entity span + (bound via :meth:`bind_span_to_entity`), also re-stamps its + ``netra..name`` and renames its entity frame, so child spans + started after this call inherit the new name. Child spans already started + keep the old name; it was stamped when they started. + + The by-name span registry stays keyed by the name the span started with, + which is what every caller passes to :meth:`unregister_span`. + + Args: + span: The span to rename. + new_name: The new span name. + """ + with _span_entity_frames_lock: + entity_frame = _span_entity_frames.get(span) + if entity_frame is not None: + entity_type, token = entity_frame + cls.rename_entity(token, new_name) + span.set_attribute(f"{Config.LIBRARY_NAME}.{_ENTITY_ATTR_SUFFIXES[entity_type]}", new_name) + span.update_name(new_name) + @classmethod def get_trace_id(cls) -> Optional[str]: """ @@ -285,13 +366,13 @@ def push_entity(cls, entity_type: str, entity_name: str) -> Optional[object]: key = _ENTITY_STACK_KEYS.get(entity_type) if key is None: return None - # A fresh object() is a process-unique identity for this exact push, so + # A fresh frame is a process-unique identity for this exact push, so # pop_entity can find and remove this frame even if frames are removed # out of order (interleaved streaming spans). - frame_id: object = object() + frame = _EntityFrame(entity_name) frames = _read_entity_frames(entity_type) - otel_context.attach(otel_context.set_value(key, frames + ((frame_id, entity_name),))) - return frame_id + otel_context.attach(otel_context.set_value(key, frames + (frame,))) + return frame @classmethod def pop_entity(cls, entity_type: str, token: Optional[object] = None) -> Optional[str]: @@ -320,20 +401,47 @@ def pop_entity(cls, entity_type: str, token: Optional[object] = None) -> Optiona if token is None: # Legacy path: no token to match, remove the most recent frame. - removed_name = frames[-1][1] + removed_name = frames[-1].name new_frames = frames[:-1] else: - index = next((i for i in range(len(frames) - 1, -1, -1) if frames[i][0] is token), None) + index = next((i for i in range(len(frames) - 1, -1, -1) if frames[i] is token), None) if index is None: # Frame already removed (or belongs to a context we can't see); # do not touch the stack. return None - removed_name = frames[index][1] + removed_name = frames[index].name new_frames = frames[:index] + frames[index + 1 :] otel_context.attach(otel_context.set_value(key, new_frames)) return removed_name + @classmethod + def rename_entity(cls, token: object, new_name: str) -> None: + """Rename the entity frame pushed with ``token``, in place. + + Spans started *after* this call read the new name from the stack (their + ``netra..name`` is stamped at their own ``on_start``) in every + context that holds the frame: the pushing context, contexts restored when + a nested scope detaches, asyncio tasks and worker threads that copied it. + The frame's identity is unchanged, so the matching :meth:`pop_entity` + still removes exactly this frame. + + This does NOT retroactively update spans that were already started under + the old name — their attribute was written at start and, once exported, + cannot be changed. Callers that also want the *current* span updated must + re-stamp it themselves (see :meth:`update_span_name`). + + No-op if ``token`` is not a token returned by :meth:`push_entity`. + Renaming an already-popped frame is harmless: no stack holds it. + + Args: + token: The token returned by the matching ``push_entity``. + new_name: The new entity name. + """ + if not isinstance(token, _EntityFrame): + return + token.name = new_name + @classmethod def get_current_entity_attributes(cls) -> Dict[str, str]: """ @@ -344,12 +452,7 @@ def get_current_entity_attributes(cls) -> Dict[str, str]: """ attributes = {} - for entity_type, attr_suffix in ( - ("workflow", "workflow.name"), - ("task", "task.name"), - ("agent", "agent.name"), - ("span", "span.name"), - ): + for entity_type, attr_suffix in _ENTITY_ATTR_SUFFIXES.items(): name = _current_entity_name(entity_type) if name is not None: attributes[f"{Config.LIBRARY_NAME}.{attr_suffix}"] = name @@ -378,10 +481,10 @@ def get_stack_info(cls) -> Dict[str, List[str]]: Dictionary containing all stack contents """ return { - "workflows": [name for _, name in _read_entity_frames("workflow")], - "tasks": [name for _, name in _read_entity_frames("task")], - "agents": [name for _, name in _read_entity_frames("agent")], - "spans": [name for _, name in _read_entity_frames("span")], + "workflows": [frame.name for frame in _read_entity_frames("workflow")], + "tasks": [frame.name for frame in _read_entity_frames("task")], + "agents": [frame.name for frame in _read_entity_frames("agent")], + "spans": [frame.name for frame in _read_entity_frames("span")], } @staticmethod diff --git a/netra/span_wrapper.py b/netra/span_wrapper.py index c342b3a9..0cc8c7e2 100644 --- a/netra/span_wrapper.py +++ b/netra/span_wrapper.py @@ -110,6 +110,9 @@ def __init__( self._local_block_token: Optional[object] = None # Detach token for the entity stack pushed in __enter__ (if any) self._entity_token: Optional[object] = None + # Name the span was registered under at __enter__; __exit__ unregisters + # under it because update_span_name changes self.name, not the registry key. + self._registered_name: Optional[str] = None if isinstance(as_type, SpanType): self.attributes["netra.span.type"] = as_type.value @@ -149,10 +152,13 @@ def __enter__(self) -> "SpanWrapper": name=self.name, kind=SpanKind.CLIENT, attributes=self.attributes ) self.span = self._span_cm.__enter__() + if self._entity_type: + SessionManager.bind_span_to_entity(self.span, self._entity_type, self._entity_token) # Register with SessionManager for name-based lookup try: SessionManager.register_span(self.name, self.span) + self._registered_name = self.name # Optionally set as current span for SDK consumers that rely on it SessionManager.set_current_span(self.span) except Exception: @@ -201,7 +207,7 @@ def __exit__(self, exc_type: Optional[type], exc_val: Optional[Exception], exc_t if self.span: # Unregister from SessionManager before ending span try: - SessionManager.unregister_span(self.name, self.span) + SessionManager.unregister_span(self._registered_name or self.name, self.span) except Exception: logger.exception("Failed to unregister span '%s' from SessionManager", self.name) if self._span_cm is not None: @@ -262,6 +268,35 @@ def set_attribute(self, key: str, value: str) -> "SpanWrapper": self.span.set_attribute(key, value) return self + def update_span_name(self, new_name: str) -> "SpanWrapper": + """Rename this span, keeping its entity name in sync. + + Use this when a span is opened under a placeholder (e.g. an id) and the + human-readable name only becomes known later. Besides the OpenTelemetry + span name, for ``AGENT`` / ``TOOL`` spans it updates + ``netra.agent.name`` / ``netra.task.name`` on this span and on child + spans started *after* this call. + + Child spans that already started keep the old entity name: it is stamped + when they start. Called before the span is entered, it just changes the + name the span will start with. + + Args: + new_name: The new span name. A non-string or empty value is ignored + with a warning. + + Returns: + 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") + return self + + if self.span: + SessionManager.update_span_name(self.span, new_name) + self.name = new_name + return self + def set_prompt(self, prompt: str) -> "SpanWrapper": """ Set the input prompt. From f9a64613f3dc27420916768d899e594c959240dd Mon Sep 17 00:00:00 2001 From: akash-vijay-kv Date: Tue, 29 Sep 2026 17:06:21 +0530 Subject: [PATCH 3/3] chore: Version bump and update changelog to v1.2.0 --- CHANGELOG.md | 16 +++++++++++++++- netra/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f26895..591fec0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..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`, @@ -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 diff --git a/netra/version.py b/netra/version.py index 6849410a..c68196d1 100644 --- a/netra/version.py +++ b/netra/version.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.2.0" diff --git a/pyproject.toml b/pyproject.toml index a60b239d..c9b349c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "netra-sdk" -version = "1.1.0" +version = "1.2.0" 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"}