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/_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/src/rius/masking.py b/src/rius/masking.py index 6af0912..f22a126 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,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``.""" @@ -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) @@ -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: 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..cf44f41 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 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 ad2b8a3..4c58535 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 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 de9d2fd..7a4944a 100644 --- a/tests/fixtures/semconv/SOURCE +++ b/tests/fixtures/semconv/SOURCE @@ -1 +1 @@ -rius-semconv 35a8b108981002c31faa50b14f7107c3fc77eeef +rius-semconv ebb29c2545f13e804128142458e434351ca12579 diff --git a/tests/fixtures/semconv/sdk_cases/README.md b/tests/fixtures/semconv/sdk_cases/README.md index 6d97761..05ad26c 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,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.`), 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..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.", + "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", @@ -103,6 +103,398 @@ "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_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": "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, + "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, 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: