Skip to content

[opentelemetry-instrumentation-genai-langchain] Divert SystemMessage to gen_ai.system_instructions - #511

Open
sfc-gh-zeningchen wants to merge 4 commits into
open-telemetry:mainfrom
sfc-gh-zeningchen:fix/ob-64558-system-instructions
Open

[opentelemetry-instrumentation-genai-langchain] Divert SystemMessage to gen_ai.system_instructions#511
sfc-gh-zeningchen wants to merge 4 commits into
open-telemetry:mainfrom
sfc-gh-zeningchen:fix/ob-64558-system-instructions

Conversation

@sfc-gh-zeningchen

@sfc-gh-zeningchen sfc-gh-zeningchen commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Description

on_chat_model_start used to fold every SystemMessage into gen_ai.input.messages as role="system" while leaving gen_ai.system_instructions empty, so the semconv system-instruction attribute never appeared on chat spans. This adds a split_system_and_input_messages helper (plus a to_system_instruction companion to to_input_messages) that routes SystemMessage — including SystemMessageChunk for streaming — to InferenceInvocation.system_instruction, and lets everything else fall through to_input_messages unchanged. to_input_messages itself is untouched.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

How has this been tested?

New unit tests in tests/test_llm_call.py cover the split helper directly (diversion, position-independent handling of interleaved system messages, SystemMessageChunk, short-hand tuple / dict inputs, no-system case) and to_system_instruction (non-system entries ignored, short-hand normalization). Existing OpenAI VCR-cassette tests were updated to assert role="system" no longer appears in gen_ai.input.messages and that gen_ai.system_instructions carries the content.

  • langchain suite: 25 passed

Checklist

  • Followed the style guidelines of this project
  • Changelog updated if the change requires an entry
  • Unit tests added
  • Documentation updated

`on_chat_model_start` used to fold every ``SystemMessage`` into
``gen_ai.input.messages`` as ``role="system"`` while leaving
``gen_ai.system_instructions`` empty, so the semconv system-instruction
attribute never appeared on chat spans. Split the flattened message
list once — system messages populate ``InferenceInvocation.system_instruction``
via a new ``split_system_and_input_messages`` helper, non-system messages
pass through ``to_input_messages`` unchanged. ``SystemMessageChunk`` is
handled via ``isinstance``.
Copilot AI lite review requested due to automatic review settings August 31, 2026 08:27
@sfc-gh-zeningchen
sfc-gh-zeningchen requested a review from a team as a code owner August 31, 2026 08:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the LangChain GenAI instrumentation to route LangChain SystemMessage (and streaming SystemMessageChunk) content into the GenAI semconv gen_ai.system_instructions attribute instead of representing it as "role": "system" inside gen_ai.input.messages, aligning chat spans/logs with the intended semantic convention mapping.

Changes:

  • Add to_system_instruction() and split_system_and_input_messages() utilities to extract and partition system instructions from chat inputs.
  • Update on_chat_model_start to populate InferenceInvocation.system_instruction separately from input_messages.
  • Extend/adjust unit + cassette-backed assertions to ensure system instructions are emitted via gen_ai.system_instructions and not as an input message role.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/utils.py Adds system-instruction extraction + message splitting helper used by callbacks.
instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py Switches chat-start handling to split system vs non-system messages and set system_instruction.
instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_llm_call.py Adds/updates tests and assertions to validate system instruction diversion for spans and logs.
instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/511.fixed Records the user-visible behavior fix in the package changelog fragments.
Suppressed comments (1)

instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/utils.py:247

  • split_system_and_input_messages() has the same iterator-consumption issue as to_system_instruction(): if messages is a generator and convert_to_messages(...) raises, the fallback list comprehension iterates an already-consumed iterable and drops everything.
    try:
        normalized: Iterable[BaseMessage] = convert_to_messages(list(messages))
    except Exception:  # pylint: disable=broad-except
        normalized = [m for m in messages if isinstance(m, BaseMessage)]

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Aug 31, 2026

Copy link
Copy Markdown

Pull request dashboard status

Waiting on the author · refreshed 2026-09-01 01:38 UTC

Resolve merge conflicts.

Respond to 3 review items (e.g. link a commit, explain why not, ask a follow-up):

  • Inline threads: 1, 2
  • Top-level threads: 3
Status above doesn't look right?
  • Just replied or pushed? Anything around or after the refresh time above may not be picked up yet — give it a few minutes.
  • Should this be with reviewers? Comment /dashboard route:reviewers to route it to them.
  • Anything wrong — including the routing? Report it with what you expected; it helps us improve the dashboard.

Move ``list(messages)`` outside the try so the fallback in ``except`` still
sees the input if ``convert_to_messages`` raises. Without this, a one-shot
iterator would be consumed by ``list(messages)`` inside the try and the
except branch would silently drop all content.

@lmolkova lmolkova left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to the inline suggestion:

In to_input_messages, materialized = list(messages) is still missing before the try block. If messages is a one-shot iterator and convert_to_messages raises, the except fallback will iterate over an already-exhausted iterable.

Comment on lines +241 to 261
def split_system_and_input_messages(
messages: Iterable[Any],
) -> tuple[list[MessagePart], list[InputMessage]]:
"""Split ``messages`` into ``system_instruction`` parts and ``InputMessage`` s."""
materialized = list(messages)
try:
normalized: Iterable[BaseMessage] = convert_to_messages(materialized)
except Exception: # pylint: disable=broad-except
normalized = [m for m in materialized if isinstance(m, BaseMessage)]
system: list[BaseMessage] = []
non_system: list[BaseMessage] = []
for message in normalized:
if isinstance(message, SystemMessage):
system.append(message)
else:
non_system.append(message)
return to_system_instruction(system), to_input_messages(non_system)


def to_output_messages(
messages: Iterable[BaseMessage],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def split_system_and_input_messages(
messages: Iterable[Any],
) -> tuple[list[MessagePart], list[InputMessage]]:
"""Split ``messages`` into ``system_instruction`` parts and ``InputMessage`` s."""
materialized = list(messages)
try:
normalized: Iterable[BaseMessage] = convert_to_messages(materialized)
except Exception: # pylint: disable=broad-except
normalized = [m for m in materialized if isinstance(m, BaseMessage)]
system: list[BaseMessage] = []
non_system: list[BaseMessage] = []
for message in normalized:
if isinstance(message, SystemMessage):
system.append(message)
else:
non_system.append(message)
return to_system_instruction(system), to_input_messages(non_system)
def to_output_messages(
messages: Iterable[BaseMessage],
def split_system_and_input_messages(
messages: Iterable[Any],
) -> tuple[list[MessagePart], list[InputMessage]]:
"""Split ``messages`` into ``system_instruction`` parts and ``InputMessage`` s."""
materialized = list(messages)
try:
normalized: Iterable[BaseMessage] = convert_to_messages(materialized)
except Exception: # pylint: disable=broad-except
normalized = [m for m in materialized if isinstance(m, BaseMessage)]
system_parts: list[MessagePart] = []
input_messages: list[InputMessage] = []
for message in normalized:
if isinstance(message, SystemMessage):
system_parts.extend(_content_to_parts(message.content))
else:
parts = _message_parts(message)
if parts:
input_messages.append(
InputMessage(role=_normalize_role(message), parts=parts)
)
return system_parts, input_messages

This processes the normalized messages in a single pass instead of splitting them and calling to_system_instruction and to_input_messages, which each re-run convert_to_messages and iterate the list again.

def test_split_system_and_input_messages_diverts_system_instructions():
system, inputs = split_system_and_input_messages(
[
SystemMessage(content="You are helpful."),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you please add system instructions to conformance tests? this is where we validate conformance to semantic conventions including value types

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants