Skip to content

[opentelemetry-instrumentation-genai-langchain] Resolve message roles by class instead of BaseMessage.type - #506

Merged
lmolkova merged 9 commits into
open-telemetry:mainfrom
sfc-gh-zeningchen:langchain-chunk-message-roles
Sep 2, 2026
Merged

[opentelemetry-instrumentation-genai-langchain] Resolve message roles by class instead of BaseMessage.type#506
lmolkova merged 9 commits into
open-telemetry:mainfrom
sfc-gh-zeningchen:langchain-chunk-message-roles

Conversation

@sfc-gh-zeningchen

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

Copy link
Copy Markdown
Contributor

Description

A message produced by streaming reported its LangChain class name as the role:
gen_ai.output.messages carried "role": "AIMessageChunk".

Roles were mapped from BaseMessage.type. Every streaming *Chunk class
subclasses its non-chunk counterpart while overriding type with its own class
name, so the two stay distinguishable when deserialized. The class hierarchy
identifies the role and type does not, so this matches on the class.

The effect reaches beyond streamed spans. convert_to_messages passes an
AIMessageChunk through unchanged, so an application that keeps an accumulated
streamed reply in its history also emitted "role": "AIMessageChunk" in
gen_ai.input.messages of later non-streamed calls, and on agent and workflow
spans.

role drives the role badge and the System/User/Assistant/Tool filter in
consumers, so an affected row shows a raw Python class name and matches no
filter value.

The spec role values move to opentelemetry-util-genai as a Role enum, next
to FinishReason. semconv constrains roles in the message JSON schemas, which
are not code-generated into opentelemetry-semconv, so instrumentations have
been spelling the literals out; google-genai already keeps a private copy of
this enum. InputMessage.role and OutputMessage.role widen to str | Role,
mirroring finish_reason, since the schemas also admit provider-specific roles.

Fixes #504

Type of change

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

How has this been tested?

  • A parametrized test in tests/test_callback_handler.py covers all ten
    LangChain message classes, each chunk class alongside its non-chunk parent.
  • test_streamed_output_message_role_is_assistant covers the role on a
    streamed on_llm_end.
  • In opentelemetry-util-genai, TestRole checks the values against the
    message schemas and that a member set on a message serializes as "assistant"
    rather than "Role.ASSISTANT".

Checklist

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

Copilot AI lite review requested due to automatic review settings August 31, 2026 02:34
@sfc-gh-zeningchen
sfc-gh-zeningchen requested a review from a team as a code owner August 31, 2026 02:34
A message produced by streaming reported its class name as the role:
gen_ai.output.messages carried "role": "AIMessageChunk". Roles were mapped from
BaseMessage.type, and every streaming *Chunk class subclasses its non-chunk
counterpart while overriding type with its own class name, so the two stay
distinguishable when deserialized. The class hierarchy identifies the role;
type does not. Match on the class instead.

This is not confined to streamed spans. convert_to_messages passes an
AIMessageChunk through unchanged, so an application that keeps an accumulated
streamed reply in its history also emitted "role": "AIMessageChunk" in
gen_ai.input.messages of subsequent non-streamed calls, and on agent and
workflow spans.

role drives the role badge and the System/User/Assistant/Tool filter in
consumers, so an affected row showed a raw Python class name and matched no
filter value.

The spec role values move to opentelemetry-util-genai as a Role enum, next to
FinishReason. semconv constrains roles in the message JSON schemas, which are
not code-generated into opentelemetry-semconv, so instrumentations have been
spelling the literals out; google-genai already keeps a private copy of this
enum. InputMessage.role and OutputMessage.role widen to str | Role, mirroring
finish_reason, since the schemas also admit provider-specific roles.

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

Fixes LangChain message-role normalization so streamed *Chunk messages emit GenAI-semconv roles (e.g., "assistant") instead of LangChain class names (e.g., "AIMessageChunk"), and introduces a shared Role enum in opentelemetry-util-genai to avoid role literal drift across instrumentations.

Changes:

  • Add Role enum to opentelemetry.util.genai.types and widen message role fields to accept str | Role.
  • Update LangChain role resolution to match by message class hierarchy (covers chunk variants) rather than BaseMessage.type.
  • Add/extend tests to cover chunk-role mapping and Role JSON serialization.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py Adds Role enum and allows InputMessage/OutputMessage roles to be `str
util/opentelemetry-util-genai/tests/test_utils.py Adds TestRole ensuring enum values match semconv and serialize correctly.
util/opentelemetry-util-genai/.changelog/506.added Changelog fragment for the new Role enum.
instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/utils.py Switches role normalization to class-based mapping (fixing streamed chunk roles).
instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_callback_handler.py Adds coverage for streamed output role and parametrized role normalization across message classes.
instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/506.fixed Changelog fragment for the LangChain chunk-role fix.

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

Comment thread util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py Outdated
Comment thread util/opentelemetry-util-genai/tests/test_utils.py Outdated
sfc-gh-zeningchen and others added 2 commits August 30, 2026 19:39
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
The test matched "\"role\":\"assistant\"" in the serialized output, so a change
to the separators gen_ai_json_dumps passes would fail it even though the
encoded value stayed correct. Parse the JSON and compare the field.
@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Aug 31, 2026

Copy link
Copy Markdown

Pull request dashboard status

Merged · refreshed 2026-09-02 19:34 UTC

Status above doesn't look right?
  • Anything look wrong? Report it with what you expected; it helps us improve the dashboard.

Comment thread util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py Outdated
Comment thread util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py
sfc-gh-zeningchen and others added 5 commits September 1, 2026 22:31
…ge-roles

# Conflicts:
#	instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_callback_handler.py
#	util/opentelemetry-util-genai/tests/test_utils.py
…ault

Falling back to BaseMessage.type leaked LangChain names that are not spec
roles, such as "chat" for ChatMessage and "remove" for RemoveMessage.
_normalize_role now returns None for a class it does not map, and each caller
supplies the role its context implies: user for input messages, assistant for
output messages.

Drop the str | Role annotation on the message dataclasses. Role subclasses str,
so plain str already accepts its members.
…es.py

Co-authored-by: Liudmila Molkova <neskazu@gmail.com>
Every other LangChain message class encodes its role in the class, so matching
by class resolves it. ChatMessage is the exception: it exists to carry an
arbitrary speaker, keeps it in a role field, and reports "chat" as its type. It
therefore fell through to the caller's default, and ChatMessage(role="assistant")
was reported as user on the input side.

Read the field for ChatMessage, which covers ChatMessageChunk through
inheritance. An empty role falls back to the caller's default, since role is
required on both message schemas.
@lmolkova
lmolkova added this pull request to the merge queue Sep 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 2, 2026
…ge-roles

# Conflicts:
#	instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_callback_handler.py
@lmolkova
lmolkova added this pull request to the merge queue Sep 2, 2026
Merged via the queue into open-telemetry:main with commit eec410c Sep 2, 2026
71 checks passed
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.

langchain: message roles report the LangChain class name instead of a spec role

3 participants