From 4d621f83435af43ba9fa228bb87ddf703615fa5d Mon Sep 17 00:00:00 2001 From: Pablo Pardo Garcia Date: Fri, 23 Oct 2026 13:21:15 +0200 Subject: [PATCH 1/4] fix: drop the Anthropic SDK's URL query with capture_content off In its content mode the anthropic SDK writes url.query and url.full with query parameters it treats as content, such as an email or path_prefix filter. On a span whose instrumentation scope starts with com.anthropic.sdk., the masking exporter now treats both as content: with capture_content off url.query is dropped and url.full keeps only its scheme, host and path; with a mask both run through it. Other spans' URLs are left alone. --- src/rius/_native_spans.py | 7 +- src/rius/masking.py | 37 +++- src/rius/semconv.py | 2 + tests/anthropic_native_span_run.py | 23 ++- tests/conformance/_recording.py | 2 +- tests/conformance/_replay.py | 2 +- tests/fixtures/semconv/SOURCE | 2 +- tests/fixtures/semconv/sdk_cases/README.md | 7 +- tests/fixtures/semconv/sdk_cases/masking.json | 167 +++++++++++++++++- tests/test_anthropic_native_span.py | 35 ++++ tests/test_sdk_cases.py | 4 +- 11 files changed, 275 insertions(+), 13 deletions(-) diff --git a/src/rius/_native_spans.py b/src/rius/_native_spans.py index 639ed86..49959cb 100644 --- a/src/rius/_native_spans.py +++ b/src/rius/_native_spans.py @@ -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) @@ -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: @@ -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 diff --git a/src/rius/masking.py b/src/rius/masking.py index 6af0912..fc3a392 100644 --- a/src/rius/masking.py +++ b/src/rius/masking.py @@ -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, @@ -37,6 +38,8 @@ EXCEPTION_EVENT, EXCEPTION_MESSAGE, EXCEPTION_STACKTRACE, + URL_FULL, + URL_QUERY, ) logger = logging.getLogger(__name__) @@ -105,6 +108,27 @@ 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 _without_content(key: str, value: Any) -> Any: + """What is left of a content attribute with content capture off, or None to drop it. + + ``url.full`` keeps its scheme, host and path, since only its query and + fragment are content. Every other key is dropped. + """ + if key == URL_FULL and isinstance(value, str): + return value.split("#", 1)[0].split("?", 1)[0] + return None + + class MaskingSpanExporter(SpanExporter): """Strip or redact content attributes before delegating to ``inner``.""" @@ -129,7 +153,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) @@ -224,7 +253,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: diff --git a/src/rius/semconv.py b/src/rius/semconv.py index 6bd5a72..039b061 100644 --- a/src/rius/semconv.py +++ b/src/rius/semconv.py @@ -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 diff --git a/tests/anthropic_native_span_run.py b/tests/anthropic_native_span_run.py index d694465..2e7fb5b 100644 --- a/tests/anthropic_native_span_run.py +++ b/tests/anthropic_native_span_run.py @@ -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", @@ -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. @@ -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, @@ -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 diff --git a/tests/conformance/_recording.py b/tests/conformance/_recording.py index f58d335..351741a 100644 --- a/tests/conformance/_recording.py +++ b/tests/conformance/_recording.py @@ -1,4 +1,4 @@ -# Vendored from rius-semconv 35a8b108981002c31faa50b14f7107c3fc77eeef fixtures/_recording.py by +# Vendored from rius-semconv 8cf735b34f65747fcfd5fa4c1f6aa5be1a85c3e5 fixtures/_recording.py by # scripts/sync-semconv-fixtures.sh. Do not edit: change it in rius-semconv. """Shared helpers for the recorders under fixtures//record.py. diff --git a/tests/conformance/_replay.py b/tests/conformance/_replay.py index ad2b8a3..e4e809c 100644 --- a/tests/conformance/_replay.py +++ b/tests/conformance/_replay.py @@ -1,4 +1,4 @@ -# Vendored from rius-semconv 35a8b108981002c31faa50b14f7107c3fc77eeef fixtures/_replay.py by +# Vendored from rius-semconv 8cf735b34f65747fcfd5fa4c1f6aa5be1a85c3e5 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). diff --git a/tests/fixtures/semconv/SOURCE b/tests/fixtures/semconv/SOURCE index de9d2fd..a511051 100644 --- a/tests/fixtures/semconv/SOURCE +++ b/tests/fixtures/semconv/SOURCE @@ -1 +1 @@ -rius-semconv 35a8b108981002c31faa50b14f7107c3fc77eeef +rius-semconv 8cf735b34f65747fcfd5fa4c1f6aa5be1a85c3e5 diff --git a/tests/fixtures/semconv/sdk_cases/README.md b/tests/fixtures/semconv/sdk_cases/README.md index 6d97761..45d3e70 100644 --- a/tests/fixtures/semconv/sdk_cases/README.md +++ b/tests/fixtures/semconv/sdk_cases/README.md @@ -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 | @@ -29,6 +29,11 @@ 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` (`masking.json` only): the instrumentation scope name of the span + the case exports. A 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.`), whose `url.query` and `url.full` are + content. ## Tagged values diff --git a/tests/fixtures/semconv/sdk_cases/masking.json b/tests/fixtures/semconv/sdk_cases/masking.json index d5b3cd6..554c1ae 100644 --- a/tests/fixtures/semconv/sdk_cases/masking.json +++ b/tests/fixtures/semconv/sdk_cases/masking.json @@ -1,5 +1,5 @@ { - "about": "What an SDK's masking exporter (MaskingSpanExporter, between the span processors and the OTLP exporter) does to a span's content: its content attributes (registry content keys, e.g. input.value, gen_ai.input.messages), the exception.message and exception.stacktrace of an exception event, and the status description. Non-content attributes, exception.type and the status code always survive. `capture_content` false drops all content whatever the mask. With it true, `mask` (when given) runs over each content value with its key (status.description for the status): `returns` gives that value back, `format` gives the string with {key} and {value} filled in, `throws` raises. A mask that returns null or throws drops the value; a string, number or boolean it returns is kept as it is. `span` is the span as it ends (an ERROR status with `status` as its description when given); `expected` is what reaches the inner exporter: every attribute, the status description (null for none), and every event's attributes.", + "about": "What an SDK's masking exporter (MaskingSpanExporter, between the span processors and the OTLP exporter) does to a span's content: its content attributes (registry content keys, e.g. input.value, gen_ai.input.messages), the exception.message and exception.stacktrace of an exception event, and the status description. Non-content attributes, exception.type and the status code always survive. `capture_content` false drops all content whatever the mask. With it true, `mask` (when given) runs over each content value with its key (status.description for the status): `returns` gives that value back, `format` gives the string with {key} and {value} filled in, `throws` raises. A mask that returns null or throws drops the value; a string, number or boolean it returns is kept as it is. `span` is the span as it ends (an ERROR status with `status` as its description when given); `expected` is what reaches the inner exporter: every attribute, the status description (null for none), and every event's attributes. `scope`, when given, is the instrumentation scope name of the span (another name otherwise). On a span whose scope starts with com.anthropic.sdk. (the Anthropic SDK's own spans), url.query and url.full are content too, as that SDK's content mode leaves query parameters it treats as content in them: `capture_content` false drops url.query and leaves url.full without its query and fragment, and a mask runs over both like any other content value. On any other scope they are not content.", "cases": [ { "name": "capture_on_without_a_mask_keeps_everything", @@ -103,6 +103,171 @@ "status": null } }, + { + "name": "capture_off_drops_the_anthropic_sdk_url_query", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "http.request.method": "GET", + "url.scheme": "https", + "url.path": "/v1/organizations/users", + "url.query": "email=jane%40example.com&limit=20", + "url.full": "https://api.anthropic.com/v1/organizations/users?email=jane%40example.com&limit=20", + "server.address": "api.anthropic.com" + }, + "events": [] + }, + "expected": { + "attributes": { + "http.request.method": "GET", + "url.scheme": "https", + "url.path": "/v1/organizations/users", + "url.full": "https://api.anthropic.com/v1/organizations/users", + "server.address": "api.anthropic.com" + }, + "events": [], + "status": null + } + }, + { + "name": "capture_on_keeps_the_anthropic_sdk_url_query", + "capture_content": true, + "mask": null, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "http.request.method": "GET", + "url.scheme": "https", + "url.path": "/v1/organizations/users", + "url.query": "email=jane%40example.com&limit=20", + "url.full": "https://api.anthropic.com/v1/organizations/users?email=jane%40example.com&limit=20", + "server.address": "api.anthropic.com" + }, + "events": [] + }, + "expected": { + "attributes": { + "http.request.method": "GET", + "url.scheme": "https", + "url.path": "/v1/organizations/users", + "url.query": "email=jane%40example.com&limit=20", + "url.full": "https://api.anthropic.com/v1/organizations/users?email=jane%40example.com&limit=20", + "server.address": "api.anthropic.com" + }, + "events": [], + "status": null + } + }, + { + "name": "mask_sees_the_anthropic_sdk_url_query_and_full_url", + "capture_content": true, + "mask": { + "format": "{key}:{value}" + }, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "http.request.method": "GET", + "url.scheme": "https", + "url.path": "/v1/organizations/users", + "url.query": "email=jane%40example.com&limit=20", + "url.full": "https://api.anthropic.com/v1/organizations/users?email=jane%40example.com&limit=20", + "server.address": "api.anthropic.com" + }, + "events": [] + }, + "expected": { + "attributes": { + "http.request.method": "GET", + "url.scheme": "https", + "url.path": "/v1/organizations/users", + "url.query": "url.query:email=jane%40example.com&limit=20", + "url.full": "url.full:https://api.anthropic.com/v1/organizations/users?email=jane%40example.com&limit=20", + "server.address": "api.anthropic.com" + }, + "events": [], + "status": null + } + }, + { + "name": "capture_off_strips_a_fragment_from_the_anthropic_sdk_full_url", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdk.typescript", + "span": { + "attributes": { + "url.full": "https://api.anthropic.com/v1/models#section" + }, + "events": [] + }, + "expected": { + "attributes": { + "url.full": "https://api.anthropic.com/v1/models" + }, + "events": [], + "status": null + } + }, + { + "name": "capture_off_keeps_the_url_query_of_another_scope", + "capture_content": false, + "mask": null, + "scope": "@opentelemetry/instrumentation-undici", + "span": { + "attributes": { + "http.request.method": "GET", + "url.scheme": "https", + "url.path": "/v1/organizations/users", + "url.query": "email=jane%40example.com&limit=20", + "url.full": "https://api.anthropic.com/v1/organizations/users?email=jane%40example.com&limit=20", + "server.address": "api.anthropic.com" + }, + "events": [] + }, + "expected": { + "attributes": { + "http.request.method": "GET", + "url.scheme": "https", + "url.path": "/v1/organizations/users", + "url.query": "email=jane%40example.com&limit=20", + "url.full": "https://api.anthropic.com/v1/organizations/users?email=jane%40example.com&limit=20", + "server.address": "api.anthropic.com" + }, + "events": [], + "status": null + } + }, + { + "name": "capture_off_keeps_the_url_query_of_a_lookalike_scope", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdkx", + "span": { + "attributes": { + "http.request.method": "GET", + "url.scheme": "https", + "url.path": "/v1/organizations/users", + "url.query": "email=jane%40example.com&limit=20", + "url.full": "https://api.anthropic.com/v1/organizations/users?email=jane%40example.com&limit=20", + "server.address": "api.anthropic.com" + }, + "events": [] + }, + "expected": { + "attributes": { + "http.request.method": "GET", + "url.scheme": "https", + "url.path": "/v1/organizations/users", + "url.query": "email=jane%40example.com&limit=20", + "url.full": "https://api.anthropic.com/v1/organizations/users?email=jane%40example.com&limit=20", + "server.address": "api.anthropic.com" + }, + "events": [], + "status": null + } + }, { "name": "capture_off_ignores_the_mask", "capture_content": false, diff --git a/tests/test_anthropic_native_span.py b/tests/test_anthropic_native_span.py index ed814b5..007f26c 100644 --- a/tests/test_anthropic_native_span.py +++ b/tests/test_anthropic_native_span.py @@ -288,6 +288,41 @@ def test_bridged_to_another_sdk_each_call_still_counts_once(case: str) -> None: assert theirs["attributes"]["error.type"] == "not_found_error" +_PATH_PREFIX = "secret-path-prefix" +_MEMORIES_PATH = "/v1/memory_stores/memstore_1/memories" + + +def _memories_list_span(capture_content: bool) -> dict[str, Any]: + if not _has_native_tracing(): + pytest.skip("this anthropic has no tracing of its own (it starts in 1.14.0)") + spans = _run(content_mode=True, capture_content=capture_content)["cases"].get("memories_list") + if spans is None: + pytest.skip("this anthropic has no memory stores") + (native,) = _native(spans) + return native + + +@pytest.mark.integration +def test_capture_off_drops_the_query_from_the_sdk_url() -> None: + # In its content mode the SDK keeps a query parameter it treats as content + # (path_prefix) in url.query and url.full. With capture_content=False + # neither may carry it; the URL without its query stays. + attributes = _memories_list_span(capture_content=False)["attributes"] + assert _PATH_PREFIX not in json.dumps(attributes), attributes + assert "url.query" not in attributes + assert attributes["url.path"] == _MEMORIES_PATH + assert attributes["url.full"].endswith(_MEMORIES_PATH) + + +@pytest.mark.integration +def test_with_capture_on_the_sdk_url_keeps_its_query() -> None: + # The same call with capture on: the SDK does write the query, so the test + # above is not passing on a run that never recorded it. + attributes = _memories_list_span(capture_content=True)["attributes"] + assert _PATH_PREFIX in attributes["url.query"] + assert _PATH_PREFIX in attributes["url.full"] + + _TOOL_RUNNER_CASES = ("tool_runner", "async_tool_runner") _TOOL_SECRETS = (*_SECRETS, "secret-tool-argument", "secret-tool-output") diff --git a/tests/test_sdk_cases.py b/tests/test_sdk_cases.py index 4104746..47cc39b 100644 --- a/tests/test_sdk_cases.py +++ b/tests/test_sdk_cases.py @@ -174,7 +174,9 @@ def test_masking(case: dict[str, Any], caplog: pytest.LogCaptureFixture) -> None provider.add_span_processor(SimpleSpanProcessor(masking)) given = case["span"] with caplog.at_level(logging.CRITICAL, logger="rius.masking"): - span = provider.get_tracer("cases").start_span("s", attributes=given["attributes"]) + span = provider.get_tracer(case.get("scope", "cases")).start_span( + "s", attributes=given["attributes"] + ) for event in given.get("events", []): span.add_event(event["name"], event["attributes"]) if "status" in given: From 3993c64f71ac38d5752e47ab0b1fe6f2f964547c Mon Sep 17 00:00:00 2001 From: Pablo Pardo Garcia Date: Fri, 23 Oct 2026 14:03:08 +0200 Subject: [PATCH 2/4] fix: strip userinfo from the Anthropic SDK's url.full with capture_content off Drop url.full when it is not a string or nothing is left of it, matching the shared cases. --- src/rius/masking.py | 24 ++++- tests/conformance/_recording.py | 2 +- tests/conformance/_replay.py | 2 +- tests/fixtures/semconv/SOURCE | 2 +- tests/fixtures/semconv/sdk_cases/README.md | 7 +- tests/fixtures/semconv/sdk_cases/masking.json | 88 ++++++++++++++++++- 6 files changed, 115 insertions(+), 10 deletions(-) diff --git a/src/rius/masking.py b/src/rius/masking.py index fc3a392..89344a2 100644 --- a/src/rius/masking.py +++ b/src/rius/masking.py @@ -118,14 +118,32 @@ def is_content_key(key: str) -> bool: _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 nothing is left. + + Without its query and fragment, and without a user name or password + before the host, which the URL convention says must never be recorded. + """ + base = url.split("#", 1)[0].split("?", 1)[0] + authority = base.find("://") + if authority >= 0: + start = authority + 3 + slash = base.find("/", start) + end = len(base) if slash < 0 else slash + at = base.rfind("@", start, end) + if at >= 0: + base = base[:start] + base[at + 1 :] + return base 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. - ``url.full`` keeps its scheme, host and path, since only its query and - fragment are content. Every other key is dropped. + 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 value.split("#", 1)[0].split("?", 1)[0] + return _scheme_host_and_path(value) return None diff --git a/tests/conformance/_recording.py b/tests/conformance/_recording.py index 351741a..ec78123 100644 --- a/tests/conformance/_recording.py +++ b/tests/conformance/_recording.py @@ -1,4 +1,4 @@ -# Vendored from rius-semconv 8cf735b34f65747fcfd5fa4c1f6aa5be1a85c3e5 fixtures/_recording.py by +# Vendored from rius-semconv d206e43a42fed718f22ebf47285e2812f254059d fixtures/_recording.py by # scripts/sync-semconv-fixtures.sh. Do not edit: change it in rius-semconv. """Shared helpers for the recorders under fixtures//record.py. diff --git a/tests/conformance/_replay.py b/tests/conformance/_replay.py index e4e809c..bd1d15a 100644 --- a/tests/conformance/_replay.py +++ b/tests/conformance/_replay.py @@ -1,4 +1,4 @@ -# Vendored from rius-semconv 8cf735b34f65747fcfd5fa4c1f6aa5be1a85c3e5 fixtures/_replay.py by +# Vendored from rius-semconv d206e43a42fed718f22ebf47285e2812f254059d 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). diff --git a/tests/fixtures/semconv/SOURCE b/tests/fixtures/semconv/SOURCE index a511051..9c5f63c 100644 --- a/tests/fixtures/semconv/SOURCE +++ b/tests/fixtures/semconv/SOURCE @@ -1 +1 @@ -rius-semconv 8cf735b34f65747fcfd5fa4c1f6aa5be1a85c3e5 +rius-semconv d206e43a42fed718f22ebf47285e2812f254059d diff --git a/tests/fixtures/semconv/sdk_cases/README.md b/tests/fixtures/semconv/sdk_cases/README.md index 45d3e70..05ad26c 100644 --- a/tests/fixtures/semconv/sdk_cases/README.md +++ b/tests/fixtures/semconv/sdk_cases/README.md @@ -29,9 +29,10 @@ 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` (`masking.json` only): the instrumentation scope name of the span - the case exports. A 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 +- `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.`), whose `url.query` and `url.full` are content. diff --git a/tests/fixtures/semconv/sdk_cases/masking.json b/tests/fixtures/semconv/sdk_cases/masking.json index 554c1ae..22d9172 100644 --- a/tests/fixtures/semconv/sdk_cases/masking.json +++ b/tests/fixtures/semconv/sdk_cases/masking.json @@ -1,5 +1,5 @@ { - "about": "What an SDK's masking exporter (MaskingSpanExporter, between the span processors and the OTLP exporter) does to a span's content: its content attributes (registry content keys, e.g. input.value, gen_ai.input.messages), the exception.message and exception.stacktrace of an exception event, and the status description. Non-content attributes, exception.type and the status code always survive. `capture_content` false drops all content whatever the mask. With it true, `mask` (when given) runs over each content value with its key (status.description for the status): `returns` gives that value back, `format` gives the string with {key} and {value} filled in, `throws` raises. A mask that returns null or throws drops the value; a string, number or boolean it returns is kept as it is. `span` is the span as it ends (an ERROR status with `status` as its description when given); `expected` is what reaches the inner exporter: every attribute, the status description (null for none), and every event's attributes. `scope`, when given, is the instrumentation scope name of the span (another name otherwise). On a span whose scope starts with com.anthropic.sdk. (the Anthropic SDK's own spans), url.query and url.full are content too, as that SDK's content mode leaves query parameters it treats as content in them: `capture_content` false drops url.query and leaves url.full without its query and fragment, and a mask runs over both like any other content value. On any other scope they are not content.", + "about": "What an SDK's masking exporter (MaskingSpanExporter, between the span processors and the OTLP exporter) does to a span's content: its content attributes (registry content keys, e.g. input.value, gen_ai.input.messages), the exception.message and exception.stacktrace of an exception event, and the status description. Non-content attributes, exception.type and the status code always survive. `capture_content` false drops all content whatever the mask. With it true, `mask` (when given) runs over each content value with its key (status.description for the status): `returns` gives that value back, `format` gives the string with {key} and {value} filled in, `throws` raises. A mask that returns null or throws drops the value; a string, number or boolean it returns is kept as it is. `span` is the span as it ends (an ERROR status with `status` as its description when given); `expected` is what reaches the inner exporter: every attribute, the status description (null for none), and every event's attributes. `scope`, when given, is the instrumentation scope name of the span (another name otherwise). On a span whose scope starts with com.anthropic.sdk. (the Anthropic SDK's own spans), url.query and url.full are content too, as that SDK's content mode leaves query parameters it treats as content in them: `capture_content` false drops url.query and leaves url.full as its scheme, host and path: without its query, its fragment and any user name or password before the host, and dropped when that leaves nothing or it is not a string. A mask runs over both like any other content value. On any other scope they are not content.", "cases": [ { "name": "capture_on_without_a_mask_keeps_everything", @@ -268,6 +268,92 @@ "status": null } }, + { + "name": "capture_off_strips_userinfo_from_the_anthropic_sdk_full_url", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "url.full": "https://user:pass@api.anthropic.com/v1/organizations/users?email=jane%40example.com&limit=20" + }, + "events": [] + }, + "expected": { + "attributes": { + "url.full": "https://api.anthropic.com/v1/organizations/users" + }, + "events": [], + "status": null + } + }, + { + "name": "capture_off_drops_a_non_string_anthropic_sdk_full_url", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "url.full": 7, + "url.path": "/v1/organizations/users" + }, + "events": [] + }, + "expected": { + "attributes": { + "url.path": "/v1/organizations/users" + }, + "events": [], + "status": null + } + }, + { + "name": "capture_off_drops_an_anthropic_sdk_full_url_left_empty", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "url.full": "?only=query", + "url.path": "/v1/organizations/users" + }, + "events": [] + }, + "expected": { + "attributes": { + "url.path": "/v1/organizations/users" + }, + "events": [], + "status": null + } + }, + { + "name": "mask_leaves_the_url_query_of_another_scope_alone", + "capture_content": true, + "mask": { + "format": "{key}:{value}" + }, + "scope": "@opentelemetry/instrumentation-undici", + "span": { + "attributes": { + "http.request.method": "GET", + "url.query": "email=jane%40example.com&limit=20", + "url.full": "https://api.anthropic.com/v1/organizations/users?email=jane%40example.com&limit=20", + "input.value": "a" + }, + "events": [] + }, + "expected": { + "attributes": { + "http.request.method": "GET", + "url.query": "email=jane%40example.com&limit=20", + "url.full": "https://api.anthropic.com/v1/organizations/users?email=jane%40example.com&limit=20", + "input.value": "input.value:a" + }, + "events": [], + "status": null + } + }, { "name": "capture_off_ignores_the_mask", "capture_content": false, From 276dc681090b7bcf4b1b4796d20d71ba786241cc Mon Sep 17 00:00:00 2001 From: Pablo Pardo Garcia Date: Fri, 23 Oct 2026 14:14:29 +0200 Subject: [PATCH 3/4] fix: drop the Anthropic SDK's url.full where its userinfo cannot be told apart Strip userinfo before the query cut, and drop a url.full with an @ but no ://, an @ before it, or an authority with both an @ and a ? or #. --- src/rius/masking.py | 33 ++-- tests/conformance/_recording.py | 2 +- tests/conformance/_replay.py | 2 +- tests/fixtures/semconv/SOURCE | 2 +- tests/fixtures/semconv/sdk_cases/masking.json | 143 +++++++++++++++++- 5 files changed, 167 insertions(+), 15 deletions(-) diff --git a/src/rius/masking.py b/src/rius/masking.py index 89344a2..f22a126 100644 --- a/src/rius/masking.py +++ b/src/rius/masking.py @@ -119,21 +119,32 @@ def is_content_key(key: str) -> bool: def _scheme_host_and_path(url: str) -> str | None: - """``url`` as its scheme, host and path, or None when nothing is left. + """``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. """ - base = url.split("#", 1)[0].split("?", 1)[0] - authority = base.find("://") - if authority >= 0: - start = authority + 3 - slash = base.find("/", start) - end = len(base) if slash < 0 else slash - at = base.rfind("@", start, end) - if at >= 0: - base = base[:start] + base[at + 1 :] - return base or None + 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: diff --git a/tests/conformance/_recording.py b/tests/conformance/_recording.py index ec78123..2772fe7 100644 --- a/tests/conformance/_recording.py +++ b/tests/conformance/_recording.py @@ -1,4 +1,4 @@ -# Vendored from rius-semconv d206e43a42fed718f22ebf47285e2812f254059d fixtures/_recording.py by +# Vendored from rius-semconv 0cfdec5e155208da698ded481fb8a060cf8f3643 fixtures/_recording.py by # scripts/sync-semconv-fixtures.sh. Do not edit: change it in rius-semconv. """Shared helpers for the recorders under fixtures//record.py. diff --git a/tests/conformance/_replay.py b/tests/conformance/_replay.py index bd1d15a..da316f3 100644 --- a/tests/conformance/_replay.py +++ b/tests/conformance/_replay.py @@ -1,4 +1,4 @@ -# Vendored from rius-semconv d206e43a42fed718f22ebf47285e2812f254059d fixtures/_replay.py by +# Vendored from rius-semconv 0cfdec5e155208da698ded481fb8a060cf8f3643 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). diff --git a/tests/fixtures/semconv/SOURCE b/tests/fixtures/semconv/SOURCE index 9c5f63c..d2dab6d 100644 --- a/tests/fixtures/semconv/SOURCE +++ b/tests/fixtures/semconv/SOURCE @@ -1 +1 @@ -rius-semconv d206e43a42fed718f22ebf47285e2812f254059d +rius-semconv 0cfdec5e155208da698ded481fb8a060cf8f3643 diff --git a/tests/fixtures/semconv/sdk_cases/masking.json b/tests/fixtures/semconv/sdk_cases/masking.json index 22d9172..9fac5cd 100644 --- a/tests/fixtures/semconv/sdk_cases/masking.json +++ b/tests/fixtures/semconv/sdk_cases/masking.json @@ -1,5 +1,5 @@ { - "about": "What an SDK's masking exporter (MaskingSpanExporter, between the span processors and the OTLP exporter) does to a span's content: its content attributes (registry content keys, e.g. input.value, gen_ai.input.messages), the exception.message and exception.stacktrace of an exception event, and the status description. Non-content attributes, exception.type and the status code always survive. `capture_content` false drops all content whatever the mask. With it true, `mask` (when given) runs over each content value with its key (status.description for the status): `returns` gives that value back, `format` gives the string with {key} and {value} filled in, `throws` raises. A mask that returns null or throws drops the value; a string, number or boolean it returns is kept as it is. `span` is the span as it ends (an ERROR status with `status` as its description when given); `expected` is what reaches the inner exporter: every attribute, the status description (null for none), and every event's attributes. `scope`, when given, is the instrumentation scope name of the span (another name otherwise). On a span whose scope starts with com.anthropic.sdk. (the Anthropic SDK's own spans), url.query and url.full are content too, as that SDK's content mode leaves query parameters it treats as content in them: `capture_content` false drops url.query and leaves url.full as its scheme, host and path: without its query, its fragment and any user name or password before the host, and dropped when that leaves nothing or it is not a string. A mask runs over both like any other content value. On any other scope they are not content.", + "about": "What an SDK's masking exporter (MaskingSpanExporter, between the span processors and the OTLP exporter) does to a span's content: its content attributes (registry content keys, e.g. input.value, gen_ai.input.messages), the exception.message and exception.stacktrace of an exception event, and the status description. Non-content attributes, exception.type and the status code always survive. `capture_content` false drops all content whatever the mask. With it true, `mask` (when given) runs over each content value with its key (status.description for the status): `returns` gives that value back, `format` gives the string with {key} and {value} filled in, `throws` raises. A mask that returns null or throws drops the value; a string, number or boolean it returns is kept as it is. `span` is the span as it ends (an ERROR status with `status` as its description when given); `expected` is what reaches the inner exporter: every attribute, the status description (null for none), and every event's attributes. `scope`, when given, is the instrumentation scope name of the span (another name otherwise). On a span whose scope starts with com.anthropic.sdk. (the Anthropic SDK's own spans), url.query and url.full are content too, as that SDK's content mode leaves query parameters it treats as content in them: `capture_content` false drops url.query and leaves url.full as its scheme, host and path: without its query, its fragment and any user name or password before the host (removed first, so a ? or # inside them does not hide them). url.full is dropped when it is not a string, when nothing is left, and when it has an @ where the host and the user name cannot be told apart: no ://, an @ before it, or an authority (up to the first /) with both an @ and a ? or #. A mask runs over both like any other content value. On any other scope they are not content.", "cases": [ { "name": "capture_on_without_a_mask_keeps_everything", @@ -327,6 +327,147 @@ "status": null } }, + { + "name": "capture_off_drops_an_anthropic_sdk_full_url_whose_userinfo_has_a_fragment_mark", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "url.full": "https://user:pa#ss@api.anthropic.com/v1/organizations/users", + "url.path": "/v1/organizations/users" + }, + "events": [] + }, + "expected": { + "attributes": { + "url.path": "/v1/organizations/users" + }, + "events": [], + "status": null + } + }, + { + "name": "capture_off_drops_an_anthropic_sdk_full_url_with_a_query_and_an_at_before_its_path", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "url.full": "https://api.anthropic.com?email=jane@example.com", + "url.path": "/v1/organizations/users" + }, + "events": [] + }, + "expected": { + "attributes": { + "url.path": "/v1/organizations/users" + }, + "events": [], + "status": null + } + }, + { + "name": "capture_off_drops_a_scheme_relative_anthropic_sdk_full_url_with_userinfo", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "url.full": "//user:pass@api.anthropic.com/v1/organizations/users", + "url.path": "/v1/organizations/users" + }, + "events": [] + }, + "expected": { + "attributes": { + "url.path": "/v1/organizations/users" + }, + "events": [], + "status": null + } + }, + { + "name": "capture_off_drops_an_anthropic_sdk_full_url_with_userinfo_and_no_scheme", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "url.full": "user:pass@api.anthropic.com/v1/organizations/users", + "url.path": "/v1/organizations/users" + }, + "events": [] + }, + "expected": { + "attributes": { + "url.path": "/v1/organizations/users" + }, + "events": [], + "status": null + } + }, + { + "name": "capture_off_drops_an_anthropic_sdk_full_url_with_one_slash_and_userinfo", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "url.full": "https:/user:pass@api.anthropic.com/v1/organizations/users", + "url.path": "/v1/organizations/users" + }, + "events": [] + }, + "expected": { + "attributes": { + "url.path": "/v1/organizations/users" + }, + "events": [], + "status": null + } + }, + { + "name": "capture_off_drops_an_anthropic_sdk_full_url_with_backslashes_and_userinfo", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "url.full": "https:\\\\user:pass@api.anthropic.com/v1/organizations/users", + "url.path": "/v1/organizations/users" + }, + "events": [] + }, + "expected": { + "attributes": { + "url.path": "/v1/organizations/users" + }, + "events": [], + "status": null + } + }, + { + "name": "capture_off_keeps_an_at_in_the_anthropic_sdk_full_url_path", + "capture_content": false, + "mask": null, + "scope": "com.anthropic.sdk.python", + "span": { + "attributes": { + "url.full": "https://api.anthropic.com/v1/files/a@b?x=1", + "url.path": "/v1/organizations/users" + }, + "events": [] + }, + "expected": { + "attributes": { + "url.path": "/v1/organizations/users", + "url.full": "https://api.anthropic.com/v1/files/a@b" + }, + "events": [], + "status": null + } + }, { "name": "mask_leaves_the_url_query_of_another_scope_alone", "capture_content": true, From d7ff1ca5f6d819df0c44afaedf518a0adc044ba9 Mon Sep 17 00:00:00 2001 From: Pablo Pardo Garcia Date: Fri, 23 Oct 2026 14:22:57 +0200 Subject: [PATCH 4/4] fix: pin rius-semconv v1.9.2 --- src/rius/_semconv_generated.py | 16 ++++++++++------ tests/conformance/_recording.py | 2 +- tests/conformance/_replay.py | 2 +- tests/fixtures/semconv/SOURCE | 2 +- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/rius/_semconv_generated.py b/src/rius/_semconv_generated.py index bcda8c8..cfb5f82 100644 --- a/src/rius/_semconv_generated.py +++ b/src/rius/_semconv_generated.py @@ -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. @@ -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", } ) diff --git a/tests/conformance/_recording.py b/tests/conformance/_recording.py index 2772fe7..cf44f41 100644 --- a/tests/conformance/_recording.py +++ b/tests/conformance/_recording.py @@ -1,4 +1,4 @@ -# Vendored from rius-semconv 0cfdec5e155208da698ded481fb8a060cf8f3643 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//record.py. diff --git a/tests/conformance/_replay.py b/tests/conformance/_replay.py index da316f3..4c58535 100644 --- a/tests/conformance/_replay.py +++ b/tests/conformance/_replay.py @@ -1,4 +1,4 @@ -# Vendored from rius-semconv 0cfdec5e155208da698ded481fb8a060cf8f3643 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). diff --git a/tests/fixtures/semconv/SOURCE b/tests/fixtures/semconv/SOURCE index d2dab6d..7a4944a 100644 --- a/tests/fixtures/semconv/SOURCE +++ b/tests/fixtures/semconv/SOURCE @@ -1 +1 @@ -rius-semconv 0cfdec5e155208da698ded481fb8a060cf8f3643 +rius-semconv ebb29c2545f13e804128142458e434351ca12579