Skip to content

Commit f8c84d4

Browse files
claude89757claude
andauthored
Python: Fix: Add system_instructions to ChatClient LLM span tracing (microsoft#3164)
* Fix: Add system_instructions to ChatClient LLM span tracing - Add system_instructions parameter to _capture_messages() calls in _trace_get_response() and _trace_get_streaming_response() - Extract instructions from chat_options in kwargs - Add unit tests to verify system_instructions are captured correctly When using ChatClient with ChatOptions.instructions, the OpenTelemetry LLM span was missing system messages in gen_ai.input.messages and the gen_ai.system_instructions attribute was not being set. This fix aligns the ChatClient-level tracing with the Agent-level tracing which already correctly passes system_instructions. Fixes microsoft#3163 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add edge case tests for system_instructions - Add test for empty string instructions (should not set attribute) - Add test for list-type instructions (verify multiple items captured) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Simplify: use options.get('instructions') directly instead of kwargs.get('chat_options') Addresses reviewer feedback: - Removed unnecessary chat_options variable from kwargs - Directly access instructions from the options parameter - Updated tests to use dict syntax for options (TypedDict convention) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3ec8815 commit f8c84d4

2 files changed

Lines changed: 134 additions & 1 deletion

File tree

python/packages/core/agent_framework/observability.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1096,7 +1096,12 @@ async def trace_get_response(
10961096
)
10971097
with _get_span(attributes=attributes, span_name_attribute=SpanAttributes.LLM_REQUEST_MODEL) as span:
10981098
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
1099-
_capture_messages(span=span, provider_name=provider_name, messages=messages)
1099+
_capture_messages(
1100+
span=span,
1101+
provider_name=provider_name,
1102+
messages=messages,
1103+
system_instructions=options.get("instructions"),
1104+
)
11001105
start_time_stamp = perf_counter()
11011106
end_time_stamp: float | None = None
11021107
try:
@@ -1189,6 +1194,7 @@ async def trace_get_streaming_response(
11891194
span=span,
11901195
provider_name=provider_name,
11911196
messages=messages,
1197+
system_instructions=options.get("instructions"),
11921198
)
11931199
start_time_stamp = perf_counter()
11941200
end_time_stamp: float | None = None

python/packages/core/tests/core/test_observability.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,133 @@ async def test_chat_client_streaming_observability(
279279
assert span.attributes[OtelAttr.OUTPUT_MESSAGES] is not None
280280

281281

282+
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
283+
async def test_chat_client_observability_with_instructions(
284+
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
285+
):
286+
"""Test that system_instructions from options are captured in LLM span."""
287+
import json
288+
289+
client = use_instrumentation(mock_chat_client)()
290+
291+
messages = [ChatMessage(role=Role.USER, text="Test message")]
292+
options = {"model_id": "Test", "instructions": "You are a helpful assistant."}
293+
span_exporter.clear()
294+
response = await client.get_response(messages=messages, options=options)
295+
296+
assert response is not None
297+
spans = span_exporter.get_finished_spans()
298+
assert len(spans) == 1
299+
span = spans[0]
300+
301+
# Verify system_instructions attribute is set
302+
assert OtelAttr.SYSTEM_INSTRUCTIONS in span.attributes
303+
system_instructions = json.loads(span.attributes[OtelAttr.SYSTEM_INSTRUCTIONS])
304+
assert len(system_instructions) == 1
305+
assert system_instructions[0]["content"] == "You are a helpful assistant."
306+
307+
# Verify input_messages contains system message
308+
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
309+
assert any(msg.get("role") == "system" for msg in input_messages)
310+
311+
312+
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
313+
async def test_chat_client_streaming_observability_with_instructions(
314+
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
315+
):
316+
"""Test streaming telemetry captures system_instructions from options."""
317+
import json
318+
319+
client = use_instrumentation(mock_chat_client)()
320+
messages = [ChatMessage(role=Role.USER, text="Test")]
321+
options = {"model_id": "Test", "instructions": "You are a helpful assistant."}
322+
span_exporter.clear()
323+
324+
updates = []
325+
async for update in client.get_streaming_response(messages=messages, options=options):
326+
updates.append(update)
327+
328+
assert len(updates) == 2
329+
spans = span_exporter.get_finished_spans()
330+
assert len(spans) == 1
331+
span = spans[0]
332+
333+
# Verify system_instructions attribute is set
334+
assert OtelAttr.SYSTEM_INSTRUCTIONS in span.attributes
335+
system_instructions = json.loads(span.attributes[OtelAttr.SYSTEM_INSTRUCTIONS])
336+
assert len(system_instructions) == 1
337+
assert system_instructions[0]["content"] == "You are a helpful assistant."
338+
339+
340+
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
341+
async def test_chat_client_observability_without_instructions(
342+
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
343+
):
344+
"""Test that system_instructions attribute is not set when instructions are not provided."""
345+
client = use_instrumentation(mock_chat_client)()
346+
347+
messages = [ChatMessage(role=Role.USER, text="Test message")]
348+
options = {"model_id": "Test"} # No instructions
349+
span_exporter.clear()
350+
response = await client.get_response(messages=messages, options=options)
351+
352+
assert response is not None
353+
spans = span_exporter.get_finished_spans()
354+
assert len(spans) == 1
355+
span = spans[0]
356+
357+
# Verify system_instructions attribute is NOT set
358+
assert OtelAttr.SYSTEM_INSTRUCTIONS not in span.attributes
359+
360+
361+
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
362+
async def test_chat_client_observability_with_empty_instructions(
363+
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
364+
):
365+
"""Test that system_instructions attribute is not set when instructions is an empty string."""
366+
client = use_instrumentation(mock_chat_client)()
367+
368+
messages = [ChatMessage(role=Role.USER, text="Test message")]
369+
options = {"model_id": "Test", "instructions": ""} # Empty string
370+
span_exporter.clear()
371+
response = await client.get_response(messages=messages, options=options)
372+
373+
assert response is not None
374+
spans = span_exporter.get_finished_spans()
375+
assert len(spans) == 1
376+
span = spans[0]
377+
378+
# Empty string should not set system_instructions
379+
assert OtelAttr.SYSTEM_INSTRUCTIONS not in span.attributes
380+
381+
382+
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
383+
async def test_chat_client_observability_with_list_instructions(
384+
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
385+
):
386+
"""Test that list-type instructions are correctly captured."""
387+
import json
388+
389+
client = use_instrumentation(mock_chat_client)()
390+
391+
messages = [ChatMessage(role=Role.USER, text="Test message")]
392+
options = {"model_id": "Test", "instructions": ["Instruction 1", "Instruction 2"]}
393+
span_exporter.clear()
394+
response = await client.get_response(messages=messages, options=options)
395+
396+
assert response is not None
397+
spans = span_exporter.get_finished_spans()
398+
assert len(spans) == 1
399+
span = spans[0]
400+
401+
# Verify system_instructions attribute contains both instructions
402+
assert OtelAttr.SYSTEM_INSTRUCTIONS in span.attributes
403+
system_instructions = json.loads(span.attributes[OtelAttr.SYSTEM_INSTRUCTIONS])
404+
assert len(system_instructions) == 2
405+
assert system_instructions[0]["content"] == "Instruction 1"
406+
assert system_instructions[1]["content"] == "Instruction 2"
407+
408+
282409
async def test_chat_client_without_model_id_observability(mock_chat_client, span_exporter: InMemorySpanExporter):
283410
"""Test telemetry shouldn't fail when the model_id is not provided for unknown reason."""
284411
client = use_instrumentation(mock_chat_client)()

0 commit comments

Comments
 (0)