Skip to content

Commit 113fac7

Browse files
authored
fix(llmobs): persist annotation context across batches (#15571)
## Description [**For this support card** ](https://datadoghq.atlassian.net/browse/MLOS-298) Taking a look at the [with_structured_output method](https://github.com/langchain-ai/langchain/blob/85012ae601a8ce2bd1ecfcdc889fb9151423ddaf/libs/partners/openai/langchain_openai/chat_models/azure.py#L833) it calls RunnableSequence.batch(), instead of BaseChatModel.batch() (what is called otherwise) which causes us to not reactivate the context after the first llm batch call. Essentially RunnableSequence.batch() creates a root-level span that encompasses the entire operation. When this root span finishes, the context provider's cleanup logic runs and (because _reactivate=False) the annotation context is lost. Here’s a nice code flow cursor made for me: User calls: gpt_4.with_structured_output(Schema).batch([...]) ↓ RunnableSequence.batch() - TRACED (creates chain span as ROOT) ↓ Internally calls ChatModel.generate() - creates child LLM spans ↓ Chain span finishes → triggers context provider's _update_active() ↓ Since _reactivate=False, annotation context is NOT restored ↓ Second batch() has no active context → annotations don't apply What we can do is track the context differently and reactivate it for batches, and then deactivate it when we know we’re actually leaving the context block. ## Testing Added tests to make sure we persist context across multiple roots ## Risks <!-- Note any risks associated with this change, or "None" if no risks --> ## Additional Notes <!-- Any other information that would be helpful for reviewers -->
1 parent e562d52 commit 113fac7

File tree

3 files changed

+57
-1
lines changed

3 files changed

+57
-1
lines changed

ddtrace/llmobs/_llmobs.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1067,15 +1067,22 @@ def annotation_context(
10671067
"""
10681068
# id to track an annotation for registering / de-registering
10691069
annotation_id = rand64bits()
1070+
# Track context we create so we can clean up _reactivate on exit.
1071+
# Using a dict as a mutable container to share state between closures.
1072+
state = {"created_context": None}
10701073

10711074
def get_annotations_context_id():
10721075
current_ctx = cls._instance.tracer.current_trace_context()
10731076
# default the context id to the annotation id
10741077
ctx_id = annotation_id
10751078
if current_ctx is None:
1079+
# No context exists - create one and enable reactivation so spans finishing
1080+
# within this annotation_context don't clear the context for subsequent operations
10761081
current_ctx = Context(is_remote=False)
10771082
current_ctx.set_baggage_item(ANNOTATIONS_CONTEXT_ID, ctx_id)
1083+
current_ctx._reactivate = True
10781084
cls._instance.tracer.context_provider.activate(current_ctx)
1085+
state["created_context"] = current_ctx
10791086
elif not current_ctx.get_baggage_item(ANNOTATIONS_CONTEXT_ID):
10801087
current_ctx.set_baggage_item(ANNOTATIONS_CONTEXT_ID, ctx_id)
10811088
else:
@@ -1098,9 +1105,13 @@ def deregister_annotation():
10981105
for i, (key, _, _) in enumerate(cls._instance._annotations):
10991106
if key == annotation_id:
11001107
cls._instance._annotations.pop(i)
1101-
return
1108+
break
11021109
else:
11031110
log.debug("Failed to pop annotation context")
1111+
# Disable reactivation on context we created to prevent it from being
1112+
# restored after exiting the annotation_context block
1113+
if state["created_context"] is not None:
1114+
state["created_context"]._reactivate = False
11041115

11051116
return AnnotationContext(register_annotation, deregister_annotation)
11061117

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
fixes:
3+
- |
4+
LLM Observability: This fix resolves an issue where ``LLMObs.annotation_context()`` properties (tags, prompt,
5+
and name) were not applied to subsequent LLM operations within the same context block. This occurred when
6+
multiple sequential operations (such as Langchain batch calls with structured outputs) were performed,
7+
causing only the first operation to receive the annotations.

tests/llmobs/test_llmobs_service.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,6 +1399,44 @@ def test_annotation_context_separate_traces_maintained(llmobs, llmobs_events):
13991399
assert agent_span["parent_id"] == "undefined"
14001400

14011401

1402+
def test_annotation_context_persists_across_multiple_root_span_operations(llmobs):
1403+
"""
1404+
Regression test: verifies that annotation context tags persist across multiple
1405+
sequential root span operations. This simulates scenarios like multiple batch()
1406+
calls with structured outputs in Langchain, where each batch creates a root span
1407+
that finishes before the next batch starts.
1408+
1409+
The bug occurred because the trace context wasn't being reactivated after a root
1410+
span finished, causing subsequent operations to lose the annotation context's baggage.
1411+
"""
1412+
with llmobs.annotation_context(tags={"test_tag": "should_persist"}):
1413+
# First operation - creates and finishes a root span
1414+
with llmobs.workflow(name="first_batch") as span1:
1415+
assert span1._get_ctx_item(TAGS) == {"test_tag": "should_persist"}
1416+
1417+
# Second operation - should still have annotation context applied
1418+
with llmobs.workflow(name="second_batch") as span2:
1419+
assert span2._get_ctx_item(TAGS) == {"test_tag": "should_persist"}
1420+
1421+
# Third operation - verify it continues to work
1422+
with llmobs.agent(name="third_operation") as span3:
1423+
assert span3._get_ctx_item(TAGS) == {"test_tag": "should_persist"}
1424+
1425+
1426+
def test_annotation_context_not_reactivated_after_exit(llmobs):
1427+
"""
1428+
Verifies that once an annotation context exits, the context we created is not
1429+
reactivated even after subsequent span operations within a new context.
1430+
"""
1431+
with llmobs.annotation_context(tags={"inside": "context"}):
1432+
with llmobs.workflow(name="inside_span") as span1:
1433+
assert span1._get_ctx_item(TAGS) == {"inside": "context"}
1434+
1435+
# After exiting annotation_context, tags should not be applied
1436+
with llmobs.workflow(name="outside_span") as span2:
1437+
assert span2._get_ctx_item(TAGS) is None
1438+
1439+
14021440
def test_annotation_context_only_applies_to_local_context(llmobs):
14031441
"""
14041442
tests that annotation contexts only apply to spans belonging to the same

0 commit comments

Comments
 (0)