Skip to content

Emit GenAI message events only on the chat span, with v1.36.0 bodies - #87

Merged
shibayan merged 2 commits into
masterfrom
align-otel-message-events
Aug 24, 2026
Merged

Emit GenAI message events only on the chat span, with v1.36.0 bodies#87
shibayan merged 2 commits into
masterfrom
align-otel-message-events

Conversation

@shibayan

Copy link
Copy Markdown
Member

Problem

With sensitive-data capture enabled, the per-message GenAI telemetry events (gen_ai.system.message / gen_ai.user.message / gen_ai.assistant.message / gen_ai.tool.message / gen_ai.choice) were emitted on both the invoke_agent span and the chat span, so every exchange was double-reported. The reference implementations emit these events for the model invocation only:

The event payload was also off-spec: it carried a role+parts JSON serialization (the shape of the gen_ai.input.messages / gen_ai.output.messages span attributes) under the v1.36.0 event names — a hybrid belonging to neither semconv generation.

Change

  • setMessageContent now records span attributes only. The gen_ai.input.messages / gen_ai.output.messages attributes are unchanged on both span kinds.
  • A new addMessageEvents (internal to core) emits the events on the chat span with the OTel GenAI v1.36.0 structured bodies, matching the Python emitter:
    • system / user messages: {"content": text}
    • assistant messages: {"content"?, "tool_calls"?} with tool_calls entries {id, type: "function", function: {name, arguments}} (only calls with both a call id and a name)
    • tool messages: one event per function_result with a call id, body {"id", "content"} (null/undefined results become "")
    • responses: one gen_ai.choice per message, body {"index", "finish_reason", "message": {content?, role?, tool_calls?}}; role is included only when it is not assistant
  • A response without a finish reason emits no choice events. The finish reason falls back to the raw representation's finish_reason when the normalized field is absent, matching Python's _get_response_finish_reason.
  • Every event carries gen_ai.system (the provider name) as an attribute, and instructions are emitted ahead of the input messages as a gen_ai.system.message event.
  • Timestamps advance 1 microsecond per event from a single wall-clock read, so ordering survives backends that collapse tight timestamps. The base uses performance.timeOrigin + performance.now(): Date.now() has millisecond resolution, so the input and choice batches of one fast invocation would collide and the choice events would stamp earlier than the stepped input events.
  • Events remain span events (the OpenTelemetry logs API lives outside @opentelemetry/api, the core's only runtime dependency); the structured body rides the body attribute as JSON, and event.name is kept for backends that lift span events into log records.

Not ported (upstream additions that are opt-in switches rather than fixes): the ENABLE_MESSAGE_EVENTS gate and the OTEL_SEMCONV_STABILITY_OPT_IN semconv-generation switch.

Breaking

Consumers reading message events from invoke_agent spans, or parsing the previous content attribute payload, need to read the chat span's events and the JSON body attribute instead. Span attributes are unaffected.

Verification

  • Reproduction tests were written first and observed failing against the previous behavior (agent-span events present, old payload shape, choice emitted without a finish reason, no provider attribute).
  • After the fix, the implementation was temporarily reverted to confirm the same tests fail again, then restored.
  • pnpm check passes (exit code verified).

🤖 Generated with Claude Code

The reference implementations emit the per-message GenAI events for the
model invocation alone and leave the invoke_agent span with
attribute-form content; emitting on both spans double-reported every
exchange once sensitive-data capture was enabled. The event payload also
carried a role+parts serialization under the v1.36.0 event names, a
shape belonging to neither semconv generation.

setMessageContent now records span attributes only, and the new
addMessageEvents emits the events on the chat span with the v1.36.0
structured bodies: system/user messages carry {content}, assistant
messages add tool_calls, tool messages become one event per function
result, and responses become gen_ai.choice events with index,
finish_reason and message. A response with no finish reason emits no
choice events, falling back to the raw representation's finish_reason
before giving up. Every event is stamped with gen_ai.system (the
provider name) and a 1-microsecond timestamp step so ordering survives
backends that collapse tight timestamps.

Events remain span events rather than log records, since the logs API
lives outside @opentelemetry/api; the structured body rides the `body`
attribute as JSON.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 24, 2026 07:24
@shibayan shibayan added bug Usage: [PRs], Target: bug fixes and regressions; issues use the Bug issue type breaking change Usage: [PRs], Target: changes that are not backward compatible labels Aug 24, 2026
@github-actions github-actions Bot added core Usage: [Issues, PRs], Target: packages/core observability Usage: [Issues, PRs], Target: tracing, metrics, and observability labels Aug 24, 2026
@shibayan shibayan self-assigned this Aug 24, 2026

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 aligns core OpenTelemetry GenAI message telemetry with the reference implementations by emitting per-message GenAI events only on the chat span (avoiding double-reporting on both invoke_agent and chat) and by switching event payloads to the OTel GenAI v1.36.0 structured bodies (JSON stored in the body attribute), while keeping the existing span-attribute message capture unchanged.

Changes:

  • Split message capture into (a) span-attribute recording (setMessageContent) and (b) per-message event emission (addMessageEvents) scoped to chat spans only.
  • Implement v1.36.0 event shaping (including provider stamping and timestamp stepping) and add responseFinishReason fallback logic.
  • Update/extend tests to lock in span placement, event shapes, provider attribute stamping, timestamp ordering, and finish-reason behavior.

Reviewed changes

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

Show a summary per file
File Description
packages/core/src/observability/tracing.ts Removes per-message events from attribute capture; adds v1.36.0 message/choice event emitter, timestamp stepping, and finish-reason fallback helper.
packages/core/src/observability/tracing.test.ts Adds focused tests asserting correct span placement and exact v1.36.0 event bodies, provider stamping, timestamp ordering, and finish reason fallback.
packages/core/src/observability/metrics.test.ts Updates expectations to reflect events being emitted on chat spans and body JSON usage.
packages/core/src/observability/attributes.ts Adds the v1.36.0 provider attribute key (gen_ai.system) to the GEN_AI constants.
packages/core/src/client/telemetry.ts Wires addMessageEvents into chat-span telemetry for both input and output sides, using responseFinishReason.
Suppressed comments (2)

packages/core/src/observability/tracing.ts:155

  • For gen_ai.tool.message, the v1.36.0 body shape expects content to be a string. Currently content.result is unknown, so the event may emit non-string content (object/array) or even become fully [unserializable] for values like bigint/circular structures. Coercing the tool result to a non-throwing string keeps the event schema stable and preserves the call id even when the raw result isn't JSON-serializable.
    for (const content of message.contents) {
      if (content.type === 'function_result' && content.callId !== '') {
        events.push({
          name: GEN_AI_MESSAGE_EVENT.tool,
          body: { id: content.callId, content: content.result ?? '' },

packages/core/src/observability/tracing.ts:176

  • For gen_ai.system.message / gen_ai.user.message events, the v1.36.0 body shape is always { "content": text }. Currently content is omitted when textOfContents(...) is empty, producing {} for non-text-only messages (e.g. image-only user input), which changes the event schema and makes it harder for consumers to rely on a stable shape. Consider always emitting content for non-assistant roles (empty string when there's no text), while keeping assistant content optional.
  const body: Record<string, unknown> = {};
  const text = textOfContents(message.contents);
  if (text !== '') {
    body.content = text;
  }

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

Comment thread packages/core/src/observability/tracing.ts
A bigint or a circular reference inside caller-built tool arguments or
results collapsed the whole event body to "[unserializable]", losing the
text and call names alongside the one bad value. The fallback now
retries with a replacer that stringifies bigints and marks revisited
objects, so only the offending values degrade — the granularity the
Python emitter gets from its exporter stringifying unencodable values
one at a time. The tool_calls arguments themselves keep passing through
verbatim (string or object), matching the reference emitter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 24, 2026 07:33
@github-actions github-actions Bot removed the breaking change Usage: [PRs], Target: changes that are not backward compatible label Aug 24, 2026

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

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@shibayan
shibayan merged commit 64383b7 into master Aug 24, 2026
9 checks passed
@shibayan
shibayan deleted the align-otel-message-events branch August 24, 2026 07:44
@shibayan shibayan mentioned this pull request Aug 28, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Usage: [PRs], Target: bug fixes and regressions; issues use the Bug issue type core Usage: [Issues, PRs], Target: packages/core observability Usage: [Issues, PRs], Target: tracing, metrics, and observability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants