Skip to content

Commit ed77151

Browse files
lievanlievan
authored andcommitted
chore(llmobs): automatically set span links with decorators (#12255)
Re-opening #12043 due to conflicts with main after 3.x-staging was merged in Decorators set span links by tracking common objects passed as inputs & outputs of functions. This functionality will be gated behind the environment variable `_DD_LLMOBS_AUTO_SPAN_LINKING_ENABLED` We maintain a dictionary in the LLMObs service that remembers which objects are used as the input/output for a spans generated by LLM Obs decorators. This is how we record the **from** direction of span links. When objects are encountered again as the input/output for another span, we now know to set the **to** direction for a span link. In my opinion, this does not need to be gated behind a feature flag since it's a read-only on app data. A follow up PR will mutate data actually used in the user app for enhanced span link inferencing, and the features introduced then should be gated by a flag. Implementation notes: - Objects are remembered by generating a string object id through the type + memory location of the object. - We ignore "input" -> "output" edges. This is not a valid edge. - Spans can only be linked to other spans belonging to the same trace Limitations: - it is very easy for link information to be lost if an object is mutated or used to create another object. A follow up PR will implement a best-effort attempt for objects to inherit link info from other objects - doesn't work for distributed tracing scenarios ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --------- Co-authored-by: lievan <[email protected]>
1 parent ad2c2f4 commit ed77151

File tree

8 files changed

+188
-4
lines changed

8 files changed

+188
-4
lines changed

ddtrace/llmobs/_llmobs.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
from ddtrace.llmobs._context import LLMObsContextProvider
5858
from ddtrace.llmobs._evaluators.runner import EvaluatorRunner
5959
from ddtrace.llmobs._utils import AnnotationContext
60+
from ddtrace.llmobs._utils import LinkTracker
6061
from ddtrace.llmobs._utils import _get_ml_app
6162
from ddtrace.llmobs._utils import _get_session_id
6263
from ddtrace.llmobs._utils import _get_span_name
@@ -112,6 +113,7 @@ def __init__(self, tracer=None):
112113

113114
forksafe.register(self._child_after_fork)
114115

116+
self._link_tracker = LinkTracker()
115117
self._annotations = []
116118
self._annotation_context_lock = forksafe.RLock()
117119

@@ -208,7 +210,7 @@ def _llmobs_span_event(cls, span: Span) -> Dict[str, Any]:
208210
llmobs_span_event["tags"] = cls._llmobs_tags(span, ml_app, session_id)
209211

210212
span_links = span._get_ctx_item(SPAN_LINKS)
211-
if isinstance(span_links, list):
213+
if isinstance(span_links, list) and span_links:
212214
llmobs_span_event["span_links"] = span_links
213215

214216
return llmobs_span_event
@@ -397,6 +399,55 @@ def disable(cls) -> None:
397399

398400
log.debug("%s disabled", cls.__name__)
399401

402+
def _record_object(self, span, obj, input_or_output):
403+
if obj is None:
404+
return
405+
span_links = []
406+
for span_link in self._link_tracker.get_span_links_from_object(obj):
407+
try:
408+
if span_link["attributes"]["from"] == "input" and input_or_output == "output":
409+
continue
410+
except KeyError:
411+
log.debug("failed to read span link: ", span_link)
412+
continue
413+
span_links.append(
414+
{
415+
"trace_id": span_link["trace_id"],
416+
"span_id": span_link["span_id"],
417+
"attributes": {
418+
"from": span_link["attributes"]["from"],
419+
"to": input_or_output,
420+
},
421+
}
422+
)
423+
self._tag_span_links(span, span_links)
424+
self._link_tracker.add_span_links_to_object(
425+
obj,
426+
[
427+
{
428+
"trace_id": self.export_span(span)["trace_id"],
429+
"span_id": self.export_span(span)["span_id"],
430+
"attributes": {
431+
"from": input_or_output,
432+
},
433+
}
434+
],
435+
)
436+
437+
def _tag_span_links(self, span, span_links):
438+
if not span_links:
439+
return
440+
span_links = [
441+
span_link
442+
for span_link in span_links
443+
if span_link["span_id"] != LLMObs.export_span(span)["span_id"]
444+
and span_link["trace_id"] == LLMObs.export_span(span)["trace_id"]
445+
]
446+
current_span_links = span._get_ctx_item(SPAN_LINKS)
447+
if current_span_links:
448+
span_links = current_span_links + span_links
449+
span._set_ctx_item(SPAN_LINKS, span_links)
450+
400451
@classmethod
401452
def annotation_context(
402453
cls, tags: Optional[Dict[str, Any]] = None, prompt: Optional[dict] = None, name: Optional[str] = None

ddtrace/llmobs/_utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,23 @@ def validate_prompt(prompt: dict) -> Dict[str, Union[str, dict, List[str]]]:
7070
return validated_prompt
7171

7272

73+
class LinkTracker:
74+
def __init__(self, object_span_links=None):
75+
self._object_span_links = object_span_links or {}
76+
77+
def get_object_id(self, obj):
78+
return f"{type(obj).__name__}_{id(obj)}"
79+
80+
def add_span_links_to_object(self, obj, span_links):
81+
obj_id = self.get_object_id(obj)
82+
if obj_id not in self._object_span_links:
83+
self._object_span_links[obj_id] = []
84+
self._object_span_links[obj_id] += span_links
85+
86+
def get_span_links_from_object(self, obj):
87+
return self._object_span_links.get(self.get_object_id(obj), [])
88+
89+
7390
class AnnotationContext:
7491
def __init__(self, _register_annotator, _deregister_annotator):
7592
self._register_annotator = _register_annotator

ddtrace/llmobs/decorators.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Callable
66
from typing import Optional
77

8+
from ddtrace import config
89
from ddtrace.internal.compat import iscoroutinefunction
910
from ddtrace.internal.compat import isgeneratorfunction
1011
from ddtrace.internal.logger import get_logger
@@ -138,8 +139,16 @@ def wrapper(*args, **kwargs):
138139
name=span_name,
139140
session_id=session_id,
140141
ml_app=ml_app,
141-
):
142-
return func(*args, **kwargs)
142+
) as span:
143+
if config._llmobs_auto_span_linking_enabled:
144+
for arg in args:
145+
LLMObs._instance._record_object(span, arg, "input")
146+
for arg in kwargs.values():
147+
LLMObs._instance._record_object(span, arg, "input")
148+
ret = func(*args, **kwargs)
149+
if config._llmobs_auto_span_linking_enabled:
150+
LLMObs._instance._record_object(span, ret, "output")
151+
return ret
143152

144153
return generator_wrapper if (isgeneratorfunction(func) or isasyncgenfunction(func)) else wrapper
145154

@@ -231,6 +240,11 @@ def wrapper(*args, **kwargs):
231240
_, span_name = _get_llmobs_span_options(name, None, func)
232241
traced_operation = getattr(LLMObs, operation_kind, LLMObs.workflow)
233242
with traced_operation(name=span_name, session_id=session_id, ml_app=ml_app) as span:
243+
if config._llmobs_auto_span_linking_enabled:
244+
for arg in args:
245+
LLMObs._instance._record_object(span, arg, "input")
246+
for arg in kwargs.values():
247+
LLMObs._instance._record_object(span, arg, "input")
234248
func_signature = signature(func)
235249
bound_args = func_signature.bind_partial(*args, **kwargs)
236250
if _automatic_io_annotation and bound_args.arguments:
@@ -243,6 +257,8 @@ def wrapper(*args, **kwargs):
243257
and span._get_ctx_item(OUTPUT_VALUE) is None
244258
):
245259
LLMObs.annotate(span=span, output_data=resp)
260+
if config._llmobs_auto_span_linking_enabled:
261+
LLMObs._instance._record_object(span, resp, "output")
246262
return resp
247263

248264
return generator_wrapper if (isgeneratorfunction(func) or isasyncgenfunction(func)) else wrapper

ddtrace/settings/_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ def __init__(self):
549549
self._llmobs_sample_rate = _get_config("DD_LLMOBS_SAMPLE_RATE", 1.0, float)
550550
self._llmobs_ml_app = _get_config("DD_LLMOBS_ML_APP")
551551
self._llmobs_agentless_enabled = _get_config("DD_LLMOBS_AGENTLESS_ENABLED", False, asbool)
552+
self._llmobs_auto_span_linking_enabled = _get_config("_DD_LLMOBS_AUTO_SPAN_LINKING_ENABLED", False, asbool)
552553

553554
self._inject_force = _get_config("DD_INJECT_FORCE", False, asbool)
554555
self._lib_was_injected = False

tests/llmobs/_utils.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,13 @@ def _expected_llmobs_non_llm_span_event(
168168

169169

170170
def _llmobs_base_span_event(
171-
span, span_kind, tags=None, session_id=None, error=None, error_message=None, error_stack=None
171+
span,
172+
span_kind,
173+
tags=None,
174+
session_id=None,
175+
error=None,
176+
error_message=None,
177+
error_stack=None,
172178
):
173179
span_event = {
174180
"trace_id": "{:x}".format(span.trace_id),
@@ -776,3 +782,11 @@ def _expected_ragas_answer_relevancy_spans(ragas_inputs=None):
776782
"_dd": {"span_id": mock.ANY, "trace_id": mock.ANY},
777783
},
778784
]
785+
786+
787+
def _expected_span_link(span_event, link_from, link_to):
788+
return {
789+
"trace_id": span_event["trace_id"],
790+
"span_id": span_event["span_id"],
791+
"attributes": {"from": link_from, "to": link_to},
792+
}

tests/llmobs/test_llmobs_decorators.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111
from ddtrace.llmobs.decorators import workflow
1212
from tests.llmobs._utils import _expected_llmobs_llm_span_event
1313
from tests.llmobs._utils import _expected_llmobs_non_llm_span_event
14+
from tests.llmobs._utils import _expected_span_link
15+
from tests.utils import override_global_config
16+
17+
18+
@pytest.fixture
19+
def auto_linking_enabled():
20+
with override_global_config(dict(_llmobs_auto_span_linking_enabled=True)):
21+
yield
1422

1523

1624
@pytest.fixture
@@ -828,3 +836,78 @@ def get_next_element(alist):
828836
error_message=span.get_tag("error.message"),
829837
error_stack=span.get_tag("error.stack"),
830838
)
839+
840+
841+
def test_decorator_records_span_links(llmobs, llmobs_events, auto_linking_enabled):
842+
@workflow
843+
def one(inp):
844+
return 1
845+
846+
@task
847+
def two(inp):
848+
return inp
849+
850+
with llmobs.agent("dummy_trace"):
851+
two(one("test_input"))
852+
853+
one_span = llmobs_events[0]
854+
two_span = llmobs_events[1]
855+
856+
assert "span_links" not in one_span
857+
assert len(two_span["span_links"]) == 2
858+
assert two_span["span_links"][0] == _expected_span_link(one_span, "output", "input")
859+
assert two_span["span_links"][1] == _expected_span_link(one_span, "output", "output")
860+
861+
862+
def test_decorator_records_span_links_for_multi_input_functions(llmobs, llmobs_events, auto_linking_enabled):
863+
@agent
864+
def some_agent(a, b):
865+
pass
866+
867+
@workflow
868+
def one():
869+
return 1
870+
871+
@task
872+
def two():
873+
return 2
874+
875+
with llmobs.agent("dummy_trace"):
876+
some_agent(one(), two())
877+
878+
one_span = llmobs_events[0]
879+
two_span = llmobs_events[1]
880+
three_span = llmobs_events[2]
881+
882+
assert "span_links" not in one_span
883+
assert "span_links" not in two_span
884+
assert len(three_span["span_links"]) == 2
885+
assert three_span["span_links"][0] == _expected_span_link(one_span, "output", "input")
886+
assert three_span["span_links"][1] == _expected_span_link(two_span, "output", "input")
887+
888+
889+
def test_decorator_records_span_links_via_kwargs(llmobs, llmobs_events, auto_linking_enabled):
890+
@agent
891+
def some_agent(a=None, b=None):
892+
pass
893+
894+
@workflow
895+
def one():
896+
return 1
897+
898+
@task
899+
def two():
900+
return 2
901+
902+
with llmobs.agent("dummy_trace"):
903+
some_agent(one(), two())
904+
905+
one_span = llmobs_events[0]
906+
two_span = llmobs_events[1]
907+
three_span = llmobs_events[2]
908+
909+
assert "span_links" not in one_span
910+
assert "span_links" not in two_span
911+
assert len(three_span["span_links"]) == 2
912+
assert three_span["span_links"][0] == _expected_span_link(one_span, "output", "input")
913+
assert three_span["span_links"][1] == _expected_span_link(two_span, "output", "input")

tests/telemetry/test_writer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@ def test_app_started_event_configuration_override(test_agent_session, run_python
471471
{"name": "_DD_APPSEC_DEDUPLICATION_ENABLED", "origin": "default", "value": True},
472472
{"name": "_DD_IAST_LAZY_TAINT", "origin": "default", "value": False},
473473
{"name": "_DD_INJECT_WAS_ATTEMPTED", "origin": "default", "value": False},
474+
{"name": "_DD_LLMOBS_AUTO_SPAN_LINKING_ENABLED", "origin": "default", "value": False},
474475
{"name": "_DD_TRACE_WRITER_LOG_ERROR_PAYLOADS", "origin": "default", "value": False},
475476
{"name": "ddtrace_auto_used", "origin": "unknown", "value": True},
476477
{"name": "ddtrace_bootstrapped", "origin": "unknown", "value": True},

tests/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ def override_global_config(values):
162162
"_llmobs_sample_rate",
163163
"_llmobs_ml_app",
164164
"_llmobs_agentless_enabled",
165+
"_llmobs_auto_span_linking_enabled",
165166
"_data_streams_enabled",
166167
"_inferred_proxy_services_enabled",
167168
]

0 commit comments

Comments
 (0)