Skip to content

Commit 4aaa497

Browse files
committed
fix(claude-messages): put the input content write inside the guard too
The prompt write ran before the try that fails the span it writes to. Serialising conversation content raises on anything that is not JSON-serialisable, so a raise there left the chat span open in the tool loop, and on both root paths left the root open: never ended, never exported, so the run disappeared from AI Config Monitoring along with the feature_flag event it carries. The output writes were moved inside their guards earlier in this stack. The input writes were not, which is the same defect at the other end of the same span. Two tests, one per root path. Found by Bugbot on #34, which is this shape in langchain-messages.
1 parent 1933374 commit 4aaa497

2 files changed

Lines changed: 83 additions & 22 deletions

File tree

packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -137,16 +137,6 @@ async def _run_tool_loop(
137137

138138
while True:
139139
model_span = start_model_span(config, parent)
140-
# Written before the call, so an in-flight or failed turn still shows what it was asked.
141-
# `conversation` grows with each turn, which is what makes a `chat` span self-contained.
142-
if capture_content:
143-
set_input_content_attributes(
144-
model_span,
145-
capture_content,
146-
system_instructions=system,
147-
messages=to_span_messages(conversation),
148-
tool_definitions=tool_definitions,
149-
)
150140

151141
kwargs: dict[str, Any] = {
152142
"model": config["model"]["name"],
@@ -163,6 +153,18 @@ async def _run_tool_loop(
163153
# span open: the blocking path has no `finally` that could recover it, so the turn would never
164154
# be exported and the run would show a model call that left no trace.
165155
try:
156+
# Written before the call, so an in-flight or failed turn still shows what it was asked.
157+
# `conversation` grows with each turn, which is what makes a `chat` span self-contained.
158+
# Inside the guard, because serialising it raises on anything that is not
159+
# JSON-serialisable and a raise out here would leave this span open.
160+
if capture_content:
161+
set_input_content_attributes(
162+
model_span,
163+
capture_content,
164+
system_instructions=system,
165+
messages=to_span_messages(conversation),
166+
tool_definitions=tool_definitions,
167+
)
166168
resp = await client.messages.create(**kwargs)
167169

168170
raw_usage = raw_usage_of(getattr(resp, "usage", None))
@@ -274,17 +276,20 @@ async def _call_impl(
274276
parent = parent_context_of(span)
275277

276278
messages, system = _build_messages(config, user_input, vs, history=history)
277-
set_input_content_attributes(
278-
span,
279-
capture_content,
280-
system_instructions=system,
281-
messages=to_span_messages(messages),
282-
)
283279

284280
# Outside the try, so the failure path can still report the spend of the turns that
285281
# completed before it.
286282
run_usage = RawRunUsage()
287283
try:
284+
# Inside the guard, because serialising the prompt raises on anything that is not
285+
# JSON-serialisable and a raise out here would leave the root open: never ended, never
286+
# exported, and the run gone from AI Config Monitoring with the feature_flag event on it.
287+
set_input_content_attributes(
288+
span,
289+
capture_content,
290+
system_instructions=system,
291+
messages=to_span_messages(messages),
292+
)
288293
output, usage = await _run_tool_loop(
289294
client,
290295
config,
@@ -364,12 +369,6 @@ async def _stream_gen(
364369
messages, system = _build_messages(
365370
config, user_input, variables, include_output_format=False, history=history
366371
)
367-
set_input_content_attributes(
368-
span,
369-
capture_content,
370-
system_instructions=system,
371-
messages=to_span_messages(messages),
372-
)
373372

374373
tools = _build_tools(config.get("tools") or {})
375374
tool_definitions = to_tool_definitions(tools)
@@ -389,6 +388,15 @@ async def _stream_gen(
389388
run_usage = RawRunUsage()
390389

391390
try:
391+
# Inside the guard, because serialising the prompt raises on anything that is not
392+
# JSON-serialisable. A raise out here would leave the root open with the `finally` never
393+
# entered, so the run would vanish from AI Config Monitoring with its feature_flag event.
394+
set_input_content_attributes(
395+
span,
396+
capture_content,
397+
system_instructions=system,
398+
messages=to_span_messages(messages),
399+
)
392400
while True:
393401
model_span = start_model_span(config, parent)
394402
open_model_span = model_span

packages/claude-messages/tests/test_handler.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1968,3 +1968,56 @@ async def _ctx_mgr() -> AsyncGenerator[Any, None]:
19681968

19691969
assert rec.root.attributes["gen_ai.usage.input_tokens"] == 37
19701970
assert rec.root.attributes["gen_ai.usage.output_tokens"] == 11
1971+
1972+
1973+
class TestInputWritesNeverLeakASpan:
1974+
"""Serialising the prompt must not be able to strand a span.
1975+
1976+
The input content write ran before the guard that fails the span it writes to, so a raise there
1977+
left the root open on both paths: never ended, never exported, so the run disappeared from AI
1978+
Config Monitoring along with the feature_flag event it carries.
1979+
"""
1980+
1981+
async def test_the_blocking_root_still_ends(
1982+
self, mock_anthropic: MagicMock
1983+
) -> None:
1984+
from opentelemetry.trace import StatusCode
1985+
1986+
from launchdarkly_ai_claude_messages import create_claude_messages_handler
1987+
1988+
ctx, rec = _recording()
1989+
with (
1990+
ctx,
1991+
patch(
1992+
"launchdarkly_ai_claude_messages.handler.set_input_content_attributes",
1993+
side_effect=TypeError("cannot serialise this prompt"),
1994+
),
1995+
pytest.raises(TypeError),
1996+
):
1997+
await create_claude_messages_handler(capture_content=True)(
1998+
CONFIG, "q", {}, {}
1999+
)
2000+
2001+
assert rec.root.ended == 1, "the root span leaked"
2002+
assert StatusCode.ERROR in rec.root.statuses
2003+
2004+
async def test_the_streaming_root_still_ends(
2005+
self, mock_anthropic: MagicMock
2006+
) -> None:
2007+
from launchdarkly_ai_claude_messages import create_claude_messages_handler
2008+
2009+
ctx, rec = _recording()
2010+
with (
2011+
ctx,
2012+
patch(
2013+
"launchdarkly_ai_claude_messages.handler.set_input_content_attributes",
2014+
side_effect=TypeError("cannot serialise this prompt"),
2015+
),
2016+
pytest.raises(TypeError),
2017+
):
2018+
async for _ in await create_claude_messages_handler(
2019+
capture_content=True
2020+
).stream(CONFIG, "q"):
2021+
pass
2022+
2023+
assert rec.root.ended == 1, "the root span leaked"

0 commit comments

Comments
 (0)