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
7 changes: 4 additions & 3 deletions src/rius/_native_spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ def _exception_class(status: int) -> str | None:
return _EXCEPTION_CLASS_BY_STATUS.get(status)


def _is_sdk_span(span: ReadableSpan) -> bool:
def is_sdk_span(span: ReadableSpan) -> bool:
"""Whether the anthropic SDK made ``span`` itself, whatever its operation."""
scope = span.instrumentation_scope
return scope is not None and scope.name.startswith(ANTHROPIC_SDK_SCOPE_PREFIX)

Expand All @@ -103,7 +104,7 @@ def native_error_type(span: ReadableSpan) -> dict[str, Any]:
no exception event on this span, so there is no ``exception.type`` to
keep in step.
"""
if not _is_sdk_span(span):
if not is_sdk_span(span):
return {}
attributes = span.attributes or {}
if ERROR_TYPE not in attributes:
Expand Down Expand Up @@ -131,7 +132,7 @@ def demoted_attributes(attributes: Mapping[str, Any]) -> dict[str, Any]:


def _is_sdk_chat_span(span: ReadableSpan) -> bool:
if not _is_sdk_span(span):
if not is_sdk_span(span):
return False
return (span.attributes or {}).get(GEN_AI_OPERATION_NAME) == _CHAT

Expand Down
16 changes: 10 additions & 6 deletions src/rius/_semconv_generated.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Vendored from rius-semconv v1.9.1 out/python/_semconv_generated.py by
# Vendored from rius-semconv v1.9.2 out/python/_semconv_generated.py by
# scripts/sync-semconv.sh. Do not edit: change the registry in rius-semconv.
"""Rius semantic conventions: the constants and tables, generated.

Expand Down Expand Up @@ -1166,11 +1166,15 @@
# other keys that mode writes, gen_ai.system_instructions,
# gen_ai.input.messages, gen_ai.output.messages and
# gen_ai.tool.call.arguments are GenAI keys, listed under content.
# Three stay in clear on purpose: gen_ai.request.stop_sequences is a
# request parameter, and url.query and url.full are every HTTP span's
# URL, which a key-based list cannot mask for one SDK alone (in that
# mode they keep query parameters the SDK treats as content, such as
# email and path_prefix).
# gen_ai.request.stop_sequences stays in clear: a request parameter.
# url.query and url.full are not listed, as they are every HTTP span's
# URL, yet in that mode they keep query parameters the SDK treats as
# content (email and path_prefix among them, and more per endpoint). So
# the SDKs' masking exporters treat them as content on a span whose
# instrumentation scope starts with com.anthropic.sdk. only: with
# content capture off url.query is dropped and url.full loses its query,
# fragment and userinfo, and with a mask both run through it (see
# sdk_cases/masking.json).
"anthropic.tool_result.content",
}
)
Expand Down
66 changes: 64 additions & 2 deletions src/rius/masking.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from opentelemetry.trace import Link, Status

from ._attributes import rebuilt_item_attributes, replacement_attributes
from ._native_spans import is_sdk_span
from ._serde import serialize
from .semconv import (
CONTENT_ATTRIBUTE_PREFIXED_SUFFIXES,
Expand All @@ -37,6 +38,8 @@
EXCEPTION_EVENT,
EXCEPTION_MESSAGE,
EXCEPTION_STACKTRACE,
URL_FULL,
URL_QUERY,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -105,6 +108,56 @@ def is_content_key(key: str) -> bool:
_STATUS_DESCRIPTION_KEY = "status.description"


# The URL attributes that are content on the anthropic SDK's own spans only.
# In its content mode the SDK keeps the query parameters it treats as content
# (an email or path_prefix filter, and more that differ by endpoint) in
# url.query and url.full; outside it, it leaves both off when the query has
# one. Rius cannot tell which parameters an endpoint treats as content, so the
# whole query is content there. On every other span they are an HTTP call's
# URL, so they are not content keys.
_ANTHROPIC_SDK_URL_CONTENT_KEYS = frozenset({URL_QUERY, URL_FULL})


def _scheme_host_and_path(url: str) -> str | None:
"""``url`` as its scheme, host and path, or None when that is not safe to keep.

Without its query and fragment, and without a user name or password
before the host, which the URL convention says must never be recorded.
The user name and password go first, so a ``?`` or ``#`` inside them
cannot hide them from the cut. A URL that has an ``@`` where the host and
its credentials cannot be told apart is dropped: one without ``://``, one
with an ``@`` before it, or one whose authority (up to the first ``/``)
has both an ``@`` and a ``?`` or ``#``. So is one with nothing left.
"""
sep = url.find("://")
if sep < 0:
if "@" in url:
return None
else:
if "@" in url[:sep]:
return None
start = sep + 3
slash = url.find("/", start)
end = len(url) if slash < 0 else slash
authority = url[start:end]
if "@" in authority:
if "?" in authority or "#" in authority:
return None
url = url[:start] + url[url.rfind("@", start, end) + 1 :]
return url.split("#", 1)[0].split("?", 1)[0] or None


def _without_content(key: str, value: Any) -> Any:
"""What is left of a content attribute with content capture off, or None to drop it.

A string ``url.full`` keeps its scheme, host and path, since only the rest
of it is content. Every other key is dropped.
"""
if key == URL_FULL and isinstance(value, str):
return _scheme_host_and_path(value)
return None


class MaskingSpanExporter(SpanExporter):
"""Strip or redact content attributes before delegating to ``inner``."""

Expand All @@ -129,7 +182,12 @@ def _sanitized(self, span: ReadableSpan) -> ReadableSpan:
# The privacy boundary is the whole span: content rides attributes,
# event attributes (OTel GenAI's event-based shape, record_exception's
# provider-echoed error strings), and link attributes alike.
new_attributes = self._sanitize_mapping(span.attributes)
new_attributes = self._sanitize_mapping(
span.attributes,
extra_content_keys=(
_ANTHROPIC_SDK_URL_CONTENT_KEYS if is_sdk_span(span) else frozenset()
),
)
new_events = self._sanitize_events(span.events)
new_links = self._sanitize_links(span.links)
new_status = self._sanitize_status(span.status)
Expand Down Expand Up @@ -224,7 +282,11 @@ def _sanitize_mapping(
new_attributes = dict(attributes)
for key in keys:
if not self._capture_content:
del new_attributes[key]
kept = _without_content(key, new_attributes[key])
if kept is None:
del new_attributes[key]
else:
new_attributes[key] = kept
continue
assert self._mask is not None # guarded in export()
try:
Expand Down
2 changes: 2 additions & 0 deletions src/rius/semconv.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from opentelemetry.semconv.attributes.http_attributes import (
HTTP_RESPONSE_STATUS_CODE as HTTP_RESPONSE_STATUS_CODE,
)
from opentelemetry.semconv.attributes.url_attributes import URL_FULL as URL_FULL
from opentelemetry.semconv.attributes.url_attributes import URL_QUERY as URL_QUERY
from opentelemetry.trace import Span
from opentelemetry.trace import SpanKind as OtelSpanKind

Expand Down
23 changes: 21 additions & 2 deletions tests/anthropic_native_span_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
TOOL_ARGUMENT = "secret-tool-argument"
TOOL_OUTPUT = "secret-tool-output"
TOOL_NAME = "lookup"
#: A query parameter the SDK treats as content: in its content mode it keeps it
#: in url.query and url.full.
PATH_PREFIX = "/secret-path-prefix/"

_MESSAGE: dict[str, Any] = {
"id": "msg_1",
Expand Down Expand Up @@ -116,6 +119,15 @@ def _sse() -> bytes:


class _Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 (http.server API)
# Every list the runner calls gets an empty page.
body = json.dumps({"data": [], "next_page": None}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def do_POST(self) -> None: # noqa: N802 (http.server API)
request = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
# Bedrock names the model in the path, not the body.
Expand Down Expand Up @@ -229,9 +241,10 @@ def main() -> None:
bridge_foreign_provider=bool(options.get("foreign")),
heartbeat=False,
)
messages = anthropic.Anthropic(
sync_client = anthropic.Anthropic(
api_key="test-key", base_url=f"http://127.0.0.1:{server.server_port}", max_retries=0
).messages
)
messages = sync_client.messages
request: dict[str, Any] = {
"model": "claude-sonnet-5",
"max_tokens": 5,
Expand Down Expand Up @@ -298,6 +311,12 @@ async def _async_not_found() -> None:
"async_stream_helper": lambda: asyncio.run(_async_stream_helper()),
"async_not_found": lambda: asyncio.run(_async_not_found()),
}
if hasattr(sync_client.beta, "memory_stores"):

def memories_list() -> None:
sync_client.beta.memory_stores.memories.list("memstore_1", path_prefix=PATH_PREFIX)

cases["memories_list"] = memories_list
cases.update(_tool_runner_cases(f"http://127.0.0.1:{server.server_port}", request))
if importlib.util.find_spec("botocore") is not None:
# The Bedrock client goes through the same Messages resource, so
Expand Down
2 changes: 1 addition & 1 deletion tests/conformance/_recording.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Vendored from rius-semconv 35a8b108981002c31faa50b14f7107c3fc77eeef fixtures/_recording.py by
# Vendored from rius-semconv ebb29c2545f13e804128142458e434351ca12579 fixtures/_recording.py by
# scripts/sync-semconv-fixtures.sh. Do not edit: change it in rius-semconv.
"""Shared helpers for the recorders under fixtures/<family>/record.py.

Expand Down
2 changes: 1 addition & 1 deletion tests/conformance/_replay.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Vendored from rius-semconv 35a8b108981002c31faa50b14f7107c3fc77eeef fixtures/_replay.py by
# Vendored from rius-semconv ebb29c2545f13e804128142458e434351ca12579 fixtures/_replay.py by
# scripts/sync-semconv-fixtures.sh. Do not edit: change it in rius-semconv.
"""Turn a stored recording back into OpenTelemetry ReadableSpans (Python).

Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/semconv/SOURCE
Original file line number Diff line number Diff line change
@@ -1 +1 @@
rius-semconv 35a8b108981002c31faa50b14f7107c3fc77eeef
rius-semconv ebb29c2545f13e804128142458e434351ca12579
8 changes: 7 additions & 1 deletion tests/fixtures/semconv/sdk_cases/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ code, so the two SDKs agree with each other by agreeing with these files.
| `values.json` | `input.value` / `output.value` of a generic span: a string as it is, anything else its compact JSON, numbers and booleans included. | `set_input` / `setInput`, `set_output` / `setOutput` |
| `attributes.json` | A span attribute set by the SDK: primitives and homogeneous arrays of them kept; a mapping, a mixed or nested array, or an array holding a mapping one JSON encoding, byte objects as markers. | `set_attribute` / `setAttribute` |
| `messages.json` | `gen_ai.input.messages`, `gen_ai.output.messages` and `rius.context.sizes`: a lone string or mapping is one message, a missing role is the default, a null role is kept and sized as `user`. | a generation's `set_input` / `set_output` |
| `masking.json` | What reaches the exporter with content capture off, and with a mask that returns a value, returns null or throws: span attributes, `exception` event text and the status description. | `MaskingSpanExporter` |
| `masking.json` | What reaches the exporter with content capture off, and with a mask that returns a value, returns null or throws: span attributes, `exception` event text, the status description, and the URL query of the Anthropic SDK's own spans. | `MaskingSpanExporter` |
| `env.json` | `RIUS_*` variables: booleans (blank or unrecognised keeps the default), blank strings (unset), numbers (as Python's `float()` reads them, clamped to range). | `resolve_config()` / `resolveConfig({}, env)` |
| `resource.json` | `OTEL_RESOURCE_ATTRIBUTES` merged under the SDK's own resource keys, and the `unknown_service` placeholder never stamped as an agent name. | `init()` |
| `anthropic_error_type.json` | `error.type` on a span the Anthropic SDK made itself: the exception class it raises for the HTTP status, for the statuses every Anthropic client in both languages maps alike (400, 401, 403, 404, 409, 422, 429, and every 5xx but 503, 504 and 529); anything else kept. | the SDK's span normalization |
Expand All @@ -29,6 +29,12 @@ the Python way (`capture_content`, `service_name`); each SDK's test maps it.
records on the `rius.config` logger, the TypeScript SDK `console.warn` calls.
A case without `warns` does not check warnings at all, so leave it out only
where the warning is not part of the contract.
- `scope` (optional in `masking.json`; `anthropic_error_type.json` requires
it): the instrumentation scope name of the span the case exports. A
masking case without it uses a scope of the test's own, which no rule
matches. It matters for the Anthropic SDK's own spans (scope
`com.anthropic.sdk.<language>`), whose `url.query` and `url.full` are
content.

## Tagged values

Expand Down
Loading
Loading