Emit GenAI message events only on the chat span, with v1.36.0 bodies - #87
Merged
Conversation
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>
Contributor
There was a problem hiding this comment.
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 tochatspans only. - Implement v1.36.0 event shaping (including provider stamping and timestamp stepping) and add
responseFinishReasonfallback 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 expectscontentto be a string. Currentlycontent.resultisunknown, so the event may emit non-stringcontent(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.messageevents, the v1.36.0 body shape is always{ "content": text }. Currentlycontentis omitted whentextOfContents(...)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 emittingcontentfor non-assistant roles (empty string when there's no text), while keeping assistantcontentoptional.
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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 theinvoke_agentspan and thechatspan, so every exchange was double-reported. The reference implementations emit these events for the model invocation only:python-1.15.0) split message capture into event emission and span-attribute capture, and removed event emission from the agent telemetry layer —invoke_agentspans now carry attribute-form content only.OpenTelemetryAgenthas never emitted message content itself; it delegates to the chat-client instrumentation.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.messagesspan attributes) under the v1.36.0 event names — a hybrid belonging to neither semconv generation.Change
setMessageContentnow records span attributes only. Thegen_ai.input.messages/gen_ai.output.messagesattributes are unchanged on both span kinds.addMessageEvents(internal to core) emits the events on thechatspan with the OTel GenAI v1.36.0 structured bodies, matching the Python emitter:system/usermessages:{"content": text}assistantmessages:{"content"?, "tool_calls"?}withtool_callsentries{id, type: "function", function: {name, arguments}}(only calls with both a call id and a name)toolmessages: one event perfunction_resultwith a call id, body{"id", "content"}(null/undefinedresults become"")gen_ai.choiceper message, body{"index", "finish_reason", "message": {content?, role?, tool_calls?}};roleis included only when it is notassistantfinish_reasonwhen the normalized field is absent, matching Python's_get_response_finish_reason.gen_ai.system(the provider name) as an attribute, and instructions are emitted ahead of the input messages as agen_ai.system.messageevent.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.@opentelemetry/api, the core's only runtime dependency); the structured body rides thebodyattribute as JSON, andevent.nameis 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_EVENTSgate and theOTEL_SEMCONV_STABILITY_OPT_INsemconv-generation switch.Breaking
Consumers reading message events from
invoke_agentspans, or parsing the previouscontentattribute payload, need to read thechatspan's events and the JSONbodyattribute instead. Span attributes are unaffected.Verification
pnpm checkpasses (exit code verified).🤖 Generated with Claude Code