From 4a5f70ec367d3dcc27f7f5c3a7992829ae0860f9 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 15 Sep 2026 12:39:41 -0600 Subject: [PATCH 1/5] fix(a365): preserve scope attribute precedence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- CHANGELOG.md | 1 + src/a365/scopes/OpenTelemetryScope.ts | 39 ++++++--- test/internal/unit/a365/scopes.test.ts | 112 +++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1229eab..a6a40ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Bugs Fixed +- A365: prevent `recordAttributes()` from overwriting scope-owned attributes such as builder-populated tags and `gen_ai.operation.name`, while still allowing late writes for known keys that were never set and repeated last-write-wins custom attributes. - Default `InvokeAgentScope` spans to `SpanKind.INTERNAL` while preserving explicit span-kind overrides. [#241](https://github.com/microsoft/opentelemetry-distro-javascript/pull/241) ### Features Added diff --git a/src/a365/scopes/OpenTelemetryScope.ts b/src/a365/scopes/OpenTelemetryScope.ts index 80cf2ce..2b4729e 100644 --- a/src/a365/scopes/OpenTelemetryScope.ts +++ b/src/a365/scopes/OpenTelemetryScope.ts @@ -51,6 +51,7 @@ export abstract class OpenTelemetryScope { private customEndTime?: TimeInput; private errorType?: string; private hasEnded = false; + private readonly ownedAttributeKeys = new Set(); private readonly logger = Logger.getInstance(); /** @@ -94,6 +95,7 @@ export abstract class OpenTelemetryScope { }, currentContext, ); + this.ownedAttributeKeys.add(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY); this.wallClockStartMs = Date.now(); if (startTime !== undefined) { @@ -165,7 +167,10 @@ export abstract class OpenTelemetryScope { this.span.recordException(error); } - /** Records multiple attribute key/value pairs. */ + /** + * Records multiple attribute key/value pairs without overwriting values that + * the scope already populated through its typed setters or span builder. + */ public recordAttributes( attributes: Iterable<[string, AttributeValue]> | Record | null | undefined, @@ -173,17 +178,9 @@ export abstract class OpenTelemetryScope { if (!attributes) return; if (Symbol.iterator in Object(attributes) && typeof attributes !== "string") { - for (const [key, value] of attributes as Iterable<[string, AttributeValue]>) { - if (key && typeof key === "string" && key.trim()) { - this.span.setAttribute(key, value); - } - } + this.recordUnownedAttributes(attributes as Iterable<[string, AttributeValue]>); } else if (typeof attributes === "object") { - for (const key of Object.keys(attributes as Record)) { - if (key && key.trim()) { - this.span.setAttribute(key, (attributes as Record)[key]); - } - } + this.recordUnownedAttributes(Object.entries(attributes as Record)); } } @@ -199,7 +196,10 @@ export abstract class OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, serializeMessages(wrapper)); } - /** Sets a tag on the span if the value is not null or undefined. */ + /** + * Sets a tag on the span if the value is not null or undefined, and marks the + * key as owned by the scope so later generic attribute writes do not replace it. + */ protected setTagMaybe( name: string, value: T | null | undefined, @@ -208,9 +208,24 @@ export abstract class OpenTelemetryScope { this.span.setAttributes({ [name]: value as string | number | boolean | string[] | number[], }); + this.ownedAttributeKeys.add(name); } } + private recordUnownedAttributes(attributes: Iterable<[string, AttributeValue]>): void { + for (const [key, value] of attributes) { + if (!OpenTelemetryScope.isNonBlankAttributeKey(key) || this.ownedAttributeKeys.has(key)) { + continue; + } + + this.span.setAttribute(key, value); + } + } + + private static isNonBlankAttributeKey(key: string): boolean { + return typeof key === "string" && key.trim().length > 0; + } + /** * Adds an event to the current span. * @param name The event name diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts index 15e4ecf..df8baa2 100644 --- a/test/internal/unit/a365/scopes.test.ts +++ b/test/internal/unit/a365/scopes.test.ts @@ -1761,6 +1761,118 @@ describe("Request content and message serialization (span attributes)", () => { }); }); +describe("recordAttributes ownership and precedence", () => { + const testAgentDetails: AgentDetails = { + agentId: "test-agent", + agentName: "Test Agent", + tenantId: "test-tenant-456", + }; + + beforeEach(() => { + sharedExporter.reset(); + }); + + const getLastSpan = (): ReadableSpan => { + const spans = sharedExporter.getFinishedSpans(); + expect(spans.length).toBeGreaterThanOrEqual(1); + return spans[spans.length - 1]; + }; + + it("should preserve builder-populated attributes when recordAttributes sees the same keys", () => { + const scope = InvokeAgentScope.start( + { + conversationId: "conv-owned", + channel: { name: "Teams", description: "https://teams.example" }, + }, + {}, + testAgentDetails, + ); + + scope.recordAttributes({ + [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]: "Override Agent", + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]: "override-conv", + "custom.attribute": "custom value", + }); + scope.dispose(); + + const attributes = getLastSpan().attributes; + expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe("Test Agent"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("conv-owned"); + expect(attributes["custom.attribute"]).toBe("custom value"); + }); + + it("should preserve the span builder operation name when recordAttributes provides another value", () => { + const scope = ExecuteToolScope.start( + { conversationId: "conv-op-name" }, + { toolName: "search" }, + testAgentDetails, + ); + + scope.recordAttributes({ + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: + OpenTelemetryConstants.CHAT_OPERATION_NAME, + }); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe( + OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, + ); + }); + + it("should accept known keys that were absent when the scope was created", () => { + const scope = InferenceScope.start( + { conversationId: "conv-late-known", channel: { name: "Teams" } }, + { operationName: InferenceOperationType.CHAT, model: "gpt-4" }, + testAgentDetails, + ); + + scope.recordAttributes({ + [OpenTelemetryConstants.CHANNEL_LINK_KEY]: "https://teams.example/deep-link", + }); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe( + "https://teams.example/deep-link", + ); + }); + + it("should keep custom recordAttributes keys last-write-wins across repeated calls", () => { + const scope = ExecuteToolScope.start( + { conversationId: "conv-custom-repeat" }, + { toolName: "search" }, + testAgentDetails, + ); + + scope.recordAttributes({ "custom.repeat": "first" }); + scope.recordAttributes({ "custom.repeat": "second" }); + scope.dispose(); + + expect(getLastSpan().attributes["custom.repeat"]).toBe("second"); + }); + + it("should support iterable attributes while skipping owned and blank keys", () => { + const scope = InvokeAgentScope.start( + { conversationId: "conv-iterable", channel: { name: "Teams" } }, + {}, + testAgentDetails, + ); + + scope.recordAttributes([ + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, "override-conv"], + ["", "ignored"], + [" ", "also ignored"], + ["custom.iterable", 42], + ]); + scope.dispose(); + + const attributes = getLastSpan().attributes; + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("conv-iterable"); + expect(attributes["custom.iterable"]).toBe(42); + expect(attributes[""]).toBeUndefined(); + expect(attributes[" "]).toBeUndefined(); + }); +}); + // Validate attribute key constant values use the new schema namespace. describe("Attribute key schema values", () => { it("caller keys use user.* / client.* namespace", () => { From b0718fe038a337f6675b8db8dbff3cf448011052 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 15 Sep 2026 12:52:42 -0600 Subject: [PATCH 2/5] docs: link scope precedence changelog entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a40ac..654adc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Bugs Fixed -- A365: prevent `recordAttributes()` from overwriting scope-owned attributes such as builder-populated tags and `gen_ai.operation.name`, while still allowing late writes for known keys that were never set and repeated last-write-wins custom attributes. +- A365: prevent `recordAttributes()` from overwriting scope-owned attributes such as builder-populated tags and `gen_ai.operation.name`, while still allowing late writes for known keys that were never set and repeated last-write-wins custom attributes. [#243](https://github.com/microsoft/opentelemetry-distro-javascript/pull/243) - Default `InvokeAgentScope` spans to `SpanKind.INTERNAL` while preserving explicit span-kind overrides. [#241](https://github.com/microsoft/opentelemetry-distro-javascript/pull/241) ### Features Added From 9fc20f6b3133e15775ed95bea006655653530e91 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 30 Sep 2026 15:08:47 -0600 Subject: [PATCH 3/5] refactor(a365): centralize request context attributes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9834b6ca-9d85-4b99-aa3d-c346c03485d2 --- src/a365/contracts.ts | 2 + src/a365/scopes/ApplyGuardrailScope.ts | 6 +- src/a365/scopes/ExecuteToolScope.ts | 6 +- src/a365/scopes/InferenceScope.ts | 7 +- src/a365/scopes/InvokeAgentScope.ts | 13 +--- src/a365/scopes/OpenTelemetryScope.ts | 10 +++ src/a365/scopes/OutputScope.ts | 7 +- .../unit/a365/applyGuardrailScope.test.ts | 27 +++++++ test/internal/unit/a365/scopes.test.ts | 34 ++++++++ .../unit/a365/scopesAfterDistroInit.test.ts | 77 ++++++++++++++++++- 10 files changed, 153 insertions(+), 36 deletions(-) diff --git a/src/a365/contracts.ts b/src/a365/contracts.ts index 5867ee4..d2382a1 100644 --- a/src/a365/contracts.ts +++ b/src/a365/contracts.ts @@ -306,6 +306,8 @@ export interface Channel { export interface Request { /** The input content (prompt / messages) for the request. */ content?: InputMessagesParam; + /** Name of the service or framework that initiated the operation. */ + operationSource?: string; /** Identifier of the session this request belongs to. */ sessionId?: string; /** The channel the request originated from. */ diff --git a/src/a365/scopes/ApplyGuardrailScope.ts b/src/a365/scopes/ApplyGuardrailScope.ts index 0de1e22..9c5e058 100644 --- a/src/a365/scopes/ApplyGuardrailScope.ts +++ b/src/a365/scopes/ApplyGuardrailScope.ts @@ -69,6 +69,7 @@ export class ApplyGuardrailScope extends OpenTelemetryScope { agentDetails, resolvedSpanDetails, userDetails, + request, ); // Required attributes @@ -119,11 +120,6 @@ export class ApplyGuardrailScope extends OpenTelemetryScope { if (typeof request.content === "string") { this.setTagMaybe(OpenTelemetryConstants.SECURITY_CONTENT_INPUT_VALUE_KEY, request.content); } - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - if (request.channel) { - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel.description); - } } } diff --git a/src/a365/scopes/ExecuteToolScope.ts b/src/a365/scopes/ExecuteToolScope.ts index b33d3c5..5774e23 100644 --- a/src/a365/scopes/ExecuteToolScope.ts +++ b/src/a365/scopes/ExecuteToolScope.ts @@ -59,6 +59,7 @@ export class ExecuteToolScope extends OpenTelemetryScope { agentDetails, resolvedSpanDetails, userDetails, + request, ); const { toolName, arguments: args, toolCallId, description, toolType, endpoint } = details; @@ -74,11 +75,6 @@ export class ExecuteToolScope extends OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_CALL_ID_KEY, toolCallId); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_DESCRIPTION_KEY, description); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request.sessionId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); - if (endpoint) { this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, endpoint.host); if (endpoint.port && endpoint.port !== 443) { diff --git a/src/a365/scopes/InferenceScope.ts b/src/a365/scopes/InferenceScope.ts index a53d7c5..b75ec5e 100644 --- a/src/a365/scopes/InferenceScope.ts +++ b/src/a365/scopes/InferenceScope.ts @@ -57,6 +57,7 @@ export class InferenceScope extends OpenTelemetryScope { agentDetails, resolvedSpanDetails, userDetails, + request, ); // Core inference information @@ -73,12 +74,6 @@ export class InferenceScope extends OpenTelemetryScope { details.thoughtProcess, ); - // Conversation and channel - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request.sessionId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); - // Endpoint if (details.endpoint) { this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, details.endpoint.host); diff --git a/src/a365/scopes/InvokeAgentScope.ts b/src/a365/scopes/InvokeAgentScope.ts index 528b412..6048487 100644 --- a/src/a365/scopes/InvokeAgentScope.ts +++ b/src/a365/scopes/InvokeAgentScope.ts @@ -71,11 +71,9 @@ export class InvokeAgentScope extends OpenTelemetryScope { agentDetails, resolvedSpanDetails, callerDetails?.userDetails, + request, ); - // Session ID - this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request.sessionId); - // Endpoint if (invokeScopeDetails.endpoint) { this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, invokeScopeDetails.endpoint.host); @@ -84,15 +82,6 @@ export class InvokeAgentScope extends OpenTelemetryScope { } } - // Channel - if (request.channel) { - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel.description); - } - - // Conversation ID - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - // Request content as input messages if (request.content != null) { this.recordInputMessages(request.content); diff --git a/src/a365/scopes/OpenTelemetryScope.ts b/src/a365/scopes/OpenTelemetryScope.ts index 2b4729e..829a6d6 100644 --- a/src/a365/scopes/OpenTelemetryScope.ts +++ b/src/a365/scopes/OpenTelemetryScope.ts @@ -14,6 +14,7 @@ import type { AgentDetails, UserDetails, SpanDetails, + Request, InputMessagesParam, OutputMessagesParam, } from "../contracts.js"; @@ -60,6 +61,7 @@ export abstract class OpenTelemetryScope { * @param agentDetails Optional agent details. Tenant ID is read from `agentDetails.tenantId`. * @param spanDetails Optional span configuration including parent context, start/end times, span kind, and span links. * @param userDetails Optional human caller identity details. + * @param request Optional request context shared by all scope types. */ protected constructor( operationName: string, @@ -67,6 +69,7 @@ export abstract class OpenTelemetryScope { agentDetails?: AgentDetails, spanDetails?: SpanDetails, userDetails?: UserDetails, + request?: Request, ) { const parentContext = spanDetails?.parentContext; const startTime = spanDetails?.startTime; @@ -103,6 +106,13 @@ export abstract class OpenTelemetryScope { } this.customEndTime = endTime; + // Set shared request context + this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request?.sessionId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request?.conversationId); + this.setTagMaybe(OpenTelemetryConstants.SERVICE_NAME_KEY, request?.operationSource); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request?.channel?.name); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request?.channel?.description); + // Set agent details if (agentDetails) { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, agentDetails.agentId); diff --git a/src/a365/scopes/OutputScope.ts b/src/a365/scopes/OutputScope.ts index 296e5d4..2561228 100644 --- a/src/a365/scopes/OutputScope.ts +++ b/src/a365/scopes/OutputScope.ts @@ -60,15 +60,10 @@ export class OutputScope extends OpenTelemetryScope { agentDetails, resolvedSpanDetails, userDetails, + request, ); - // Set initial output messages this._setOutput(response.messages); - - // Conversation and channel - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); } /** diff --git a/test/internal/unit/a365/applyGuardrailScope.test.ts b/test/internal/unit/a365/applyGuardrailScope.test.ts index 39670b2..a0337a7 100644 --- a/test/internal/unit/a365/applyGuardrailScope.test.ts +++ b/test/internal/unit/a365/applyGuardrailScope.test.ts @@ -104,6 +104,33 @@ describe("ApplyGuardrailScope", () => { expect(getFinishedSpan().kind).toBe(SpanKind.CLIENT); }); + it("should write shared request context attributes", () => { + const scope = ApplyGuardrailScope.start( + { targetType: GuardrailTargetType.LlmInput, decisionType: GuardrailDecisionType.Allow }, + testAgentDetails, + { + content: "Guard this prompt", + conversationId: "guardrail-conversation", + sessionId: "guardrail-session", + operationSource: "guardrail-host", + channel: { name: "Teams", description: "https://teams.example" }, + }, + ); + scope.dispose(); + + const attributes = getFinishedSpan().attributes; + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe( + "guardrail-conversation", + ); + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("guardrail-session"); + expect(attributes[OpenTelemetryConstants.SERVICE_NAME_KEY]).toBe("guardrail-host"); + expect(attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("Teams"); + expect(attributes[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe("https://teams.example"); + expect(attributes[OpenTelemetryConstants.SECURITY_CONTENT_INPUT_VALUE_KEY]).toBe( + "Guard this prompt", + ); + }); + it("should throw when agentDetails.tenantId is missing", () => { expect(() => ApplyGuardrailScope.start( diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts index df8baa2..ae95cc4 100644 --- a/test/internal/unit/a365/scopes.test.ts +++ b/test/internal/unit/a365/scopes.test.ts @@ -1200,6 +1200,34 @@ describe("Request content and message serialization (span attributes)", () => { }); }); + describe("shared request context span attributes", () => { + it("should write request.operationSource to service.name", () => { + const scope = InvokeAgentScope.start( + { ...testRequest, operationSource: "agent-framework" }, + {}, + testAgentDetails, + ); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.SERVICE_NAME_KEY]).toBe( + "agent-framework", + ); + }); + + it("should write request.sessionId on output spans", () => { + const scope = OutputScope.start( + { ...testRequest, sessionId: "session-output-123" }, + { messages: "Hello" }, + testAgentDetails, + ); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe( + "session-output-123", + ); + }); + }); + describe("InvokeAgentScope – GenAI request and response parameters", () => { it("should record all request attributes and response-at-start attributes", () => { const requestParameters: GenAiRequestParameters = { @@ -1782,6 +1810,8 @@ describe("recordAttributes ownership and precedence", () => { const scope = InvokeAgentScope.start( { conversationId: "conv-owned", + sessionId: "session-owned", + operationSource: "service-owned", channel: { name: "Teams", description: "https://teams.example" }, }, {}, @@ -1791,6 +1821,8 @@ describe("recordAttributes ownership and precedence", () => { scope.recordAttributes({ [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]: "Override Agent", [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]: "override-conv", + [OpenTelemetryConstants.SESSION_ID_KEY]: "override-session", + [OpenTelemetryConstants.SERVICE_NAME_KEY]: "override-service", "custom.attribute": "custom value", }); scope.dispose(); @@ -1798,6 +1830,8 @@ describe("recordAttributes ownership and precedence", () => { const attributes = getLastSpan().attributes; expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe("Test Agent"); expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("conv-owned"); + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("session-owned"); + expect(attributes[OpenTelemetryConstants.SERVICE_NAME_KEY]).toBe("service-owned"); expect(attributes["custom.attribute"]).toBe("custom value"); }); diff --git a/test/internal/unit/a365/scopesAfterDistroInit.test.ts b/test/internal/unit/a365/scopesAfterDistroInit.test.ts index ca8f064..a553062 100644 --- a/test/internal/unit/a365/scopesAfterDistroInit.test.ts +++ b/test/internal/unit/a365/scopesAfterDistroInit.test.ts @@ -14,7 +14,7 @@ * span-creation time instead of caching it in a static field. */ import { describe, it, expect, afterEach } from "vitest"; -import { trace, context as otelContext } from "@opentelemetry/api"; +import { trace, context as otelContext, propagation } from "@opentelemetry/api"; import { InMemorySpanExporter, SimpleSpanProcessor, @@ -29,6 +29,8 @@ import { InferenceScope, ExecuteToolScope, OutputScope, + A365SpanProcessor, + OpenTelemetryConstants, InferenceOperationType, } from "../../../../src/a365/index.js"; import type { AgentDetails } from "../../../../src/a365/index.js"; @@ -50,7 +52,7 @@ function simulateDistroInit(): InMemorySpanExporter { // ── Step 2: Register a new provider (like NodeSDK.start()) ────── const exporter = new InMemorySpanExporter(); const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], + spanProcessors: [new A365SpanProcessor(), new SimpleSpanProcessor(exporter)], }); const contextManager = new AsyncLocalStorageContextManager(); @@ -61,6 +63,14 @@ function simulateDistroInit(): InMemorySpanExporter { return exporter; } +function withBaggage(entries: Record, callback: () => void): void { + let baggage = propagation.createBaggage(); + for (const [key, value] of Object.entries(entries)) { + baggage = baggage.setEntry(key, { value }); + } + otelContext.with(propagation.setBaggage(otelContext.active(), baggage), callback); +} + describe("A365 scopes after distro global-state reset", () => { const agentDetails: AgentDetails = { agentId: "test-agent", @@ -135,6 +145,69 @@ describe("A365 scopes after distro global-state reset", () => { expect(spans.length).toBe(1); }); + it("explicit request context should override baggage attributes", () => { + const exporter = simulateDistroInit(); + + withBaggage( + { + [OpenTelemetryConstants.SESSION_ID_KEY]: "baggage-session", + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]: "baggage-conversation", + [OpenTelemetryConstants.SERVICE_NAME_KEY]: "baggage-service", + [OpenTelemetryConstants.CHANNEL_NAME_KEY]: "Baggage Channel", + [OpenTelemetryConstants.CHANNEL_LINK_KEY]: "https://baggage.example", + }, + () => { + const scope = InvokeAgentScope.start( + { + sessionId: "request-session", + conversationId: "request-conversation", + operationSource: "request-service", + channel: { name: "Request Channel", description: "https://request.example" }, + }, + {}, + agentDetails, + ); + scope.dispose(); + }, + ); + + const attributes = exporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("request-session"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe( + "request-conversation", + ); + expect(attributes[OpenTelemetryConstants.SERVICE_NAME_KEY]).toBe("request-service"); + expect(attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("Request Channel"); + expect(attributes[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe("https://request.example"); + }); + + it("absent request context should preserve baggage attributes", () => { + const exporter = simulateDistroInit(); + + withBaggage( + { + [OpenTelemetryConstants.SESSION_ID_KEY]: "baggage-session", + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]: "baggage-conversation", + [OpenTelemetryConstants.SERVICE_NAME_KEY]: "baggage-service", + [OpenTelemetryConstants.CHANNEL_NAME_KEY]: "Baggage Channel", + [OpenTelemetryConstants.CHANNEL_LINK_KEY]: "https://baggage.example", + }, + () => { + const scope = InvokeAgentScope.start({}, {}, agentDetails); + scope.dispose(); + }, + ); + + const attributes = exporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("baggage-session"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe( + "baggage-conversation", + ); + expect(attributes[OpenTelemetryConstants.SERVICE_NAME_KEY]).toBe("baggage-service"); + expect(attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("Baggage Channel"); + expect(attributes[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe("https://baggage.example"); + }); + it("ConsoleSpanExporter scenario: spans reach user-provided processors", () => { const exporter = simulateDistroInit(); From ef416b8e43e00a4b717621048b00dda75177035e Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 30 Sep 2026 15:28:44 -0600 Subject: [PATCH 4/5] test(a365): cover late typed attribute ownership Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9834b6ca-9d85-4b99-aa3d-c346c03485d2 --- test/internal/unit/a365/scopes.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts index ae95cc4..29d0d75 100644 --- a/test/internal/unit/a365/scopes.test.ts +++ b/test/internal/unit/a365/scopes.test.ts @@ -1870,6 +1870,25 @@ describe("recordAttributes ownership and precedence", () => { ); }); + it("should protect a key after a late typed setter claims ownership", () => { + const scope = InferenceScope.start( + { conversationId: "conv-late-owned" }, + { operationName: InferenceOperationType.CHAT, model: "gpt-4" }, + testAgentDetails, + ); + + scope.recordAttributes({ + [OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY]: 10, + }); + scope.recordInputTokens(20); + scope.recordAttributes({ + [OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY]: 30, + }); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY]).toBe(20); + }); + it("should keep custom recordAttributes keys last-write-wins across repeated calls", () => { const scope = ExecuteToolScope.start( { conversationId: "conv-custom-repeat" }, From 4c920318fd9d5cc55b02cc51ae75e15612ea8742 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 30 Sep 2026 15:34:00 -0600 Subject: [PATCH 5/5] refactor(a365): keep recordAttributes last-write-wins Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9834b6ca-9d85-4b99-aa3d-c346c03485d2 --- CHANGELOG.md | 2 +- src/a365/scopes/OpenTelemetryScope.ts | 21 ++++++--------------- test/internal/unit/a365/scopes.test.ts | 24 ++++++++++++------------ 3 files changed, 19 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 654adc7..acb4251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Bugs Fixed -- A365: prevent `recordAttributes()` from overwriting scope-owned attributes such as builder-populated tags and `gen_ai.operation.name`, while still allowing late writes for known keys that were never set and repeated last-write-wins custom attributes. [#243](https://github.com/microsoft/opentelemetry-distro-javascript/pull/243) +- A365: centralize shared request attributes in the base scope, including session and conversation IDs, channel details, and `operationSource` as `service.name`, while retaining last-write-wins `recordAttributes()` behavior. [#243](https://github.com/microsoft/opentelemetry-distro-javascript/pull/243) - Default `InvokeAgentScope` spans to `SpanKind.INTERNAL` while preserving explicit span-kind overrides. [#241](https://github.com/microsoft/opentelemetry-distro-javascript/pull/241) ### Features Added diff --git a/src/a365/scopes/OpenTelemetryScope.ts b/src/a365/scopes/OpenTelemetryScope.ts index 829a6d6..98081c7 100644 --- a/src/a365/scopes/OpenTelemetryScope.ts +++ b/src/a365/scopes/OpenTelemetryScope.ts @@ -52,7 +52,6 @@ export abstract class OpenTelemetryScope { private customEndTime?: TimeInput; private errorType?: string; private hasEnded = false; - private readonly ownedAttributeKeys = new Set(); private readonly logger = Logger.getInstance(); /** @@ -98,7 +97,6 @@ export abstract class OpenTelemetryScope { }, currentContext, ); - this.ownedAttributeKeys.add(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY); this.wallClockStartMs = Date.now(); if (startTime !== undefined) { @@ -177,10 +175,7 @@ export abstract class OpenTelemetryScope { this.span.recordException(error); } - /** - * Records multiple attribute key/value pairs without overwriting values that - * the scope already populated through its typed setters or span builder. - */ + /** Records multiple attribute key/value pairs using last-write-wins semantics. */ public recordAttributes( attributes: Iterable<[string, AttributeValue]> | Record | null | undefined, @@ -188,9 +183,9 @@ export abstract class OpenTelemetryScope { if (!attributes) return; if (Symbol.iterator in Object(attributes) && typeof attributes !== "string") { - this.recordUnownedAttributes(attributes as Iterable<[string, AttributeValue]>); + this.recordAttributeEntries(attributes as Iterable<[string, AttributeValue]>); } else if (typeof attributes === "object") { - this.recordUnownedAttributes(Object.entries(attributes as Record)); + this.recordAttributeEntries(Object.entries(attributes as Record)); } } @@ -206,10 +201,7 @@ export abstract class OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, serializeMessages(wrapper)); } - /** - * Sets a tag on the span if the value is not null or undefined, and marks the - * key as owned by the scope so later generic attribute writes do not replace it. - */ + /** Sets a tag on the span if the value is not null or undefined. */ protected setTagMaybe( name: string, value: T | null | undefined, @@ -218,13 +210,12 @@ export abstract class OpenTelemetryScope { this.span.setAttributes({ [name]: value as string | number | boolean | string[] | number[], }); - this.ownedAttributeKeys.add(name); } } - private recordUnownedAttributes(attributes: Iterable<[string, AttributeValue]>): void { + private recordAttributeEntries(attributes: Iterable<[string, AttributeValue]>): void { for (const [key, value] of attributes) { - if (!OpenTelemetryScope.isNonBlankAttributeKey(key) || this.ownedAttributeKeys.has(key)) { + if (!OpenTelemetryScope.isNonBlankAttributeKey(key)) { continue; } diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts index 29d0d75..9b0ccd4 100644 --- a/test/internal/unit/a365/scopes.test.ts +++ b/test/internal/unit/a365/scopes.test.ts @@ -1789,7 +1789,7 @@ describe("Request content and message serialization (span attributes)", () => { }); }); -describe("recordAttributes ownership and precedence", () => { +describe("recordAttributes last-write-wins behavior", () => { const testAgentDetails: AgentDetails = { agentId: "test-agent", agentName: "Test Agent", @@ -1806,7 +1806,7 @@ describe("recordAttributes ownership and precedence", () => { return spans[spans.length - 1]; }; - it("should preserve builder-populated attributes when recordAttributes sees the same keys", () => { + it("should overwrite builder-populated attributes when recordAttributes sees the same keys", () => { const scope = InvokeAgentScope.start( { conversationId: "conv-owned", @@ -1828,14 +1828,14 @@ describe("recordAttributes ownership and precedence", () => { scope.dispose(); const attributes = getLastSpan().attributes; - expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe("Test Agent"); - expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("conv-owned"); - expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("session-owned"); - expect(attributes[OpenTelemetryConstants.SERVICE_NAME_KEY]).toBe("service-owned"); + expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe("Override Agent"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("override-conv"); + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("override-session"); + expect(attributes[OpenTelemetryConstants.SERVICE_NAME_KEY]).toBe("override-service"); expect(attributes["custom.attribute"]).toBe("custom value"); }); - it("should preserve the span builder operation name when recordAttributes provides another value", () => { + it("should overwrite the span builder operation name", () => { const scope = ExecuteToolScope.start( { conversationId: "conv-op-name" }, { toolName: "search" }, @@ -1849,7 +1849,7 @@ describe("recordAttributes ownership and precedence", () => { scope.dispose(); expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe( - OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, + OpenTelemetryConstants.CHAT_OPERATION_NAME, ); }); @@ -1870,7 +1870,7 @@ describe("recordAttributes ownership and precedence", () => { ); }); - it("should protect a key after a late typed setter claims ownership", () => { + it("should let a generic write overwrite a late typed setter", () => { const scope = InferenceScope.start( { conversationId: "conv-late-owned" }, { operationName: InferenceOperationType.CHAT, model: "gpt-4" }, @@ -1886,7 +1886,7 @@ describe("recordAttributes ownership and precedence", () => { }); scope.dispose(); - expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY]).toBe(20); + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY]).toBe(30); }); it("should keep custom recordAttributes keys last-write-wins across repeated calls", () => { @@ -1903,7 +1903,7 @@ describe("recordAttributes ownership and precedence", () => { expect(getLastSpan().attributes["custom.repeat"]).toBe("second"); }); - it("should support iterable attributes while skipping owned and blank keys", () => { + it("should support iterable attributes while skipping blank keys", () => { const scope = InvokeAgentScope.start( { conversationId: "conv-iterable", channel: { name: "Teams" } }, {}, @@ -1919,7 +1919,7 @@ describe("recordAttributes ownership and precedence", () => { scope.dispose(); const attributes = getLastSpan().attributes; - expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("conv-iterable"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("override-conv"); expect(attributes["custom.iterable"]).toBe(42); expect(attributes[""]).toBeUndefined(); expect(attributes[" "]).toBeUndefined();