Skip to content

Commit 71e53e0

Browse files
Yun-Kimclaude
andcommitted
fix(llmobs): clear meta_struct on user-processor span omit
When a user span_processor returns None to drop an LLMObs span, also clear meta_struct["_llmobs"] off the underlying APM span. Previously the early return in _prepare_llmobs_span_data left the LLMObs payload in place. That's a no-op today (the LLMObs writer never enqueued, and APM doesn't read meta_struct["_llmobs"] yet), but it becomes a leak post-convergence (MLOB-4925): once APM ships meta_struct["_llmobs"] to the LLMObs backend, an "omitted" span whose payload was left intact would arrive at LLMObs anyway — exactly the data the user told us to drop. The scrub is unconditional: it overrides _DD_LLMOBS_TEST_KEEP_META_STRUCT because the omit contract is "no LLMObs data on this span", regardless of test mode. The env var only suppresses the kept-path scrub (where data must survive for assertions). Rewrites test_processor_omit_span to assert directly on the contract: omit ⇒ _get_llmobs_data_metastruct(span) == {}, kept ⇒ payload intact. The test no longer references llmobs_events, so the only remaining llmobs_events callsite in tests/llmobs/ is test_malformed_span_logs_error_instead_of_raising — which legitimately checks "no event was emitted" for a malformed span and stays on the existing fixture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4f2a51a commit 71e53e0

2 files changed

Lines changed: 17 additions & 14 deletions

File tree

ddtrace/llmobs/_llmobs.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,10 @@ def _prepare_llmobs_span_data(self, span: Span, span_kind: Optional[str]) -> boo
593593
user_processed_span = self._apply_user_span_processor(span, llmobs_span)
594594
if user_processed_span is None:
595595
log.debug("LLMObs span %s dropped by user processor", span)
596+
# Clear LLMObs payload so the underlying APM span carries no _llmobs meta_struct.
597+
# Otherwise, when this span ships through the APM trace writer (post-convergence),
598+
# the LLMObs backend would receive data the user's processor told us to drop.
599+
span._remove_struct_tag(LLMOBS_STRUCT.KEY)
596600
return False
597601

598602
_normalize_llmobs_meta(

tests/llmobs/test_llmobs.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -191,20 +191,19 @@ def _omit_span_processor(span: LLMObsSpan) -> Optional[LLMObsSpan]:
191191
return span
192192

193193
@pytest.mark.parametrize("llmobs_enable_opts", [dict(span_processor=_omit_span_processor)])
194-
def test_processor_omit_span(self, llmobs, llmobs_enable_opts, llmobs_events):
195-
"""Test that a processor that returns None omits the span from being sent."""
196-
# Create a span that should be omitted
197-
with llmobs.llm() as llm_span:
198-
llmobs.annotate(llm_span, input_data="omit me", output_data="response", tags={"omit_span": "true"})
199-
200-
# Create a span that should be kept
201-
with llmobs.llm() as llm_span:
202-
llmobs.annotate(llm_span, input_data="keep me", output_data="response", tags={"omit_span": "false"})
203-
204-
# Only the second span should be in the events (suppression is a side effect on the wire,
205-
# not on the meta_struct payload — keep this assertion on llmobs_events).
206-
assert len(llmobs_events) == 1
207-
assert llmobs_events[0]["meta"]["input"]["messages"][0]["content"] == "keep me"
194+
def test_processor_omit_span(self, llmobs, llmobs_enable_opts):
195+
"""Test that a processor that returns None clears the LLMObs payload off the span."""
196+
with llmobs.llm() as omit_span:
197+
llmobs.annotate(omit_span, input_data="omit me", output_data="response", tags={"omit_span": "true"})
198+
199+
with llmobs.llm() as keep_span:
200+
llmobs.annotate(keep_span, input_data="keep me", output_data="response", tags={"omit_span": "false"})
201+
202+
# Dropped: meta_struct["_llmobs"] is cleared so the APM-shipped span
203+
# carries no LLMObs data to the backend.
204+
assert _get_llmobs_data_metastruct(omit_span) == {}
205+
# Kept: payload is intact for export.
206+
assert get_llmobs_input_messages(keep_span) == [{"content": "keep me", "role": ""}]
208207

209208
def test_ddtrace_run_register_processor(self, ddtrace_run_python_code_in_subprocess, llmobs_backend):
210209
"""Users using ddtrace-run can register a processor to be called on each LLMObs span."""

0 commit comments

Comments
 (0)