From 7d8ddf18cd417cb8be5e094038912e51a0bd282d Mon Sep 17 00:00:00 2001 From: Pablo Pardo Garcia Date: Wed, 12 Aug 2026 13:38:08 +0200 Subject: [PATCH] fix: cover bare llm.prompts and llm.prompt_template in content controls The prefix entries (llm.prompts., llm.prompt_template.) only match flattened indexed keys; an instrumentation that sets the bare attribute carried prompts past capture_content=False and mask. Both bare keys join CONTENT_ATTRIBUTES, and a new invariant test asserts every content prefix's bare key is covered, so the class of gap cannot recur. --- src/rius/semconv.py | 5 +++++ tests/test_masking.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/rius/semconv.py b/src/rius/semconv.py index 06b6110..2bfe5e8 100644 --- a/src/rius/semconv.py +++ b/src/rius/semconv.py @@ -83,6 +83,11 @@ "gen_ai.completion", "llm.input_messages", "llm.output_messages", + # bare (unflattened) forms of the prefix-covered families below: + # instrumentations vary on whether they flatten these into indexed + # keys, and a prefix match never covers its own bare key + "llm.prompts", + "llm.prompt_template", "mlflow.spanInputs", "mlflow.spanOutputs", # OpenLLMetry workflow/task spans carry full I/O here diff --git a/tests/test_masking.py b/tests/test_masking.py index 546d006..a090ed4 100644 --- a/tests/test_masking.py +++ b/tests/test_masking.py @@ -121,6 +121,35 @@ def test_capture_content_false_covers_retriever_embedding_and_traceloop_keys() - assert attrs["retrieval.documents.0.document.id"] == "doc-1" +def test_capture_content_false_covers_unflattened_prompt_keys() -> None: + # Instrumentations do not always flatten llm.prompts into indexed + # llm.prompts.0 keys; the bare key must be covered too, or the privacy + # switch fails open on exactly the attribute that carries every prompt. + inner = InMemorySpanExporter() + client = init(span_exporter=inner, set_global=False, capture_content=False) + with client.get_tracer().start_as_current_span("op") as span: + span.set_attribute("llm.prompts", '["secret prompt one", "secret prompt two"]') + span.set_attribute("llm.prompt_template", "Answer as {persona}: {question}") + client.flush() + attrs = inner.get_finished_spans()[0].attributes + assert "llm.prompts" not in attrs + assert "llm.prompt_template" not in attrs + + +def test_every_content_prefix_has_its_bare_key_covered() -> None: + # The invariant whose silent violation caused the llm.prompts gap: a + # family covered by prefix (indexed keys) must also cover the bare, + # unflattened attribute the prefix is derived from. + from rius.semconv import CONTENT_ATTRIBUTE_PREFIXES, CONTENT_ATTRIBUTES + + missing = { + prefix.rstrip(".") + for prefix in CONTENT_ATTRIBUTE_PREFIXES + if prefix.rstrip(".") not in CONTENT_ATTRIBUTES + } + assert not missing, f"prefixes without their bare key in CONTENT_ATTRIBUTES: {missing}" + + def test_mask_accepting_key_receives_attribute_key() -> None: seen: list[str] = []