Your agent’s traces go to a hosted backend. Every execute_tool span carries the tool’s result as
an attribute: the customer’s email, the account notes, and the key someone pasted into them.
Dropping the attributes blinds the one person debugging a bad run, so the raw spans keep shipping,
and the backend becomes a searchable copy of your customer records.
What you get
You will end up with one OpenTelemetry span processor that hashes or drops the content attributes, the exception message, and the status description. A test reads the exported spans for the leak. This is for you if your traces leave your network.
Short answer
Put a span processor in front of the processor that owns your exporter, and have it forward a redacted copy of each ended span. The GenAI content attributes, the exception message, and the status description become a salted hash, and the stack trace is dropped. Names, timings, token counts, and tool names pass through. Hash rather than drop, so two different inputs still look different, and cover the error path, where the payload shows up twice.
You will need
Python 3.10 or later, an agent traced with the OpenTelemetry SDK, and a backend to read from,
which here is the SDK’s in-memory exporter. Verified 2026-10-21 against Python 3.13.16,
opentelemetry-sdk 1.45.1, opentelemetry-api 1.45.1, and opentelemetry-semantic-conventions
0.66b1, installed under vendor/ by the sample’s npm ci postinstall, so every command carries
PYTHONPATH=vendor. The agent is a stub whose tool returns a fixed record holding an email address
and the access key id AWS prints in its own documentation.1 No model or trace service was
called. Emitting the spans is a separate task, and so is filtering what the agent says to the
user.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Content-capture switch of the GenAI instrumentation | Prompts and results must never leave the process, and nobody will debug from them | All or nothing: off loses every prompt and result, and the variable’s values differ between instrumentation packages | You want the span tree with the content hashed rather than gone |
Langfuse mask_otel_spans hook | Your traces go to Langfuse through its Python SDK, which also exports third-party instrumentation spans | Patches attributes only, so an exception event or a status description passes as it was, and a second exporter gets the raw span | More than one exporter, or a collector in the path |
OpenTelemetry SpanProcessor forwarding a redacted copy | Everything in the process goes through one SDK, whatever instrumented it, and you want one place to enforce the policy | A class you maintain, keyed on attribute names from a convention still marked Development, so a renamed or added attribute leaks until you notice | One SDK’s own hook already reaches every span and event you emit |
| Self-hosted trace backend | The data may not leave your network, and the team reproduces failures from the raw prompt | A database and a service to run, patch, and back up, with the prompts readable by everyone who can open it | A hosted backend is acceptable once the content is hashed |
The capture switch is the cheapest and the bluntest: turned off, it removes the prompt from every span and event, so there is nothing left to hash. Langfuse’s hook runs at export and patches attributes, and its documentation says it cannot change events, so an exception message goes through it untouched. The processor here costs a class you maintain against moving attribute names. Self-hosting keeps the raw data, which is the point when the team reproduces failures from prompts, and makes the backend one more system holding customer records.
Name what is kept, what is hashed, and what is dropped
Three lists make the policy, and the first is implicit. Anything the policy does not name passes
through, so span names, timings, token counts, tool names, and status codes reach the backend as
they are. The hash list holds the six content attributes of the
GenAI conventions, each
of which the registry marks as possibly carrying sensitive information,2 plus
exception.message. The drop list holds exception.stacktrace, because a hash of a stack trace
helps nobody.
# The attribute names come from the GenAI semantic conventions, which are marked Development and
# have moved repositories once already. These six are the ones the registry of semantic conventions
# 1.44.0 warns may hold sensitive information. They live here as data, so a rename is a one-line
# change and not a search through the code that applies them.
GENAI_CONTENT = frozenset(
{
"gen_ai.system_instructions",
"gen_ai.input.messages",
"gen_ai.output.messages",
"gen_ai.retrieval.query.text",
"gen_ai.tool.call.arguments",
"gen_ai.tool.call.result",
}
)
# An exception event carries the raw payload twice: once in exception.message and once more in the
# last line of exception.stacktrace. The message is hashed so two different failures still look
# different; the trace is dropped, because a hash of a stack trace tells nobody anything. The salt
# is a secret from the environment: anyone who knows it can hash a list of guessed emails too.
DEFAULT = Policy(
hash=GENAI_CONTENT | {"exception.message"},
drop=frozenset({"exception.stacktrace"}),
salt=os.environ.get("TRACE_REDACTION_SALT", ""),
)
The fingerprint is sha256 over the value with a salt in front, cut to sixteen hex characters.
Equal inputs give equal fingerprints, so two spans that carried the same prompt still show it.
The salt keeps a short value such as an email address from being recovered by hashing a list of
guesses, which holds only while the salt is secret. It comes from TRACE_REDACTION_SALT, and the
processor refuses to start without one. The attribute names are data rather than code because the conventions are marked Development and have
moved repositories once, so the next rename is a one-line change.
Forward a redacted copy, never the span you were handed
The SDK specification says the span handed to on_end may not be modified, even where the
object is technically writable.3 The processor therefore builds a new ReadableSpan with the
policy applied and passes that to the processor that owns the exporter, which never sees the
original.
def redact_span(span: ReadableSpan, policy: Policy) -> ReadableSpan:
return ReadableSpan(
name=span.name,
context=span.context,
parent=span.parent,
resource=span.resource,
attributes=redact_attributes(span.attributes, policy),
events=[redact_event(e, policy) for e in span.events],
links=span.links,
kind=span.kind,
instrumentation_scope=span.instrumentation_scope,
status=redact_status(span.status, policy),
start_time=span.start_time,
end_time=span.end_time,
)
class RedactingSpanProcessor(SpanProcessor):
"""Wraps the processor that owns the exporter, so the exporter only ever sees the copy."""
def __init__(self, inner: SpanProcessor, policy: Policy = DEFAULT) -> None:
# Without a salt the process stops here, rather than ship hashes a guess list can match.
if not policy.salt:
raise ValueError("no salt: set TRACE_REDACTION_SALT, or pass a Policy that carries one")
self._inner = inner
self._policy = policy
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
self._inner.on_start(span, parent_context)
def on_end(self, span: ReadableSpan) -> None:
self._inner.on_end(redact_span(span, self._policy))
def shutdown(self) -> None:
self._inner.shutdown()
def force_flush(self, timeout_millis: int = 30000) -> bool:
return self._inner.force_flush(timeout_millis)
Wire it as the outermost processor: RedactingSpanProcessor(BatchSpanProcessor(exporter)) in
production, and a SimpleSpanProcessor over an in-memory exporter in the demo, where the exporter
plays the backend. The demo fixes its salt in demo.py so the hashes below reproduce. First, what
leaves the process without it:
PYTHONPATH=vendor python3 demo.py --raw
execute_tool lookup_customer status=UNSET
gen_ai.operation.name = execute_tool
gen_ai.tool.call.arguments = {"id": "cus_418"}
gen_ai.tool.call.id = call_1
gen_ai.tool.call.result = {"id": "cus_418", "email": "jane.doe@example.com", "notes": "legacy key AKIAIOSFODNN7EXAMPLE"}
gen_ai.tool.name = lookup_customer
Then with the processor in place:
PYTHONPATH=vendor python3 demo.py
mode redacted, the task completed; the backend received:
chat stub-model status=UNSET
gen_ai.input.messages = sha256:da7801a070a226b8
gen_ai.operation.name = chat
gen_ai.output.messages = sha256:ec2e4583cfd13796
gen_ai.request.model = stub-model
gen_ai.usage.input_tokens = 212
gen_ai.usage.output_tokens = 31
execute_tool lookup_customer status=UNSET
gen_ai.operation.name = execute_tool
gen_ai.tool.call.arguments = sha256:bde42a76ddcc16b8
gen_ai.tool.call.id = call_1
gen_ai.tool.call.result = sha256:311e7e9b386b62c6
gen_ai.tool.name = lookup_customer
invoke_agent support status=UNSET
app.ticket = T-2041
gen_ai.agent.name = support
gen_ai.operation.name = invoke_agent
gen_ai.output.messages = sha256:4cde44d061298508
The tree, the span names, the token counts, app.ticket, and the tool name are as they were. The
result that held the email and the key is sha256:311e7e9b386b62c6. The arguments hash to the
same value in every run and every mode on this page, because the input was the same.
Redact the error path, where the payload shows up twice
record_exception writes an event named exception with the message and the full stack trace as
attributes, and the description passed to set_status is usually str(exc). A validation error
that quotes the record it rejected therefore ships the record twice more, on the span most likely
to be opened. The failing tool here does exactly that:
PYTHONPATH=vendor python3 demo.py --fail
execute_tool lookup_customer status=ERROR "sha256:68346c442ebe6231"
gen_ai.operation.name = execute_tool
gen_ai.tool.call.arguments = sha256:bde42a76ddcc16b8
gen_ai.tool.call.id = call_1
gen_ai.tool.name = lookup_customer
event exception
exception.escaped = False
exception.message = sha256:68346c442ebe6231
exception.type = LookupError
invoke_agent support status=ERROR "sha256:f9a36ca95402b95f"
app.ticket = T-2041
gen_ai.agent.name = support
gen_ai.operation.name = invoke_agent
The message and both status descriptions are fingerprints, and the stack trace is gone. The agent
span’s description, lookup_customer failed, was harmless and is hashed all the same, because a
processor cannot tell a harmless description from one that quotes a record. That is the cost of a
rule that does not read, and the reason to keep the exception type, which it does.
Check it worked
Thirteen tests read the exported spans the way the backend would. The two that matter assert that
neither the email nor the key appears in any attribute value, event attribute, or status
description. They run the success path and the failure path. Another asserts that the span handed
to on_end is left as it was.
PYTHONPATH=vendor python3 test_redact.py 2>&1
test_a_drop_policy_for_the_status_removes_the_description_and_keeps_the_code (__main__.Redaction.test_a_drop_policy_for_the_status_removes_the_description_and_keeps_the_code) ... ok
test_a_retrieval_query_is_hashed_like_the_messages (__main__.Redaction.test_a_retrieval_query_is_hashed_like_the_messages) ... ok
test_an_attribute_the_policy_does_not_name_passes_through (__main__.Redaction.test_an_attribute_the_policy_does_not_name_passes_through) ... ok
test_equal_inputs_hash_equal_and_different_inputs_differ (__main__.Redaction.test_equal_inputs_hash_equal_and_different_inputs_differ) ... ok
test_nothing_exported_carries_either_value_on_the_error_path (__main__.Redaction.test_nothing_exported_carries_either_value_on_the_error_path) ... ok
test_nothing_exported_carries_either_value_on_the_happy_path (__main__.Redaction.test_nothing_exported_carries_either_value_on_the_happy_path) ... ok
test_the_attributes_only_processor_leaks_through_the_event_and_the_status (__main__.Redaction.test_the_attributes_only_processor_leaks_through_the_event_and_the_status) ... ok
test_the_default_policy_takes_its_salt_from_the_environment (__main__.Redaction.test_the_default_policy_takes_its_salt_from_the_environment) ... ok
test_the_processor_refuses_to_start_without_a_salt (__main__.Redaction.test_the_processor_refuses_to_start_without_a_salt) ... ok
test_the_raw_pipeline_ships_the_email_and_the_key (__main__.Redaction.test_the_raw_pipeline_ships_the_email_and_the_key) ... ok
test_the_salt_changes_the_hash (__main__.Redaction.test_the_salt_changes_the_hash) ... ok
test_the_span_handed_to_on_end_is_left_as_it_was (__main__.Redaction.test_the_span_handed_to_on_end_is_left_as_it_was) ... ok
test_the_structure_survives (__main__.Redaction.test_the_structure_survives) ... ok
When it goes wrong
The key turns up in the backend on a span whose attributes are clean. A policy applied to attributes alone leaves the exception event and the status description as they were, and the error path is where the payload travels twice.
PYTHONPATH=vendor python3 demo.py --fail --attributes-only
execute_tool lookup_customer status=ERROR "record for cus_418 failed validation: {"id": "cus_418", "email": "jane.doe@example.com", "notes": "legacy key AKIAIOS..."
gen_ai.operation.name = execute_tool
gen_ai.tool.call.arguments = sha256:bde42a76ddcc16b8
gen_ai.tool.call.id = call_1
gen_ai.tool.name = lookup_customer
event exception
exception.escaped = False
exception.message = record for cus_418 failed validation: {"id": "cus_418", "email": "jane.doe@example.com", "notes": "legacy key AKIAIOS...
exception.stacktrace = ..., last line: LookupError: record for cus_418 failed validation: {"id": "cus_418", "email": "jane.doe@example.com",...
exception.type = LookupError
--attributes-only is the processor on this page with the event and status steps removed, which
is also the reach of a Langfuse mask_otel_spans hook, since its documentation says it changes
attributes only. Redact the event and the status in the same place as the attributes.
Content turns up in log records that no span processor saw. The capture switch,
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, decides where content goes as well as
whether. In opentelemetry-instrumentation-openai-v2
it takes true for the legacy events, and span_only, event_only, or span_and_event once
OTEL_SEMCONV_STABILITY_OPT_IN includes gen_ai_latest_experimental.
opentelemetry-util-genai spells the same
choices NO_CONTENT, SPAN_ONLY, EVENT_ONLY, and SPAN_AND_EVENT. An event setting routes the
prompt through the logs pipeline, so either set span_only or put the same policy in a log record
processor.
A second exporter ships the raw copy. The processor wraps one exporter-owning processor, and a second one added to the provider, for a collector or a vendor SDK, receives the original span. The test that reads the exported spans is the check: run it against every exporter the provider has, or wrap each of them.
When not to do this
Do not redact at the call sites. A policy in forty set_attribute calls is forty places to
forget, and the exception event is written by the SDK rather than by you. One processor sees
every span, whatever instrumented it.
Do not drop what you can hash. A dropped prompt leaves a reviewer with a span name and a duration, while a fingerprint still says that two failing runs carried the same prompt and a third did not.
Do not run a detector such as Presidio over fields you already know are content. Its recognizers find email addresses, card numbers, and phone numbers in free text by pattern and context. Its entity list has no AWS key, so the record on this page would have passed with the key in it. A detector earns its latency on free text whose shape you do not control.
Do not treat the capture switch as the redaction. Off is all or nothing, and the exception message is not a message-content setting.
Do not hash when the team needs the raw prompt to reproduce a failure. Self-host the backend inside the network instead, and accept that it then holds customer records.
Related how-tos
Last verified
Verified 2026-10-21 against Python 3.13.16, opentelemetry-sdk 1.45.1, opentelemetry-api
1.45.1, and opentelemetry-semantic-conventions 0.66b1. Every output block is what the command preceding
it printed. The agent is a stub and the backend is the SDK’s in-memory exporter, so no model and
no trace service was called. The behavior of the Langfuse hook, the two instrumentation packages’
capture switch, and Presidio is taken from their documentation.
Footnotes
-
AWS’s IAM guide gives
AKIAIOSFODNN7EXAMPLEas its example access key id andwJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEYas the matching secret. The sample uses the first because any scanner that knows the shape of an AWS key knows this one, so a trace that still contains it has not been scanned. ↩︎ Back to text -
The attribute registry on opentelemetry.io keeps every
gen_aiattribute beside a notice that it has moved to the GenAI conventions repository. It attaches “This attribute may contain sensitive information” to system instructions, the retrieval query, tool call arguments, and tool call results. For the input and output messages it says “likely to contain sensitive information including user/PII data” instead. The conventions themselves live in open-telemetry/semantic-conventions-genai, whose README has a placeholder where its schema URL should be. ↩︎ Back to text -
The specification’s note on
OnEndsays that “modifying it is not allowed” even where the span “may be technically writable” at that point. The hook for changing a span before it ends,OnEnding, is marked Development and hands over a read/write span. The Python SDK at 1.45.1 implements it as_on_ending, with the underscore that marks it private, which is why the processor on this page copies. ↩︎ Back to text