Skip to content

Commit c1c0b67

Browse files
feat(llmobs): allow span processor to return None to omit spans (#14260)
Allow users to omit any traces they want by returning none in the user_span_processor method. Motivation: I am trying to prevent some auto traces from cluttering my Datadog observability dashboards. These traces create noise and make it harder to focus on the more critical traces. My LLM observability overview is filled with what is clustered as empty input which is incorrect. e.g. all embedding traces are are just a spammer. To do that, I am using the new span_processor, and the new way to omit specific spans, will be to return null by the relevant span processor. I have added a test for that. The only risk is that there is no indication that span was omitted but I was afraid that a debug log would be too spammy. But please let me know if that will help. Also there are some telemetric collected about sent span which might also need to be changed, will be great to hear your thoughts Co-authored-by: amirbenami <amir@linxsecurity.io>
1 parent d36f0a6 commit c1c0b67

File tree

3 files changed

+45
-9
lines changed

3 files changed

+45
-9
lines changed

ddtrace/llmobs/_llmobs.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,10 @@ class LLMObsSpan:
145145
Passed to the `span_processor` function in the `enable` or `register_processor` methods.
146146
147147
Example::
148-
def span_processor(span: LLMObsSpan) -> LLMObsSpan:
148+
def span_processor(span: LLMObsSpan) -> Optional[LLMObsSpan]:
149+
# Modify input/output
150+
if span.get_tag("omit_span") == "1":
151+
return None
149152
if span.get_tag("no_input") == "1":
150153
span.input = []
151154
return span
@@ -178,7 +181,7 @@ class LLMObs(Service):
178181
def __init__(
179182
self,
180183
tracer: Optional[Tracer] = None,
181-
span_processor: Optional[Callable[[LLMObsSpan], LLMObsSpan]] = None,
184+
span_processor: Optional[Callable[[LLMObsSpan], Optional[LLMObsSpan]]] = None,
182185
) -> None:
183186
super(LLMObs, self).__init__()
184187
self.tracer = tracer or ddtrace.tracer
@@ -230,6 +233,8 @@ def _submit_llmobs_span(self, span: Span) -> None:
230233
span_event = None
231234
try:
232235
span_event = self._llmobs_span_event(span)
236+
if span_event is None:
237+
return
233238
self._llmobs_span_writer.enqueue(span_event)
234239
except (KeyError, TypeError, ValueError):
235240
log.error(
@@ -241,7 +246,7 @@ def _submit_llmobs_span(self, span: Span) -> None:
241246
if self._evaluator_runner:
242247
self._evaluator_runner.enqueue(span_event, span)
243248

244-
def _llmobs_span_event(self, span: Span) -> LLMObsSpanEvent:
249+
def _llmobs_span_event(self, span: Span) -> Optional[LLMObsSpanEvent]:
245250
"""Span event object structure."""
246251
span_kind = span._get_ctx_item(SPAN_KIND)
247252
if not span_kind:
@@ -321,8 +326,12 @@ def _llmobs_span_event(self, span: Span) -> LLMObsSpanEvent:
321326
try:
322327
llmobs_span._tags = cast(Dict[str, str], span._get_ctx_item(TAGS))
323328
user_llmobs_span = self._user_span_processor(llmobs_span)
329+
if user_llmobs_span is None:
330+
return None
324331
if not isinstance(user_llmobs_span, LLMObsSpan):
325-
raise TypeError("User span processor must return an LLMObsSpan, got %r" % type(user_llmobs_span))
332+
raise TypeError(
333+
"User span processor must return an LLMObsSpan or None, got %r" % type(user_llmobs_span)
334+
)
326335
llmobs_span = user_llmobs_span
327336
except Exception as e:
328337
log.error("Error in LLMObs span processor (%r): %r", self._user_span_processor, e)
@@ -488,7 +497,7 @@ def enable(
488497
project_name: Optional[str] = None,
489498
env: Optional[str] = None,
490499
service: Optional[str] = None,
491-
span_processor: Optional[Callable[[LLMObsSpan], LLMObsSpan]] = None,
500+
span_processor: Optional[Callable[[LLMObsSpan], Optional[LLMObsSpan]]] = None,
492501
_tracer: Optional[Tracer] = None,
493502
_auto: bool = False,
494503
) -> None:
@@ -505,8 +514,8 @@ def enable(
505514
:param str project_name: Your project name used for experiments.
506515
:param str env: Your environment name.
507516
:param str service: Your service name.
508-
:param Callable[[LLMObsSpan], LLMObsSpan] span_processor: A function that takes an LLMObsSpan and returns an
509-
LLMObsSpan.
517+
:param Callable[[LLMObsSpan], Optional[LLMObsSpan]] span_processor: A function that takes an LLMObsSpan and
518+
returns an LLMObsSpan or None. If None is returned, the span will be omitted and not sent to LLMObs.
510519
"""
511520
if cls.enabled:
512521
log.debug("%s already enabled", cls.__name__)
@@ -741,14 +750,16 @@ def experiment(
741750
)
742751

743752
@classmethod
744-
def register_processor(cls, processor: Optional[Callable[[LLMObsSpan], LLMObsSpan]] = None) -> None:
753+
def register_processor(cls, processor: Optional[Callable[[LLMObsSpan], Optional[LLMObsSpan]]] = None) -> None:
745754
"""Register a processor to be called on each LLMObs span.
746755
747756
This can be used to modify the span before it is sent to LLMObs. For example, you can modify the input/output.
757+
You can also return None to omit the span entirely from being sent to LLMObs.
748758
749759
To deregister the processor, call `register_processor(None)`.
750760
751-
:param processor: A function that takes an LLMObsSpan and returns an LLMObsSpan.
761+
:param processor: A function that takes an LLMObsSpan and returns an LLMObsSpan or None.
762+
If None is returned, the span will be omitted and not sent to LLMObs.
752763
"""
753764
cls._instance._user_span_processor = processor
754765

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
features:
3+
- |
4+
LLM Observability: add ability to drop spans by having a ``SpanProcessor`` return ``None``.

tests/llmobs/test_llmobs.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import os
33
from textwrap import dedent
4+
from typing import Optional
45

56
import pytest
67

@@ -167,6 +168,26 @@ def test_processor_bad_return_type(self, llmobs, llmobs_enable_opts, llmobs_even
167168
assert llmobs_events[0]["meta"]["input"] == {"messages": [{"content": "value", "role": ""}]}
168169
assert llmobs_events[0]["meta"]["output"] == {"messages": [{"content": "value", "role": ""}]}
169170

171+
def _omit_span_processor(span: LLMObsSpan) -> Optional[LLMObsSpan]:
172+
if span.get_tag("omit_span") == "true":
173+
return None
174+
return span
175+
176+
@pytest.mark.parametrize("llmobs_enable_opts", [dict(span_processor=_omit_span_processor)])
177+
def test_processor_omit_span(self, llmobs, llmobs_enable_opts, llmobs_events):
178+
"""Test that a processor that returns None omits the span from being sent."""
179+
# Create a span that should be omitted
180+
with llmobs.llm() as llm_span:
181+
llmobs.annotate(llm_span, input_data="omit me", output_data="response", tags={"omit_span": "true"})
182+
183+
# Create a span that should be kept
184+
with llmobs.llm() as llm_span:
185+
llmobs.annotate(llm_span, input_data="keep me", output_data="response", tags={"omit_span": "false"})
186+
187+
# Only the second span should be in the events
188+
assert len(llmobs_events) == 1
189+
assert llmobs_events[0]["meta"]["input"]["messages"][0]["content"] == "keep me"
190+
170191
def test_ddtrace_run_register_processor(self, ddtrace_run_python_code_in_subprocess, llmobs_backend):
171192
"""Users using ddtrace-run can register a processor to be called on each LLMObs span."""
172193
env = os.environ.copy()

0 commit comments

Comments
 (0)