Skip to content

Commit be4f7d3

Browse files
authored
fix(agentscope): start streaming LLM spans before model calls (#242)
2 parents c86e01f + b987ab7 commit be4f7d3

3 files changed

Lines changed: 127 additions & 19 deletions

File tree

instrumentation-loongsuite/loongsuite-instrumentation-agentscope/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Fixed
11+
12+
- Start AgentScope v2 streaming LLM spans before invoking the underlying model
13+
call so TTFT and stream lifecycle are recorded on the framework LLM span.
14+
- Capture AgentScope v2 string message content as text parts so LLM input and
15+
output message attributes are populated when content capture is enabled.
16+
1017
## Version 0.7.0 (2026-07-03)
1118

1219
### Added

instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/_v2_middleware.py

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from agentscope.model import ChatModelBase, ChatResponse
3232
from agentscope.tool import ToolResponse
3333

34-
from opentelemetry.context import Context, get_current
34+
from opentelemetry.context import get_current
3535
from opentelemetry.util.genai.extended_handler import ExtendedTelemetryHandler
3636
from opentelemetry.util.genai.extended_types import (
3737
ExecuteToolInvocation,
@@ -175,30 +175,20 @@ async def on_model_call(
175175

176176
invocation = _create_llm_invocation(model, input_kwargs)
177177
span_context = get_current()
178-
started = False
179-
if not _is_streaming_model(model, input_kwargs):
180-
handler.start_llm(invocation, context=span_context)
181-
started = True
178+
handler.start_llm(invocation, context=span_context)
182179
try:
183180
result = await next_handler(**input_kwargs)
184181
if inspect.isasyncgen(result):
185182
return self._wrap_model_stream(
186183
result,
187184
invocation,
188-
span_context,
189185
handler,
190-
span_started=started,
191186
)
192187

193-
if not started:
194-
handler.start_llm(invocation, context=span_context)
195-
started = True
196188
_finish_llm_invocation(invocation, result)
197189
handler.stop_llm(invocation)
198190
return result
199191
except BaseException as exc:
200-
if not started:
201-
handler.start_llm(invocation, context=span_context)
202192
handler.fail_llm(
203193
invocation,
204194
Error(message=str(exc) or type(exc).__name__, type=type(exc)),
@@ -209,17 +199,11 @@ async def _wrap_model_stream(
209199
self,
210200
result: AsyncGenerator[ChatResponse, None],
211201
invocation: LLMInvocation,
212-
span_context: Context,
213202
handler: ExtendedTelemetryHandler,
214-
*,
215-
span_started: bool,
216203
) -> AsyncGenerator[ChatResponse, None]:
217204
first_token_seen = False
218205
last_chunk = None
219206
closed = False
220-
if not span_started:
221-
handler.start_llm(invocation, context=span_context)
222-
span_started = True
223207
try:
224208
async for chunk in result:
225209
if not first_token_seen:
@@ -239,7 +223,7 @@ async def _wrap_model_stream(
239223
handler.stop_llm(invocation)
240224
closed = True
241225
finally:
242-
if span_started and not closed:
226+
if not closed:
243227
handler.stop_llm(invocation)
244228

245229
async def on_acting(
@@ -402,6 +386,10 @@ def _chat_response_to_output(response: ChatResponse) -> OutputMessage:
402386

403387

404388
def _blocks_to_parts(blocks: Sequence[Any]) -> list[Any]:
389+
if blocks is None:
390+
return []
391+
if isinstance(blocks, str):
392+
return [Text(content=blocks)]
405393
parts = []
406394
for block in blocks:
407395
block_type = getattr(block, "type", None)

instrumentation-loongsuite/loongsuite-instrumentation-agentscope/tests/test_v2_instrumentation.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import asyncio
2020
import importlib.metadata
21+
import json
2122
import os
2223
from dataclasses import asdict
2324
from types import SimpleNamespace
@@ -41,13 +42,17 @@
4142
from agentscope.model import ChatResponse, DashScopeChatModel # noqa: E402
4243
from agentscope.tool import ToolResponse # noqa: E402
4344

45+
from opentelemetry import trace as trace_api # noqa: E402
4446
from opentelemetry.instrumentation.agentscope._v2_middleware import ( # noqa: E402
4547
AgentScopeV2Middleware,
4648
_message_to_input,
4749
)
4850
from opentelemetry.instrumentation.agentscope.package import ( # noqa: E402
4951
get_installed_instrumentation_dependencies,
5052
)
53+
from opentelemetry.semconv._incubating.attributes import ( # noqa: E402
54+
gen_ai_attributes as GenAI,
55+
)
5156
from opentelemetry.trace.status import StatusCode # noqa: E402
5257
from opentelemetry.util.genai.utils import gen_ai_json_dumps # noqa: E402
5358

@@ -209,6 +214,114 @@ async def stream_handler(**kwargs):
209214
assert span.attributes["error.type"] == "RuntimeError"
210215

211216

217+
async def test_v2_streaming_model_call_starts_llm_span_before_model_handler(
218+
instrument,
219+
span_exporter,
220+
):
221+
agent = Agent(
222+
name="stream_suppression_agent",
223+
system_prompt="Reply briefly.",
224+
model=_make_model(stream=True),
225+
)
226+
middleware = _middleware(agent._model_call_middlewares)
227+
observed_current_span_ids = []
228+
consumer_current_span_ids = []
229+
230+
async def stream_handler(**kwargs):
231+
del kwargs
232+
observed_current_span_ids.append(
233+
trace_api.get_current_span().get_span_context().span_id
234+
)
235+
236+
async def stream():
237+
observed_current_span_ids.append(
238+
trace_api.get_current_span().get_span_context().span_id
239+
)
240+
yield ChatResponse(
241+
content=[TextBlock(text="partial")],
242+
is_last=False,
243+
)
244+
observed_current_span_ids.append(
245+
trace_api.get_current_span().get_span_context().span_id
246+
)
247+
yield ChatResponse(
248+
content=[TextBlock(text="done")],
249+
is_last=True,
250+
)
251+
252+
return stream()
253+
254+
stream = await middleware.on_model_call(
255+
agent,
256+
{
257+
"current_model": agent.model,
258+
"messages": [UserMsg(name="user", content="hello")],
259+
},
260+
stream_handler,
261+
)
262+
263+
async for _ in stream:
264+
consumer_current_span_ids.append(
265+
trace_api.get_current_span().get_span_context().span_id
266+
)
267+
268+
spans = _spans_by_operation(span_exporter.get_finished_spans(), "chat")
269+
assert len(spans) == 1
270+
llm_span_id = spans[0].context.span_id
271+
assert observed_current_span_ids == [llm_span_id, llm_span_id, llm_span_id]
272+
assert consumer_current_span_ids == [llm_span_id, llm_span_id]
273+
assert (
274+
trace_api.get_current_span().get_span_context().span_id != llm_span_id
275+
)
276+
277+
278+
async def test_v2_streaming_model_call_captures_input_and_output_content(
279+
instrument_with_content,
280+
span_exporter,
281+
):
282+
agent = Agent(
283+
name="stream_content_agent",
284+
system_prompt="Reply briefly.",
285+
model=_make_model(stream=True),
286+
)
287+
middleware = _middleware(agent._model_call_middlewares)
288+
289+
async def stream_handler(**kwargs):
290+
del kwargs
291+
292+
async def stream():
293+
yield ChatResponse(
294+
content=[TextBlock(text="partial")],
295+
is_last=False,
296+
)
297+
yield ChatResponse(
298+
content=[TextBlock(text="done")],
299+
is_last=True,
300+
)
301+
302+
return stream()
303+
304+
stream = await middleware.on_model_call(
305+
agent,
306+
{
307+
"current_model": agent.model,
308+
"messages": [UserMsg(name="user", content="hello")],
309+
},
310+
stream_handler,
311+
)
312+
313+
async for _ in stream:
314+
pass
315+
316+
span = _spans_by_operation(span_exporter.get_finished_spans(), "chat")[0]
317+
input_messages = json.loads(span.attributes[GenAI.GEN_AI_INPUT_MESSAGES])
318+
output_messages = json.loads(span.attributes[GenAI.GEN_AI_OUTPUT_MESSAGES])
319+
assert input_messages[0]["role"] == "user"
320+
assert input_messages[0]["parts"][0]["content"] == "hello"
321+
assert output_messages[0]["role"] == "assistant"
322+
assert output_messages[0]["parts"][0]["content"] == "done"
323+
324+
212325
async def test_v2_tool_acting_hook(instrument, span_exporter):
213326
agent = Agent(
214327
name="tool_agent",

0 commit comments

Comments
 (0)