From 9fff53126e3e4f88ed84ad895d113f450b375595 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 07:46:29 -0600 Subject: [PATCH 01/24] Add session id to scope spans Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/a365/scopes/ExecuteToolScope.ts | 1 + src/a365/scopes/InferenceScope.ts | 1 + test/internal/unit/a365/scopes.test.ts | 38 ++++++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/src/a365/scopes/ExecuteToolScope.ts b/src/a365/scopes/ExecuteToolScope.ts index 84b4b908..fbd2d076 100644 --- a/src/a365/scopes/ExecuteToolScope.ts +++ b/src/a365/scopes/ExecuteToolScope.ts @@ -72,6 +72,7 @@ export class ExecuteToolScope extends OpenTelemetryScope { 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); diff --git a/src/a365/scopes/InferenceScope.ts b/src/a365/scopes/InferenceScope.ts index 821ce5ca..a53d7c58 100644 --- a/src/a365/scopes/InferenceScope.ts +++ b/src/a365/scopes/InferenceScope.ts @@ -75,6 +75,7 @@ export class InferenceScope extends OpenTelemetryScope { // 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); diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts index 4c51f71e..469eba25 100644 --- a/test/internal/unit/a365/scopes.test.ts +++ b/test/internal/unit/a365/scopes.test.ts @@ -569,6 +569,7 @@ describe("Scopes", () => { const scope = ExecuteToolScope.start( { conversationId: "conv-tool-123", + sessionId: "session-tool-123", channel: { name: "ChannelTool", description: "https://channel/tool" }, }, { toolName: "test-tool" }, @@ -583,6 +584,10 @@ describe("Scopes", () => { key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: "conv-tool-123", }), + expect.objectContaining({ + key: OpenTelemetryConstants.SESSION_ID_KEY, + val: "session-tool-123", + }), expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: "ChannelTool", @@ -828,6 +833,7 @@ describe("Scopes", () => { const scope = InferenceScope.start( { conversationId: "conv-inf-123", + sessionId: "session-inf-123", channel: { name: "ChannelInf", description: "https://channel/inf" }, }, inferenceDetails, @@ -842,6 +848,10 @@ describe("Scopes", () => { key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: "conv-inf-123", }), + expect.objectContaining({ + key: OpenTelemetryConstants.SESSION_ID_KEY, + val: "session-inf-123", + }), expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: "ChannelInf", @@ -1147,6 +1157,34 @@ describe("Request content and message serialization (span attributes)", () => { }); }); + describe("InferenceScope – session id span attribute", () => { + it("should write request.sessionId directly to the span", () => { + const scope = InferenceScope.start( + { ...testRequest, sessionId: "session-inf-123" }, + { operationName: InferenceOperationType.CHAT, model: "gpt-4o" }, + testAgentDetails, + ); + scope.dispose(); + + const attributes = getLastSpan().attributes; + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("session-inf-123"); + }); + }); + + describe("ExecuteToolScope – session id span attribute", () => { + it("should write request.sessionId directly to the span", () => { + const scope = ExecuteToolScope.start( + { ...testRequest, sessionId: "session-tool-123" }, + { toolName: "lookup", toolCallId: "tool-call-1", toolType: "function" }, + testAgentDetails, + ); + scope.dispose(); + + const attributes = getLastSpan().attributes; + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("session-tool-123"); + }); + }); + describe("OutputScope", () => { it("should create scope with agent and request details", () => { const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); From 70cfe812773ce21608ec732aacf576be0d73871d Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 07:54:49 -0600 Subject: [PATCH 02/24] chore: record Copilot session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c From cd1126102db0c5148a0797234e62a5870172ee9c Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 08:06:53 -0600 Subject: [PATCH 03/24] Add custom baggage metadata tracking Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/a365/constants.ts | 3 + src/a365/middleware/BaggageBuilder.ts | 140 +++++++++++++--- .../internal/unit/a365/baggageBuilder.test.ts | 152 ++++++++++++++++++ 3 files changed, 275 insertions(+), 20 deletions(-) diff --git a/src/a365/constants.ts b/src/a365/constants.ts index 94f28a98..96b0da57 100644 --- a/src/a365/constants.ts +++ b/src/a365/constants.ts @@ -3,6 +3,9 @@ import { MICROSOFT_OPENTELEMETRY_VERSION } from "../types.js"; +/** Internal baggage metadata key for registered custom attribute names. */ +export const INTERNAL_CUSTOM_KEYS_METADATA_KEY = "_internal.custom_keys"; + /** * OpenTelemetry constants for A365 observability. * diff --git a/src/a365/middleware/BaggageBuilder.ts b/src/a365/middleware/BaggageBuilder.ts index cf62ed61..dbe5a270 100644 --- a/src/a365/middleware/BaggageBuilder.ts +++ b/src/a365/middleware/BaggageBuilder.ts @@ -12,7 +12,70 @@ import { propagation, context as otelContext } from "@opentelemetry/api"; import type { Context } from "@opentelemetry/api"; -import { OpenTelemetryConstants } from "../constants.js"; +import { INTERNAL_CUSTOM_KEYS_METADATA_KEY, OpenTelemetryConstants } from "../constants.js"; + +type BaggagePairs = + | Record + | Iterable<[string, unknown]> + | null + | undefined; + +function getPairEntries( + pairs: Record | Iterable<[string, unknown]>, +): Iterable<[string, unknown]> { + if (Symbol.iterator in Object(pairs)) { + return pairs as Iterable<[string, unknown]>; + } + + return Object.entries(pairs); +} + +function normalizeValue(value: string | null | undefined): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeCustomKey(key: string): string | undefined { + const trimmed = key.trim(); + if (!trimmed || trimmed.includes(",") || trimmed === INTERNAL_CUSTOM_KEYS_METADATA_KEY) { + return undefined; + } + + return trimmed; +} + +function parseCustomKeys(value: string | undefined): Set { + const keys = new Set(); + if (!value) { + return keys; + } + + for (const rawKey of value.split(",")) { + const key = normalizeCustomKey(rawKey); + if (key) { + keys.add(key); + } + } + + return keys; +} + +function serializeCustomKeys(customKeys: Iterable): string | undefined { + const normalizedKeys = new Set(); + for (const customKey of customKeys) { + const normalizedKey = normalizeCustomKey(customKey); + if (normalizedKey) { + normalizedKeys.add(normalizedKey); + } + } + + const sortedKeys = [...normalizedKeys].sort((left, right) => left.localeCompare(right)); + return sortedKeys.length > 0 ? sortedKeys.join(",") : undefined; +} /** * Fluent builder for setting OpenTelemetry baggage values. @@ -31,6 +94,7 @@ import { OpenTelemetryConstants } from "../constants.js"; */ export class BaggageBuilder { private pairs: Map = new Map(); + private customKeys: Set = new Set(); /** Set the operation source baggage value (e.g., ATG, ACF). */ operationSource(value: string | null | undefined): BaggageBuilder { @@ -178,25 +242,47 @@ export class BaggageBuilder { * @param pairs Dictionary or iterable of key-value pairs */ - setPairs( - pairs: Record | Iterable<[string, any]> | null | undefined, - ): BaggageBuilder { + setPairs(pairs: BaggagePairs): BaggageBuilder { if (!pairs) { return this; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let entries: Iterable<[string, any]>; - if (Symbol.iterator in Object(pairs)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - entries = pairs as Iterable<[string, any]>; - } else { - entries = Object.entries(pairs); + for (const [key, value] of getPairEntries(pairs)) { + if (value !== null && value !== undefined) { + this.set(key, String(value)); + } + } + + return this; + } + + /** + * Set a single custom baggage pair and register its key for metadata propagation. + */ + customAttribute(key: string, value: string | null | undefined): BaggageBuilder { + const normalizedKey = normalizeCustomKey(key); + const normalizedValue = normalizeValue(value); + + if (normalizedKey && normalizedValue) { + this.pairs.set(normalizedKey, normalizedValue); + this.customKeys.add(normalizedKey); + } + + return this; + } + + /** + * Set multiple custom baggage pairs and register their keys for metadata propagation. + * @param pairs Dictionary or iterable of key-value pairs + */ + customAttributes(pairs: BaggagePairs): BaggageBuilder { + if (!pairs) { + return this; } - for (const [key, value] of entries) { + for (const [key, value] of getPairEntries(pairs)) { if (value !== null && value !== undefined) { - this.set(key, String(value)); + this.customAttribute(key, String(value)); } } @@ -208,18 +294,16 @@ export class BaggageBuilder { * @returns A BaggageScope that can run callbacks under the baggage context */ build(): BaggageScope { - return new BaggageScope(this.pairs); + return new BaggageScope(this.pairs, this.customKeys); } /** * Add a baggage key/value if the value is not null or whitespace. */ private set(key: string, value: string | null | undefined): void { - if (value !== null && value !== undefined) { - const trimmed = value.trim(); - if (trimmed) { - this.pairs.set(key, trimmed); - } + const trimmed = normalizeValue(value); + if (trimmed) { + this.pairs.set(key, trimmed); } } @@ -244,18 +328,34 @@ export class BaggageScope { /** @internal Exposed for testing. */ readonly contextWithBaggage: Context; - constructor(pairs: Map) { + constructor(pairs: Map, customKeys: ReadonlySet = new Set()) { // 1. Start from current active context const currentCtx = otelContext.active(); // 2. Build merged baggage let bag = propagation.getBaggage(currentCtx) ?? propagation.createBaggage({}); + const mergedCustomKeys = parseCustomKeys( + bag.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value, + ); + for (const [key, value] of pairs.entries()) { if (value && value.trim()) { bag = bag.setEntry(key, { value }); } } + for (const customKey of customKeys) { + const normalizedKey = normalizeCustomKey(customKey); + if (normalizedKey) { + mergedCustomKeys.add(normalizedKey); + } + } + + const customKeysMetadata = serializeCustomKeys(mergedCustomKeys); + bag = customKeysMetadata + ? bag.setEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY, { value: customKeysMetadata }) + : bag.removeEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY); + // 3. Create a new context that carries that baggage this.contextWithBaggage = propagation.setBaggage(currentCtx, bag); } diff --git a/test/internal/unit/a365/baggageBuilder.test.ts b/test/internal/unit/a365/baggageBuilder.test.ts index 53bb25da..9d529d05 100644 --- a/test/internal/unit/a365/baggageBuilder.test.ts +++ b/test/internal/unit/a365/baggageBuilder.test.ts @@ -11,6 +11,13 @@ import { OpenTelemetryConstants, } from "../../../../src/a365/index.js"; +const INTERNAL_CUSTOM_KEYS_METADATA_KEY = "_internal.custom_keys"; + +function getScopeBaggage(scope: BaggageScope) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return propagation.getBaggage((scope as any).contextWithBaggage); +} + describe("BaggageBuilder", () => { let contextManager: AsyncLocalStorageContextManager; @@ -153,6 +160,90 @@ describe("BaggageBuilder", () => { const scope = builder.build(); expect(scope).toBeInstanceOf(BaggageScope); }); + + it("should not mark setPairs entries as custom metadata", () => { + const scope = new BaggageBuilder() + .setPairs({ + custom_key: "custom-value", + }) + .build(); + + const bag = getScopeBaggage(scope); + expect(bag?.getEntry("custom_key")?.value).toBe("custom-value"); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)).toBeUndefined(); + }); + }); + + describe("custom attributes", () => { + it("should normalize and mark a custom attribute", () => { + const builder = new BaggageBuilder(); + expect(typeof (builder as unknown as { customAttribute?: unknown }).customAttribute).toBe( + "function", + ); + + const scope = ( + builder as unknown as { + customAttribute(key: string, value: string | null | undefined): BaggageBuilder; + } + ) + .customAttribute(" custom.key ", " custom-value ") + .build(); + + const bag = getScopeBaggage(scope); + expect(bag?.getEntry("custom.key")?.value).toBe("custom-value"); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("custom.key"); + }); + + it("should accept object inputs and stringify valid values", () => { + const scope = ( + new BaggageBuilder() as unknown as { + customAttributes( + pairs: Record | Iterable<[string, unknown]> | null | undefined, + ): BaggageBuilder; + } + ) + .customAttributes({ + " beta ": " two ", + alpha: 1, + blank: " ", + skipNull: null, + }) + .build(); + + const bag = getScopeBaggage(scope); + expect(bag?.getEntry("alpha")?.value).toBe("1"); + expect(bag?.getEntry("beta")?.value).toBe("two"); + expect(bag?.getEntry("blank")).toBeUndefined(); + expect(bag?.getEntry("skipNull")).toBeUndefined(); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("alpha,beta"); + }); + + it("should ignore invalid iterable keys and values", () => { + const scope = ( + new BaggageBuilder() as unknown as { + customAttributes( + pairs: Record | Iterable<[string, unknown]> | null | undefined, + ): BaggageBuilder; + } + ) + .customAttributes([ + [" valid ", " kept "], + [" ", "blank-key"], + ["bad,key", "comma-key"], + [INTERNAL_CUSTOM_KEYS_METADATA_KEY, "reserved-key"], + ["blank-value", " "], + ["null-value", null], + ]) + .build(); + + const bag = getScopeBaggage(scope); + expect(bag?.getEntry("valid")?.value).toBe("kept"); + expect(bag?.getEntry("")).toBeUndefined(); + expect(bag?.getEntry("bad,key")).toBeUndefined(); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("valid"); + expect(bag?.getEntry("blank-value")).toBeUndefined(); + expect(bag?.getEntry("null-value")).toBeUndefined(); + }); }); describe("null and whitespace handling", () => { @@ -325,6 +416,67 @@ describe("BaggageScope", () => { const restoredContext = context.active(); expect(restoredContext).toBeDefined(); }); + + it("should union custom metadata across nested scopes", () => { + const outerScope = ( + new BaggageBuilder() as unknown as { + customAttributes( + pairs: Record | Iterable<[string, unknown]> | null | undefined, + ): BaggageBuilder; + } + ) + .customAttributes({ + beta: "two", + alpha: "one", + }) + .build(); + + outerScope.run(() => { + const innerScope = ( + new BaggageBuilder() as unknown as { + customAttributes( + pairs: Record | Iterable<[string, unknown]> | null | undefined, + ): BaggageBuilder; + } + ) + .customAttributes([ + ["gamma", "three"], + ["beta", "updated"], + ]) + .build(); + + const bag = getScopeBaggage(innerScope); + expect(bag?.getEntry("alpha")?.value).toBe("one"); + expect(bag?.getEntry("beta")?.value).toBe("updated"); + expect(bag?.getEntry("gamma")?.value).toBe("three"); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("alpha,beta,gamma"); + }); + }); + + it("should deduplicate ambient custom metadata deterministically", () => { + const ambientBaggage = propagation + .createBaggage({}) + .setEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY, { + value: "beta, alpha ,beta", + }) + .setEntry("alpha", { value: "one" }); + const ambientContext = propagation.setBaggage(context.active(), ambientBaggage); + + context.with(ambientContext, () => { + const scope = ( + new BaggageBuilder() as unknown as { + customAttribute(key: string, value: string | null | undefined): BaggageBuilder; + } + ) + .customAttribute(" gamma ", " three ") + .build(); + + const bag = getScopeBaggage(scope); + expect(bag?.getEntry("alpha")?.value).toBe("one"); + expect(bag?.getEntry("gamma")?.value).toBe("three"); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("alpha,beta,gamma"); + }); + }); }); describe("disposable pattern", () => { From 73bbc5a7eb59a27e39f39963016846d6866e8d6b Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 09:24:16 -0600 Subject: [PATCH 04/24] feat(a365): propagate registered custom baggage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- src/a365/processors/A365SpanProcessor.ts | 22 +++- .../unit/a365/a365SpanProcessor.test.ts | 112 ++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/a365/processors/A365SpanProcessor.ts b/src/a365/processors/A365SpanProcessor.ts index 792b4c82..bb173fc5 100644 --- a/src/a365/processors/A365SpanProcessor.ts +++ b/src/a365/processors/A365SpanProcessor.ts @@ -17,10 +17,24 @@ import type { SpanProcessor as BaseSpanProcessor, ReadableSpan, } from "@opentelemetry/sdk-trace-base"; -import { OpenTelemetryConstants } from "../constants.js"; +import { + INTERNAL_CUSTOM_KEYS_METADATA_KEY, + OpenTelemetryConstants, +} from "../constants.js"; import { GEN_AI_OPERATION_NAMES } from "../exporter/utils.js"; import { GENERIC_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES } from "./util.js"; +function getRegisteredCustomKeys(value: string | undefined): string[] { + if (!value) { + return []; + } + + return value + .split(",") + .map((key) => key.trim()) + .filter((key) => key && key !== INTERNAL_CUSTOM_KEYS_METADATA_KEY); +} + /** * Copies relevant baggage entries to span attributes on span start. * @@ -89,6 +103,9 @@ export class A365SpanProcessor implements BaseSpanProcessor { if (isInvokeAgent) { INVOKE_AGENT_ATTRIBUTES.forEach((key) => targetKeys.add(key)); } + getRegisteredCustomKeys(baggageMap.get(INTERNAL_CUSTOM_KEYS_METADATA_KEY)).forEach((key) => + targetKeys.add(key), + ); // Set telemetry SDK attributes if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY)) { @@ -96,18 +113,21 @@ export class A365SpanProcessor implements BaseSpanProcessor { OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY, OpenTelemetryConstants.TELEMETRY_SDK_NAME_VALUE, ); + existingAttrs.add(OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY); } if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY)) { span.setAttribute( OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY, OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_VALUE, ); + existingAttrs.add(OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY); } if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY)) { span.setAttribute( OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY, OpenTelemetryConstants.TELEMETRY_SDK_VERSION_VALUE, ); + existingAttrs.add(OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY); } // Copy baggage to span attributes diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts index f64d3b88..054699a2 100644 --- a/test/internal/unit/a365/a365SpanProcessor.test.ts +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -16,6 +16,8 @@ import { INVOKE_AGENT_ATTRIBUTES, } from "../../../../src/a365/index.js"; +const INTERNAL_CUSTOM_KEYS_METADATA_KEY = "_internal.custom_keys"; + /** * Helper: creates a baggage instance with the given entries. */ @@ -36,6 +38,7 @@ function startGenAiSpan( operationName: string, baggage: Record = {}, spanName?: string, + attributes: Record = {}, ) { const bag = createBaggage(baggage); const ctx = propagation.setBaggage(context.active(), bag); @@ -46,6 +49,7 @@ function startGenAiSpan( kind: SpanKind.CLIENT, attributes: { [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: operationName, + ...attributes, }, }, ctx, @@ -184,7 +188,9 @@ describe("A365SpanProcessor", () => { it("should not mutate spans with an unknown gen_ai.operation.name value", () => { const bag = createBaggage({ + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + "custom.one": "value-1", }); const ctx = propagation.setBaggage(context.active(), bag); @@ -204,6 +210,7 @@ describe("A365SpanProcessor", () => { const spans = memoryExporter.getFinishedSpans(); expect(spans).toHaveLength(1); const attrs = spans[0].attributes; + expect(attrs["custom.one"]).toBeUndefined(); expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBeUndefined(); }); @@ -361,6 +368,111 @@ describe("A365SpanProcessor", () => { }); }); + describe("registered custom baggage propagation", () => { + it("should copy registered custom baggage attributes from metadata", () => { + const testSpan = startGenAiSpan(provider, "chat", { + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one,custom.two", + "custom.one": "value-1", + "custom.two": "value-2", + "custom.three": "value-3", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + const attrs = spans[0].attributes; + expect(attrs["custom.one"]).toBe("value-1"); + expect(attrs["custom.two"]).toBe("value-2"); + expect(attrs["custom.three"]).toBeUndefined(); + expect(attrs[INTERNAL_CUSTOM_KEYS_METADATA_KEY]).toBeUndefined(); + }); + + it("should trim registered custom baggage metadata and ignore empty entries", () => { + const testSpan = startGenAiSpan(provider, "chat", { + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: " custom.one , , custom.two ,, ", + "custom.one": "value-1", + "custom.two": "value-2", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + const attrs = spans[0].attributes; + expect(attrs["custom.one"]).toBe("value-1"); + expect(attrs["custom.two"]).toBe("value-2"); + }); + + it("should not copy unmarked custom baggage attributes", () => { + const testSpan = startGenAiSpan(provider, "chat", { + "custom.one": "value-1", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0].attributes["custom.one"]).toBeUndefined(); + }); + + it("should keep existing span attributes when registered custom baggage collides", () => { + const testSpan = startGenAiSpan( + provider, + "chat", + { + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-from-baggage", + }, + undefined, + { + "custom.one": "value-existing", + }, + ); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0].attributes["custom.one"]).toBe("value-existing"); + }); + + it("should never copy the custom metadata attribute itself", () => { + const testSpan = startGenAiSpan(provider, "chat", { + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: + `custom.one,${INTERNAL_CUSTOM_KEYS_METADATA_KEY}`, + "custom.one": "value-1", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + const attrs = spans[0].attributes; + expect(attrs["custom.one"]).toBe("value-1"); + expect(attrs[INTERNAL_CUSTOM_KEYS_METADATA_KEY]).toBeUndefined(); + }); + + it("should not allow registered custom baggage to overwrite telemetry SDK attributes", () => { + const testSpan = startGenAiSpan(provider, "chat", { + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: + "telemetry.sdk.name,telemetry.sdk.language,telemetry.sdk.version", + [OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]: "spoofed-sdk", + [OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY]: "spoofed-language", + [OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY]: "0.0.0", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + const attrs = spans[0].attributes; + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBe( + OpenTelemetryConstants.TELEMETRY_SDK_NAME_VALUE, + ); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY]).toBe( + OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_VALUE, + ); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY]).toBe( + OpenTelemetryConstants.TELEMETRY_SDK_VERSION_VALUE, + ); + }); + }); + describe("attribute registry application", () => { it("should apply all generic attributes", () => { expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.TENANT_ID_KEY); From 7f7c0ae30dd1feca437e0157f725967eca7bb9b3 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 10:52:20 -0600 Subject: [PATCH 05/24] docs(a365): document custom baggage propagation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- A365_DOCUMENTATION.md | 26 +++++++++++++++++-- CHANGELOG.md | 3 +++ src/a365/middleware/BaggageBuilder.ts | 14 +++++----- src/a365/processors/A365SpanProcessor.ts | 5 +--- .../unit/a365/a365SpanProcessor.test.ts | 3 +-- 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 7b041e07..f437e9ea 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -31,13 +31,13 @@ const invokeScope = InvokeAgentScope.start( invokeScope.run(async () => { const toolScope = ExecuteToolScope.start( - { conversationId: "conv-123" }, + { conversationId: "conv-123", sessionId: "session-456" }, { toolName: "Search", input: { query: "hello" } }, { agentId: "agent-1", tenantId: "tenant-1" }, ); const inferenceScope = InferenceScope.start( - { conversationId: "conv-123" }, + { conversationId: "conv-123", sessionId: "session-456" }, { operationName: InferenceOperationType.ChatCompletion }, { agentId: "agent-1", tenantId: "tenant-1" }, ); @@ -49,6 +49,10 @@ invokeScope.run(async () => { invokeScope.dispose(); ``` +Each manual scope accepts `request.sessionId`. When you provide it, the scope writes +`microsoft.session.id` directly on the created span instead of relying on later baggage +enrichment. + ## Baggage And Context Use `BaggageBuilder` when you want tenant, agent, user, conversation, or session data to flow with the active context. @@ -61,6 +65,11 @@ const baggageScope = new BaggageBuilder() .agentId("agent-1") .conversationId("conv-123") .sessionId("session-456") + .customAttribute("deployment.ring", "firstrelease") + .customAttributes({ + "feature.name": "grounded-chat", + "customer.segment": "internal", + }) .build(); baggageScope.run(() => { @@ -69,6 +78,19 @@ baggageScope.run(() => { }); ``` +- Baggage may cross process and service boundaries when you inject/extract context. Treat it like + outbound metadata: do not put secrets, access tokens, or PII in baggage values. +- `customAttribute()` and `customAttributes()` trim keys and values before storing them. Blank + keys/values are dropped, keys containing commas are rejected, and the reserved + `_internal.custom_keys` metadata key cannot be set directly. +- Custom baggage enrichment is opt-in. Only keys registered through `customAttribute()` or + `customAttributes()` are copied from baggage onto spans; plain `setPairs()` entries stay in + baggage only. +- Automatic baggage-to-span enrichment only runs for recognized GenAI spans + (`invoke_agent`, `execute_tool`, `chat`, and `output_messages`). +- Explicit span attributes win over baggage. If a span already has a value for a registered custom + key, the span value is preserved. + ## Hosting Use `configureA365Hosting` to register the A365 middleware on an adapter. diff --git a/CHANGELOG.md b/CHANGELOG.md index d61c5f77..5c090b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Features Added +- Add manual `sessionId` propagation to `ExecuteToolScope` and `InferenceScope`, plus opt-in custom baggage enrichment for recognized GenAI spans through `BaggageBuilder.customAttribute()` and `customAttributes()`. + ### Other Changes - Consolidate Dependabot updates for Vitest 4.1.11, Hono 4.13.7, qs 6.16.0, fast-uri 3.1.7, actions/deploy-pages 5.0.1, and actions/checkout 7.0.1. diff --git a/src/a365/middleware/BaggageBuilder.ts b/src/a365/middleware/BaggageBuilder.ts index dbe5a270..084e1a4d 100644 --- a/src/a365/middleware/BaggageBuilder.ts +++ b/src/a365/middleware/BaggageBuilder.ts @@ -14,12 +14,6 @@ import { propagation, context as otelContext } from "@opentelemetry/api"; import type { Context } from "@opentelemetry/api"; import { INTERNAL_CUSTOM_KEYS_METADATA_KEY, OpenTelemetryConstants } from "../constants.js"; -type BaggagePairs = - | Record - | Iterable<[string, unknown]> - | null - | undefined; - function getPairEntries( pairs: Record | Iterable<[string, unknown]>, ): Iterable<[string, unknown]> { @@ -242,7 +236,9 @@ export class BaggageBuilder { * @param pairs Dictionary or iterable of key-value pairs */ - setPairs(pairs: BaggagePairs): BaggageBuilder { + setPairs( + pairs: Record | Iterable<[string, unknown]> | null | undefined, + ): BaggageBuilder { if (!pairs) { return this; } @@ -275,7 +271,9 @@ export class BaggageBuilder { * Set multiple custom baggage pairs and register their keys for metadata propagation. * @param pairs Dictionary or iterable of key-value pairs */ - customAttributes(pairs: BaggagePairs): BaggageBuilder { + customAttributes( + pairs: Record | Iterable<[string, unknown]> | null | undefined, + ): BaggageBuilder { if (!pairs) { return this; } diff --git a/src/a365/processors/A365SpanProcessor.ts b/src/a365/processors/A365SpanProcessor.ts index bb173fc5..12aa26fc 100644 --- a/src/a365/processors/A365SpanProcessor.ts +++ b/src/a365/processors/A365SpanProcessor.ts @@ -17,10 +17,7 @@ import type { SpanProcessor as BaseSpanProcessor, ReadableSpan, } from "@opentelemetry/sdk-trace-base"; -import { - INTERNAL_CUSTOM_KEYS_METADATA_KEY, - OpenTelemetryConstants, -} from "../constants.js"; +import { INTERNAL_CUSTOM_KEYS_METADATA_KEY, OpenTelemetryConstants } from "../constants.js"; import { GEN_AI_OPERATION_NAMES } from "../exporter/utils.js"; import { GENERIC_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES } from "./util.js"; diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts index 054699a2..e24912a9 100644 --- a/test/internal/unit/a365/a365SpanProcessor.test.ts +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -435,8 +435,7 @@ describe("A365SpanProcessor", () => { it("should never copy the custom metadata attribute itself", () => { const testSpan = startGenAiSpan(provider, "chat", { - [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: - `custom.one,${INTERNAL_CUSTOM_KEYS_METADATA_KEY}`, + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: `custom.one,${INTERNAL_CUSTOM_KEYS_METADATA_KEY}`, "custom.one": "value-1", }); testSpan.end(); From e64f5d4f61651997291c92ac0016ab821c4c7117 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 15:16:51 -0600 Subject: [PATCH 06/24] fix(a365): address final baggage review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: de36d0f9-7edb-42c8-bbca-75c9812c1c31 --- A365_DOCUMENTATION.md | 4 ++- src/a365/middleware/BaggageBuilder.ts | 12 +++---- .../internal/unit/a365/baggageBuilder.test.ts | 31 ++++++++++++++++++- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index f437e9ea..38d9f0e6 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -79,7 +79,9 @@ baggageScope.run(() => { ``` - Baggage may cross process and service boundaries when you inject/extract context. Treat it like - outbound metadata: do not put secrets, access tokens, or PII in baggage values. + inbound and outbound metadata: `_internal.custom_keys` registration metadata can arrive through + inbound baggage headers, applications must reject or sanitize untrusted baggage headers at the + edge, and you must never put secrets, access tokens, or PII in baggage keys or values. - `customAttribute()` and `customAttributes()` trim keys and values before storing them. Blank keys/values are dropped, keys containing commas are rejected, and the reserved `_internal.custom_keys` metadata key cannot be set directly. diff --git a/src/a365/middleware/BaggageBuilder.ts b/src/a365/middleware/BaggageBuilder.ts index 084e1a4d..b98e6739 100644 --- a/src/a365/middleware/BaggageBuilder.ts +++ b/src/a365/middleware/BaggageBuilder.ts @@ -14,11 +14,11 @@ import { propagation, context as otelContext } from "@opentelemetry/api"; import type { Context } from "@opentelemetry/api"; import { INTERNAL_CUSTOM_KEYS_METADATA_KEY, OpenTelemetryConstants } from "../constants.js"; -function getPairEntries( - pairs: Record | Iterable<[string, unknown]>, -): Iterable<[string, unknown]> { +function getPairEntries( + pairs: Record | Iterable<[string, T]>, +): Iterable<[string, T]> { if (Symbol.iterator in Object(pairs)) { - return pairs as Iterable<[string, unknown]>; + return pairs as Iterable<[string, T]>; } return Object.entries(pairs); @@ -235,9 +235,9 @@ export class BaggageBuilder { * Set multiple baggage pairs from a dictionary or iterable. * @param pairs Dictionary or iterable of key-value pairs */ - setPairs( - pairs: Record | Iterable<[string, unknown]> | null | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- preserve source compatibility for interface/class-typed callers + pairs: Record | Iterable<[string, any]> | null | undefined, ): BaggageBuilder { if (!pairs) { return this; diff --git a/test/internal/unit/a365/baggageBuilder.test.ts b/test/internal/unit/a365/baggageBuilder.test.ts index 9d529d05..09968c75 100644 --- a/test/internal/unit/a365/baggageBuilder.test.ts +++ b/test/internal/unit/a365/baggageBuilder.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, expectTypeOf, beforeAll, afterAll } from "vitest"; import { context, propagation } from "@opentelemetry/api"; import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; @@ -13,6 +13,11 @@ import { const INTERNAL_CUSTOM_KEYS_METADATA_KEY = "_internal.custom_keys"; +interface InterfaceTypedBaggagePairs { + "microsoft.tenant.id": string; + "gen_ai.agent.id": string; +} + function getScopeBaggage(scope: BaggageScope) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return propagation.getBaggage((scope as any).contextWithBaggage); @@ -118,6 +123,15 @@ describe("BaggageBuilder", () => { }); describe("setPairs", () => { + it("should preserve the public setPairs parameter compatibility", () => { + type SetPairsArg = Parameters[0]; + + + expectTypeOf().toEqualTypeOf< + Record | Iterable<[string, any]> | null | undefined + >(); + }); + it("should accept dictionary of pairs", () => { const builder = new BaggageBuilder(); builder.setPairs({ @@ -129,6 +143,21 @@ describe("BaggageBuilder", () => { expect(scope).toBeInstanceOf(BaggageScope); }); + it("should accept interface-typed object inputs", () => { + const builder = new BaggageBuilder(); + const pairs: InterfaceTypedBaggagePairs = { + "microsoft.tenant.id": "tenant-123", + "gen_ai.agent.id": "agent-456", + }; + + const result: BaggageBuilder = builder.setPairs(pairs); + expect(result).toBe(builder); + + const bag = getScopeBaggage(result.build()); + expect(bag?.getEntry(OpenTelemetryConstants.TENANT_ID_KEY)?.value).toBe("tenant-123"); + expect(bag?.getEntry(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY)?.value).toBe("agent-456"); + }); + it("should accept iterable of pairs", () => { const builder = new BaggageBuilder(); const pairs: Array<[string, string]> = [ From cb0a79b23462971106a5a8e0841fed09aeefb444 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 15:18:54 -0600 Subject: [PATCH 07/24] chore(a365): format baggage review test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: de36d0f9-7edb-42c8-bbca-75c9812c1c31 --- test/internal/unit/a365/baggageBuilder.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/internal/unit/a365/baggageBuilder.test.ts b/test/internal/unit/a365/baggageBuilder.test.ts index 09968c75..32328fbb 100644 --- a/test/internal/unit/a365/baggageBuilder.test.ts +++ b/test/internal/unit/a365/baggageBuilder.test.ts @@ -126,7 +126,6 @@ describe("BaggageBuilder", () => { it("should preserve the public setPairs parameter compatibility", () => { type SetPairsArg = Parameters[0]; - expectTypeOf().toEqualTypeOf< Record | Iterable<[string, any]> | null | undefined >(); From faa6af7fd2b0106cf9ee46c4a7c618b4ecdf2a14 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 17:26:22 -0600 Subject: [PATCH 08/24] fix(a365): finish baggage review follow-ups Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: de36d0f9-7edb-42c8-bbca-75c9812c1c31 --- .github/workflows/pr-validation.yml | 3 ++ A365_DOCUMENTATION.md | 7 ++-- package.json | 1 + src/a365/middleware/BaggageBuilder.ts | 3 +- .../internal/unit/a365/baggageBuilder.test.ts | 34 +++++++++++++++++-- .../baggageBuilder.public-api.typecheck.ts | 25 ++++++++++++++ tsconfig.baggage-public-api.json | 21 ++++++++++++ 7 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 test/typecheck/baggageBuilder.public-api.typecheck.ts create mode 100644 tsconfig.baggage-public-api.json diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 32a41d56..94c1b8a6 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -24,6 +24,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Focused public API typecheck + run: npm run typecheck:baggage-public-api + - name: Build run: npm run build diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 38d9f0e6..bbc56421 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -49,9 +49,10 @@ invokeScope.run(async () => { invokeScope.dispose(); ``` -Each manual scope accepts `request.sessionId`. When you provide it, the scope writes -`microsoft.session.id` directly on the created span instead of relying on later baggage -enrichment. +`InvokeAgentScope`, `InferenceScope`, and `ExecuteToolScope` accept `request.sessionId`. +When you provide it, those scopes write `microsoft.session.id` directly on the created +span instead of relying on later baggage enrichment. `OutputScope` does not currently +propagate `request.sessionId` directly. ## Baggage And Context diff --git a/package.json b/package.json index f6edea07..f39a11b1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "type": "module", "scripts": { "build": "npm run clean && tsc -p tsconfig.src.json && tsc -p tsconfig.src.cjs.json && npm run fixup-cjs", + "typecheck:baggage-public-api": "tsc -p tsconfig.baggage-public-api.json", "fixup-cjs": "node scripts/fixup-cjs.cjs", "clean": "rimraf dist", "lint": "eslint src test", diff --git a/src/a365/middleware/BaggageBuilder.ts b/src/a365/middleware/BaggageBuilder.ts index b98e6739..83efa55f 100644 --- a/src/a365/middleware/BaggageBuilder.ts +++ b/src/a365/middleware/BaggageBuilder.ts @@ -272,7 +272,8 @@ export class BaggageBuilder { * @param pairs Dictionary or iterable of key-value pairs */ customAttributes( - pairs: Record | Iterable<[string, unknown]> | null | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- preserve source compatibility for interface/class-typed callers + pairs: Record | Iterable<[string, any]> | null | undefined, ): BaggageBuilder { if (!pairs) { return this; diff --git a/test/internal/unit/a365/baggageBuilder.test.ts b/test/internal/unit/a365/baggageBuilder.test.ts index 32328fbb..02d0c09c 100644 --- a/test/internal/unit/a365/baggageBuilder.test.ts +++ b/test/internal/unit/a365/baggageBuilder.test.ts @@ -18,6 +18,14 @@ interface InterfaceTypedBaggagePairs { "gen_ai.agent.id": string; } +interface InterfaceTypedCustomAttributes { + alpha: number; + beta: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- match the source-compatible public API surface +type PublicPairsArg = Record | Iterable<[string, any]> | null | undefined; + function getScopeBaggage(scope: BaggageScope) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return propagation.getBaggage((scope as any).contextWithBaggage); @@ -126,9 +134,7 @@ describe("BaggageBuilder", () => { it("should preserve the public setPairs parameter compatibility", () => { type SetPairsArg = Parameters[0]; - expectTypeOf().toEqualTypeOf< - Record | Iterable<[string, any]> | null | undefined - >(); + expectTypeOf().toEqualTypeOf(); }); it("should accept dictionary of pairs", () => { @@ -203,6 +209,12 @@ describe("BaggageBuilder", () => { }); describe("custom attributes", () => { + it("should preserve the public customAttributes parameter compatibility", () => { + type CustomAttributesArg = Parameters[0]; + + expectTypeOf().toEqualTypeOf(); + }); + it("should normalize and mark a custom attribute", () => { const builder = new BaggageBuilder(); expect(typeof (builder as unknown as { customAttribute?: unknown }).customAttribute).toBe( @@ -246,6 +258,22 @@ describe("BaggageBuilder", () => { expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("alpha,beta"); }); + it("should accept interface-typed object inputs and stringify valid values", () => { + const builder = new BaggageBuilder(); + const pairs: InterfaceTypedCustomAttributes = { + alpha: 1, + beta: " two ", + }; + + const result: BaggageBuilder = builder.customAttributes(pairs); + expect(result).toBe(builder); + + const bag = getScopeBaggage(result.build()); + expect(bag?.getEntry("alpha")?.value).toBe("1"); + expect(bag?.getEntry("beta")?.value).toBe("two"); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("alpha,beta"); + }); + it("should ignore invalid iterable keys and values", () => { const scope = ( new BaggageBuilder() as unknown as { diff --git a/test/typecheck/baggageBuilder.public-api.typecheck.ts b/test/typecheck/baggageBuilder.public-api.typecheck.ts new file mode 100644 index 00000000..094e9ff5 --- /dev/null +++ b/test/typecheck/baggageBuilder.public-api.typecheck.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { expectTypeOf } from "vitest"; +import { BaggageBuilder } from "@microsoft/opentelemetry"; + +interface InterfaceTypedCustomAttributes { + alpha: number; + beta: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- match the source-compatible public API surface +type PublicCustomAttributesArg = Record | Iterable<[string, any]> | null | undefined; + +type CustomAttributesArg = Parameters[0]; + +expectTypeOf().toEqualTypeOf(); + +const builder = new BaggageBuilder(); +const pairs: InterfaceTypedCustomAttributes = { + alpha: 1, + beta: "two", +}; + +expectTypeOf(builder.customAttributes(pairs)).toEqualTypeOf(); diff --git a/tsconfig.baggage-public-api.json b/tsconfig.baggage-public-api.json new file mode 100644 index 00000000..e94529d4 --- /dev/null +++ b/tsconfig.baggage-public-api.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "rootDir": ".", + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@microsoft/opentelemetry": ["./src/index.ts"] + } + }, + "include": ["src/**/*.ts", "test/typecheck/baggageBuilder.public-api.typecheck.ts"], + "exclude": ["node_modules", "dist", "dist-test"] +} From e42df19ab6a881011390aff8a342ba3fae477892 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 17:32:18 -0600 Subject: [PATCH 09/24] docs: link custom baggage 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 5c090b80..6c2ef63e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Features Added -- Add manual `sessionId` propagation to `ExecuteToolScope` and `InferenceScope`, plus opt-in custom baggage enrichment for recognized GenAI spans through `BaggageBuilder.customAttribute()` and `customAttributes()`. +- Add manual `sessionId` propagation to `ExecuteToolScope` and `InferenceScope`, plus opt-in custom baggage enrichment for recognized GenAI spans through `BaggageBuilder.customAttribute()` and `customAttributes()`. [#242](https://github.com/microsoft/opentelemetry-distro-javascript/pull/242) ### Other Changes - Consolidate Dependabot updates for Vitest 4.1.11, Hono 4.13.7, qs 6.16.0, fast-uri 3.1.7, actions/deploy-pages 5.0.1, and actions/checkout 7.0.1. From 90f72305263180106db7d1ab3569ccb0fe989cf2 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 17 Sep 2026 12:14:15 -0600 Subject: [PATCH 10/24] docs(a365): list all enriched GenAI operations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b01797b-8c8b-4ab7-aba9-96a5e7b7a5a0 --- A365_DOCUMENTATION.md | 5 +++-- src/a365/processors/A365SpanProcessor.ts | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index e0309a1c..62214273 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -131,8 +131,9 @@ baggageScope.run(() => { - Custom baggage enrichment is opt-in. Only keys registered through `customAttribute()` or `customAttributes()` are copied from baggage onto spans; plain `setPairs()` entries stay in baggage only. -- Automatic baggage-to-span enrichment only runs for recognized GenAI spans - (`invoke_agent`, `execute_tool`, `chat`, and `output_messages`). +- Automatic baggage-to-span enrichment only runs for recognized GenAI spans whose + `gen_ai.operation.name` is `invoke_agent`, `execute_tool`, `output_messages`, + `apply_guardrail`, `chat`, `Chat`, `TextCompletion`, or `GenerateContent`. - Explicit span attributes win over baggage. If a span already has a value for a registered custom key, the span value is preserved. diff --git a/src/a365/processors/A365SpanProcessor.ts b/src/a365/processors/A365SpanProcessor.ts index 12aa26fc..e472eb94 100644 --- a/src/a365/processors/A365SpanProcessor.ts +++ b/src/a365/processors/A365SpanProcessor.ts @@ -71,8 +71,7 @@ export class A365SpanProcessor implements BaseSpanProcessor { return; } - // Only process GenAI spans — those with a known gen_ai.operation.name - // span attribute (invoke_agent, execute_tool, chat, output_messages). + // Only process spans with an operation registered in GEN_AI_OPERATION_NAMES. // eslint-disable-next-line @typescript-eslint/no-explicit-any const operationNameAttr = (span as any).attributes?.[ OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY From 1a44019672fbb20e92bcdfbd8d0744fbeb87310c Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 17 Sep 2026 12:52:38 -0600 Subject: [PATCH 11/24] test(a365): remove focused baggage API typecheck Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b01797b-8c8b-4ab7-aba9-96a5e7b7a5a0 --- .github/workflows/pr-validation.yml | 3 --- package.json | 1 - .../baggageBuilder.public-api.typecheck.ts | 25 ------------------- tsconfig.baggage-public-api.json | 21 ---------------- 4 files changed, 50 deletions(-) delete mode 100644 test/typecheck/baggageBuilder.public-api.typecheck.ts delete mode 100644 tsconfig.baggage-public-api.json diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 94c1b8a6..32a41d56 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -24,9 +24,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Focused public API typecheck - run: npm run typecheck:baggage-public-api - - name: Build run: npm run build diff --git a/package.json b/package.json index f39a11b1..f6edea07 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "type": "module", "scripts": { "build": "npm run clean && tsc -p tsconfig.src.json && tsc -p tsconfig.src.cjs.json && npm run fixup-cjs", - "typecheck:baggage-public-api": "tsc -p tsconfig.baggage-public-api.json", "fixup-cjs": "node scripts/fixup-cjs.cjs", "clean": "rimraf dist", "lint": "eslint src test", diff --git a/test/typecheck/baggageBuilder.public-api.typecheck.ts b/test/typecheck/baggageBuilder.public-api.typecheck.ts deleted file mode 100644 index 094e9ff5..00000000 --- a/test/typecheck/baggageBuilder.public-api.typecheck.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { expectTypeOf } from "vitest"; -import { BaggageBuilder } from "@microsoft/opentelemetry"; - -interface InterfaceTypedCustomAttributes { - alpha: number; - beta: string; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- match the source-compatible public API surface -type PublicCustomAttributesArg = Record | Iterable<[string, any]> | null | undefined; - -type CustomAttributesArg = Parameters[0]; - -expectTypeOf().toEqualTypeOf(); - -const builder = new BaggageBuilder(); -const pairs: InterfaceTypedCustomAttributes = { - alpha: 1, - beta: "two", -}; - -expectTypeOf(builder.customAttributes(pairs)).toEqualTypeOf(); diff --git a/tsconfig.baggage-public-api.json b/tsconfig.baggage-public-api.json deleted file mode 100644 index e94529d4..00000000 --- a/tsconfig.baggage-public-api.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "rootDir": ".", - "noEmit": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "baseUrl": ".", - "paths": { - "@microsoft/opentelemetry": ["./src/index.ts"] - } - }, - "include": ["src/**/*.ts", "test/typecheck/baggageBuilder.public-api.typecheck.ts"], - "exclude": ["node_modules", "dist", "dist-test"] -} From 097142dbb8bc8d66336c4103ec3867029b394e56 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 11:23:38 -0600 Subject: [PATCH 12/24] docs: design A365 GenAI scope classification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b01797b-8c8b-4ab7-aba9-96a5e7b7a5a0 --- ...-a365-genai-scope-classification-design.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-18-a365-genai-scope-classification-design.md diff --git a/docs/superpowers/specs/2026-09-18-a365-genai-scope-classification-design.md b/docs/superpowers/specs/2026-09-18-a365-genai-scope-classification-design.md new file mode 100644 index 00000000..87399755 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-a365-genai-scope-classification-design.md @@ -0,0 +1,77 @@ +# A365 GenAI Scope Classification Design + +## Goal + +Ensure A365 baggage enrichment reaches spans from supported JavaScript GenAI +instrumentations when `gen_ai.operation.name` is unavailable or provisional at +span start, without classifying unrelated spans as GenAI. + +## Supported instrumentation scopes + +The classifier recognizes exact scope names owned by this distribution: + +- `microsoft-otel-langchain` +- `microsoft-otel-openai-agents` +- The resolved `instrumentationOptions.openaiAgents.tracerName`, when configured + +`Agent365Sdk` does not require scope fallback because manual A365 scopes provide +their operation as an initial span attribute. Scope names are matched exactly; +prefixes and dotted descendants are not accepted implicitly. + +## Classification precedence + +At `A365SpanProcessor.onStart()`: + +1. A recognized explicit `gen_ai.operation.name` classifies the span with that + known operation. +2. An explicit but unrecognized operation remains authoritative over span-name + inference. A supported instrumentation scope may still classify the span as + GenAI with an unknown operation. +3. When no explicit operation exists, an exact recognized operation at the + beginning of the span name, followed by the end of the name or a space, + classifies the span with that operation. +4. Otherwise, an exact supported instrumentation scope classifies the span as + GenAI with an unknown operation. +5. Spans with no recognized operation, name, or scope remain untouched. + +Known `invoke_agent` operations receive generic, invoke-agent-specific, and +registered custom baggage. GenAI spans with an unknown operation receive only +generic and registered custom baggage. + +## Configuration flow + +`A365SpanProcessor` accepts an optional iterable of additional supported scope +names. It always includes the two distribution-owned defaults. Distro +initialization passes the resolved OpenAI Agents `tracerName` from +`config.instrumentationOptions`, which includes defaults applied by existing +configuration resolution. + +No global registry is introduced. Instrumentors remain responsible only for +producing spans, and exporter filtering continues to use final recognized +operation names. + +## Testing + +Unit tests cover: + +- Recognized operations independent of instrumentation scope. +- LangChain spans whose operation is added after `startSpan()`. +- OpenAI Agents spans starting with the provisional `chain` operation. +- Generic/custom enrichment without invoke-agent-only baggage for unknown + operations under supported scopes. +- The configured custom OpenAI tracer name. +- Unrelated scopes, scope prefix collisions, and span-name prefix collisions. +- Explicit unrecognized operations remaining authoritative over span names. + +Functional instrumentation tests assert baggage enrichment on spans emitted by +the LangChain and OpenAI Agents adapters. Source-defined scope names are +deterministic, so running external service samples is unnecessary unless these +tests reveal a mismatch. + +## Non-goals + +- Recognizing Python-specific scope roots such as `agent_framework`, + `semantic_kernel`, or `opentelemetry.instrumentation.openai_v2`. +- Classifying spans from arbitrary descendants of a recognized scope prefix. +- Changing A365 exporter eligibility or accepting operation names from ambient + baggage. From 1c7e711cbd5fa7a2178c7a99b98b7582830f7b44 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 11:26:41 -0600 Subject: [PATCH 13/24] docs: plan A365 GenAI scope classification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b01797b-8c8b-4ab7-aba9-96a5e7b7a5a0 --- ...6-09-18-a365-genai-scope-classification.md | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-18-a365-genai-scope-classification.md diff --git a/docs/superpowers/plans/2026-09-18-a365-genai-scope-classification.md b/docs/superpowers/plans/2026-09-18-a365-genai-scope-classification.md new file mode 100644 index 00000000..c86ad9d2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-a365-genai-scope-classification.md @@ -0,0 +1,439 @@ +# A365 GenAI Scope Classification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enrich supported LangChain and OpenAI Agents spans when their final GenAI operation is unavailable at span start, including configured OpenAI tracer names. + +**Architecture:** `A365SpanProcessor` will classify spans from recognized initial operations, exact span-name boundaries, or an exact supported instrumentation scope. The processor owns the two default JavaScript scope names and accepts additional exact names from distro configuration; exporter filtering remains based on final operation names. + +**Tech Stack:** TypeScript, OpenTelemetry JS SDK, Vitest, npm. + +## Global Constraints + +- Match instrumentation scope names exactly; do not accept prefixes or dotted descendants. +- Recognize `microsoft-otel-langchain`, `microsoft-otel-openai-agents`, and the resolved custom OpenAI Agents `tracerName`. +- Explicit unrecognized operations remain authoritative over span-name inference. +- Unknown operations under supported scopes receive generic and registered custom baggage, but not invoke-agent-only baggage. +- Do not change A365 exporter eligibility or read operation classification from ambient baggage. + +--- + +### Task 1: Add exact-scope span classification + +**Files:** +- Modify: `test/internal/unit/a365/a365SpanProcessor.test.ts` +- Modify: `src/a365/processors/A365SpanProcessor.ts` + +**Interfaces:** +- Consumes: `GEN_AI_OPERATION_NAMES: ReadonlySet`, `Span.instrumentationScope.name` from the SDK span implementation. +- Produces: `new A365SpanProcessor(additionalGenAiInstrumentationScopeNames?: Iterable)`. + +- [ ] **Step 1: Write failing processor tests** + +Add test helpers that allow the tracer scope, span name, and optional initial operation to be selected: + +```ts +function startSpan( + provider: BasicTracerProvider, + { + tracerName = "test", + spanName, + operationName, + baggage = {}, + }: { + tracerName?: string; + spanName: string; + operationName?: string; + baggage?: Record; + }, +) { + const ctx = propagation.setBaggage(context.active(), createBaggage(baggage)); + return provider.getTracer(tracerName).startSpan( + spanName, + { + kind: SpanKind.CLIENT, + ...(operationName + ? { + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: operationName, + }, + } + : {}), + }, + ctx, + ); +} +``` + +Add cases asserting: + +```ts +it.each(["microsoft-otel-langchain", "microsoft-otel-openai-agents"])( + "copies generic and registered custom baggage for supported scope %s without an initial operation", + (tracerName) => { + const span = startSpan(provider, { + tracerName, + spanName: "unmodeled operation", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-1", + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes["custom.one"]).toBe("value-1"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + }, +); +``` + +Also add tests for: + +```ts +// Exact matching only. +tracerName: "microsoft-otel-langchain.child" // untouched + +// Span-name boundary recognition. +spanName: "invoke_agent planner" // invoke-agent baggage copied +spanName: "invoke_agent_toolbox" // untouched + +// Explicit unknown operation blocks span-name inference. +tracerName: "microsoft-otel-langchain" +spanName: "invoke_agent planner" +operationName: "chain" // generic/custom copied; invoke-agent baggage omitted + +// Constructor-provided custom scope. +const customProcessor = new A365SpanProcessor(["custom-openai-scope"]); +``` + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: + +```powershell +$nodeDir = 'C:\Users\nikhilc\AppData\Roaming\nvm\v22.14.0' +$env:PATH = "$nodeDir;$env:PATH" +& "$nodeDir\npx.cmd" vitest run test/internal/unit/a365/a365SpanProcessor.test.ts +``` + +Expected: the new supported-scope and custom-scope cases fail because the processor currently requires a recognized initial operation. + +- [ ] **Step 3: Implement exact-scope classification** + +In `A365SpanProcessor.ts`, add: + +```ts +const DEFAULT_GEN_AI_INSTRUMENTATION_SCOPE_NAMES: readonly string[] = [ + "microsoft-otel-langchain", + "microsoft-otel-openai-agents", +]; + +function getOperationFromSpanName(spanName: unknown): string | undefined { + if (typeof spanName !== "string") { + return undefined; + } + + for (const operationName of GEN_AI_OPERATION_NAMES) { + if (spanName === operationName || spanName.startsWith(`${operationName} `)) { + return operationName; + } + } + + return undefined; +} +``` + +Add an exact-name set: + +```ts +private readonly genAiInstrumentationScopeNames = new Set( + DEFAULT_GEN_AI_INSTRUMENTATION_SCOPE_NAMES, +); + +constructor(additionalGenAiInstrumentationScopeNames: Iterable = []) { + for (const scopeName of additionalGenAiInstrumentationScopeNames) { + const normalizedScopeName = scopeName.trim(); + if (normalizedScopeName) { + this.genAiInstrumentationScopeNames.add(normalizedScopeName); + } + } +} +``` + +Replace the current operation-only gate with: + +```ts +const spanRecord = span as Span & { + attributes?: Record; + name?: string; + instrumentationScope?: { name?: string }; +}; +const explicitOperation = spanRecord.attributes?.[ + OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY +]; +const recognizedExplicitOperation = + typeof explicitOperation === "string" && GEN_AI_OPERATION_NAMES.has(explicitOperation) + ? explicitOperation + : undefined; +const inferredOperation = + explicitOperation === undefined ? getOperationFromSpanName(spanRecord.name) : undefined; +const operationName = recognizedExplicitOperation ?? inferredOperation; +const supportedScope = + typeof spanRecord.instrumentationScope?.name === "string" && + this.genAiInstrumentationScopeNames.has(spanRecord.instrumentationScope.name); + +if (!operationName && !supportedScope) { + return; +} +``` + +Use only the classified operation for invoke-agent baggage: + +```ts +const isInvokeAgent = + operationName === OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME; +``` + +- [ ] **Step 4: Run focused tests and verify success** + +Run: + +```powershell +& "$nodeDir\npx.cmd" vitest run test/internal/unit/a365/a365SpanProcessor.test.ts +``` + +Expected: all `A365SpanProcessor` tests pass, including exact-scope and precedence cases. + +- [ ] **Step 5: Commit processor classification** + +```powershell +git add src/a365/processors/A365SpanProcessor.ts test/internal/unit/a365/a365SpanProcessor.test.ts +git commit -m "fix(a365): classify supported GenAI instrumentation scopes" +``` + +--- + +### Task 2: Wire configured OpenAI tracer names + +**Files:** +- Modify: `test/internal/unit/main.test.ts:1185-1212` +- Modify: `src/distro/distro.ts:321-328` + +**Interfaces:** +- Consumes: `A365SpanProcessor(additionalGenAiInstrumentationScopeNames?: Iterable)`. +- Produces: distro registration that passes `config.instrumentationOptions.openaiAgents?.tracerName`. + +- [ ] **Step 1: Write a failing distro configuration test** + +Add a test near the existing A365 processor registration case: + +```ts +it("passes a configured OpenAI tracer name to A365SpanProcessor", async () => { + useMicrosoftOpenTelemetry({ + azureMonitor: { enabled: false }, + enableConsoleExporters: false, + a365: { + enabled: true, + tokenResolver: () => "token", + }, + instrumentationOptions: { + openaiAgents: { + enabled: false, + tracerName: "custom-openai-scope", + }, + langchain: { enabled: false }, + }, + }); + + const internalSdk = _getSdkInstance(); + const tracerProvider = (internalSdk as any)["_tracerProvider"]; + const registeredProcessors = + tracerProvider?.["_activeSpanProcessor"]?.["_spanProcessors"] || []; + const processor = registeredProcessors.find( + (candidate: any) => candidate.constructor?.name === "A365SpanProcessor", + ); + + assert.isDefined(processor); + assert.isTrue(processor["genAiInstrumentationScopeNames"].has("custom-openai-scope")); + + await shutdownMicrosoftOpenTelemetry(); +}); +``` + +- [ ] **Step 2: Run the test and verify failure** + +Run: + +```powershell +& "$nodeDir\npx.cmd" vitest run --config vitest.unit.config.ts test/internal/unit/main.test.ts +``` + +Expected: the new assertion fails because distro initialization currently constructs `A365SpanProcessor` without the configured tracer name. + +- [ ] **Step 3: Pass the resolved custom scope** + +Replace the registration in `src/distro/distro.ts` with: + +```ts +const configuredOpenAiTracerName = + config.instrumentationOptions.openaiAgents?.tracerName; +spanProcessors.push( + new A365SpanProcessor( + configuredOpenAiTracerName ? [configuredOpenAiTracerName] : [], + ), +); +``` + +- [ ] **Step 4: Run the focused unit test** + +Run: + +```powershell +& "$nodeDir\npx.cmd" vitest run --config vitest.unit.config.ts test/internal/unit/main.test.ts +``` + +Expected: all main unit tests pass. + +- [ ] **Step 5: Commit configuration wiring** + +```powershell +git add src/distro/distro.ts test/internal/unit/main.test.ts +git commit -m "fix(a365): register configured OpenAI tracer scope" +``` + +--- + +### Task 3: Verify real producer lifecycle and documentation + +**Files:** +- Modify: `test/internal/functional/genai-distro.test.ts` +- Modify: `test/internal/functional/genai-openai-distro.test.ts` +- Modify: `A365_DOCUMENTATION.md:131-140` + +**Interfaces:** +- Consumes: scope-aware `A365SpanProcessor` registered through `useMicrosoftOpenTelemetry`. +- Produces: functional regression coverage for actual LangChain and OpenAI Agents adapters. + +- [ ] **Step 1: Add failing LangChain enrichment coverage** + +Update the existing functional initialization to enable A365 and run the adapter under baggage: + +```ts +const baggage = propagation + .createBaggage() + .setEntry(OpenTelemetryConstants.TENANT_ID_KEY, { value: "tenant-123" }) + .setEntry("_internal.custom_keys", { value: "custom.scope" }) + .setEntry("custom.scope", { value: "langchain" }); +const ctx = propagation.setBaggage(context.active(), baggage); + +await context.with(ctx, async () => { + await langChainTracer.onRunCreate(run); + await langChainTracer._endTrace(run); +}); +``` + +Configure: + +```ts +a365: { + enabled: true, + tokenResolver: () => "token", +}, +``` + +Assert the finished LangChain span contains: + +```ts +expect(chatSpan?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); +expect(chatSpan?.attributes["custom.scope"]).toBe("langchain"); +``` + +- [ ] **Step 2: Add custom OpenAI tracer enrichment coverage** + +Configure the OpenAI functional test with: + +```ts +a365: { + enabled: true, + tokenResolver: () => "token", +}, +instrumentationOptions: { + openaiAgents: { + enabled: true, + tracerName: "custom-openai-scope", + isContentRecordingEnabled: true, + }, + langchain: { enabled: false }, +}, +``` + +Run the existing OpenAI generation under baggage registered with +`_internal.custom_keys`, then assert: + +```ts +expect(chatSpan?.instrumentationScope.name).toBe("custom-openai-scope"); +expect(chatSpan?.attributes["custom.scope"]).toBe("openai"); +``` + +Add a processor-level MCP lifecycle case in +`test/internal/unit/a365/a365SpanProcessor.test.ts` using the supported OpenAI +scope, initial operation `chain`, and a later `span.setAttribute()` to +`execute_tool`. Assert generic/custom baggage was applied at start and +invoke-agent-only baggage was omitted. + +- [ ] **Step 3: Run functional and processor tests** + +Run: + +```powershell +& "$nodeDir\npx.cmd" vitest run --config vitest.functional.config.ts test/internal/functional/genai-distro.test.ts test/internal/functional/genai-openai-distro.test.ts +& "$nodeDir\npx.cmd" vitest run test/internal/unit/a365/a365SpanProcessor.test.ts +``` + +Expected: all selected tests pass. The source-defined scope identities and emitted functional spans agree, so no external service sample run is required. + +- [ ] **Step 4: Document scope fallback** + +Add to `A365_DOCUMENTATION.md`: + +```md +For the built-in LangChain and OpenAI Agents instrumentations, enrichment also +recognizes their exact instrumentation scope names when the final GenAI +operation is not available at span start. A configured OpenAI Agents +`tracerName` is registered as an exact supported scope. Scope prefixes and +unrelated child scopes are not matched. +``` + +- [ ] **Step 5: Run complete verification** + +Run: + +```powershell +& "$nodeDir\npm.cmd" run format +& "$nodeDir\npm.cmd" run lint +& "$nodeDir\npm.cmd" run build +& "$nodeDir\npm.cmd" test +git diff --check +``` + +Expected: formatting, lint, build, and all tests succeed with no merge markers or whitespace errors. + +- [ ] **Step 6: Commit functional coverage and documentation** + +```powershell +git add test/internal/functional/genai-distro.test.ts test/internal/functional/genai-openai-distro.test.ts test/internal/unit/a365/a365SpanProcessor.test.ts A365_DOCUMENTATION.md +git commit -m "test(a365): cover GenAI scope fallback lifecycle" +``` + +- [ ] **Step 7: Push and verify the PR** + +```powershell +git push origin feature/a365-custom-baggage +gh pr view 242 --repo microsoft/opentelemetry-distro-javascript --json headRefOid,mergeable,mergeStateStatus,statusCheckRollup +``` + +Expected: the remote head matches local `HEAD`, the PR is mergeable, and CI starts for the pushed commits. From fc7a8f72768d8882a5c6e2c71bd5cd6bb72b9e74 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 11:38:22 -0600 Subject: [PATCH 14/24] fix(a365): classify supported GenAI instrumentation scopes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b01797b-8c8b-4ab7-aba9-96a5e7b7a5a0 --- src/a365/processors/A365SpanProcessor.ts | 73 ++++++--- .../unit/a365/a365SpanProcessor.test.ts | 153 +++++++++++++++++- 2 files changed, 204 insertions(+), 22 deletions(-) diff --git a/src/a365/processors/A365SpanProcessor.ts b/src/a365/processors/A365SpanProcessor.ts index e472eb94..75e05e0c 100644 --- a/src/a365/processors/A365SpanProcessor.ts +++ b/src/a365/processors/A365SpanProcessor.ts @@ -21,6 +21,25 @@ import { INTERNAL_CUSTOM_KEYS_METADATA_KEY, OpenTelemetryConstants } from "../co import { GEN_AI_OPERATION_NAMES } from "../exporter/utils.js"; import { GENERIC_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES } from "./util.js"; +const DEFAULT_GEN_AI_INSTRUMENTATION_SCOPE_NAMES: readonly string[] = [ + "microsoft-otel-langchain", + "microsoft-otel-openai-agents", +]; + +function getOperationFromSpanName(spanName: unknown): string | undefined { + if (typeof spanName !== "string") { + return undefined; + } + + for (const operationName of GEN_AI_OPERATION_NAMES) { + if (spanName === operationName || spanName.startsWith(`${operationName} `)) { + return operationName; + } + } + + return undefined; +} + function getRegisteredCustomKeys(value: string | undefined): string[] { if (!value) { return []; @@ -40,6 +59,19 @@ function getRegisteredCustomKeys(value: string | undefined): string[] { * without explicitly creating scopes. */ export class A365SpanProcessor implements BaseSpanProcessor { + private readonly genAiInstrumentationScopeNames = new Set( + DEFAULT_GEN_AI_INSTRUMENTATION_SCOPE_NAMES, + ); + + constructor(additionalGenAiInstrumentationScopeNames: Iterable = []) { + for (const scopeName of additionalGenAiInstrumentationScopeNames) { + const normalizedScopeName = scopeName.trim(); + if (normalizedScopeName) { + this.genAiInstrumentationScopeNames.add(normalizedScopeName); + } + } + } + /** * Called when a span is started. * Copies relevant baggage entries to span attributes. @@ -53,16 +85,16 @@ export class A365SpanProcessor implements BaseSpanProcessor { return; } + const spanRecord = span as Span & { + attributes?: Record; + name?: string; + instrumentationScope?: { name?: string }; + }; + // Get existing span attributes const existingAttrs = new Set(); - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const spanRecord = span as any; - if (spanRecord.attributes) { - Object.keys(spanRecord.attributes).forEach((key) => existingAttrs.add(key)); - } - } catch { - // Ignore errors accessing span attributes + if (spanRecord.attributes) { + Object.keys(spanRecord.attributes).forEach((key) => existingAttrs.add(key)); } // Get all baggage entries @@ -71,12 +103,19 @@ export class A365SpanProcessor implements BaseSpanProcessor { return; } - // Only process spans with an operation registered in GEN_AI_OPERATION_NAMES. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const operationNameAttr = (span as any).attributes?.[ - OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY - ]; - if (!GEN_AI_OPERATION_NAMES.has(operationNameAttr)) { + const explicitOperation = spanRecord.attributes?.[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]; + const recognizedExplicitOperation = + typeof explicitOperation === "string" && GEN_AI_OPERATION_NAMES.has(explicitOperation) + ? explicitOperation + : undefined; + const inferredOperation = + explicitOperation === undefined ? getOperationFromSpanName(spanRecord.name) : undefined; + const operationName = recognizedExplicitOperation ?? inferredOperation; + const supportedScope = + typeof spanRecord.instrumentationScope?.name === "string" && + this.genAiInstrumentationScopeNames.has(spanRecord.instrumentationScope.name); + + if (!operationName && !supportedScope) { return; } @@ -88,11 +127,7 @@ export class A365SpanProcessor implements BaseSpanProcessor { }); // Determine if this is an invoke_agent operation - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const spanName = (span as any).name || ""; - const isInvokeAgent = - operationNameAttr === OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME || - spanName.startsWith(OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME); + const isInvokeAgent = operationName === OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME; // Build target key set const targetKeys = new Set(GENERIC_ATTRIBUTES); diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts index e24912a9..4fe5af8d 100644 --- a/test/internal/unit/a365/a365SpanProcessor.test.ts +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -29,6 +29,37 @@ function createBaggage(entries: Record) { return baggage; } +function startSpan( + provider: BasicTracerProvider, + { + tracerName = "test", + spanName, + operationName, + baggage = {}, + }: { + tracerName?: string; + spanName: string; + operationName?: string; + baggage?: Record; + }, +) { + const ctx = propagation.setBaggage(context.active(), createBaggage(baggage)); + return provider.getTracer(tracerName).startSpan( + spanName, + { + kind: SpanKind.CLIENT, + ...(operationName + ? { + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: operationName, + }, + } + : {}), + }, + ctx, + ); +} + /** * Helper: starts a GenAI span with `gen_ai.operation.name` as a span attribute * and the given baggage entries in context. @@ -40,8 +71,6 @@ function startGenAiSpan( spanName?: string, attributes: Record = {}, ) { - const bag = createBaggage(baggage); - const ctx = propagation.setBaggage(context.active(), bag); const tracer = provider.getTracer("test"); return tracer.startSpan( spanName ?? `${operationName} span`, @@ -52,7 +81,7 @@ function startGenAiSpan( ...attributes, }, }, - ctx, + propagation.setBaggage(context.active(), createBaggage(baggage)), ); } @@ -74,6 +103,124 @@ describe("A365SpanProcessor", () => { }); describe("GenAI span filtering", () => { + it.each(["microsoft-otel-langchain", "microsoft-otel-openai-agents"])( + "copies generic and registered custom baggage for supported scope %s without an initial operation", + (tracerName) => { + const span = startSpan(provider, { + tracerName, + spanName: "unmodeled operation", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-1", + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes["custom.one"]).toBe("value-1"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + }, + ); + + it("keeps exact tracer scope matching only", () => { + const span = startSpan(provider, { + tracerName: "microsoft-otel-langchain.child", + spanName: "unmodeled operation", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-1", + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); + expect(attributes["custom.one"]).toBeUndefined(); + }); + + it("recognizes span-name boundaries for invoke-agent operations only", () => { + const copied = startSpan(provider, { + tracerName: "microsoft-otel-langchain", + spanName: "invoke_agent planner", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + }, + }); + copied.end(); + + const copiedAttrs = memoryExporter.getFinishedSpans()[0].attributes; + expect(copiedAttrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(copiedAttrs[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBe( + "caller-123", + ); + + memoryExporter.reset(); + + const untouched = startSpan(provider, { + tracerName: "microsoft-otel-langchain", + spanName: "invoke_agent_toolbox", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-456", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-456", + }, + }); + untouched.end(); + + const untouchedAttrs = memoryExporter.getFinishedSpans()[0].attributes; + expect(untouchedAttrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); + expect(untouchedAttrs[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + }); + + it("prefers explicit unknown operations over span-name inference", () => { + const span = startSpan(provider, { + tracerName: "microsoft-otel-langchain", + spanName: "invoke_agent planner", + operationName: "chain", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-1", + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes["custom.one"]).toBe("value-1"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + }); + + it("supports constructor-provided custom scopes", async () => { + const customProcessor = new A365SpanProcessor(["custom-openai-scope"]); + const customMemoryExporter = new InMemorySpanExporter(); + const customProvider = new BasicTracerProvider({ + spanProcessors: [customProcessor, new SimpleSpanProcessor(customMemoryExporter)], + }); + + const span = startSpan(customProvider, { + tracerName: "custom-openai-scope", + spanName: "unmodeled operation", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-1", + }, + }); + span.end(); + + const attributes = customMemoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes["custom.one"]).toBe("value-1"); + + await customProvider.shutdown(); + }); + it("should not mutate spans without gen_ai.operation.name", () => { const baggageEntries = { [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", From 6ab2f6e6a15b1c30b6db2c424d0be13370090ada Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 12:01:28 -0600 Subject: [PATCH 15/24] fix(a365): block ambient operation baggage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b01797b-8c8b-4ab7-aba9-96a5e7b7a5a0 --- src/a365/processors/A365SpanProcessor.ts | 1 + .../unit/a365/a365SpanProcessor.test.ts | 46 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/a365/processors/A365SpanProcessor.ts b/src/a365/processors/A365SpanProcessor.ts index 75e05e0c..1f6e2e9f 100644 --- a/src/a365/processors/A365SpanProcessor.ts +++ b/src/a365/processors/A365SpanProcessor.ts @@ -137,6 +137,7 @@ export class A365SpanProcessor implements BaseSpanProcessor { getRegisteredCustomKeys(baggageMap.get(INTERNAL_CUSTOM_KEYS_METADATA_KEY)).forEach((key) => targetKeys.add(key), ); + targetKeys.delete(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY); // Set telemetry SDK attributes if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY)) { diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts index 4fe5af8d..d99a18b9 100644 --- a/test/internal/unit/a365/a365SpanProcessor.test.ts +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -125,6 +125,30 @@ describe("A365SpanProcessor", () => { }, ); + it.each(["microsoft-otel-langchain", "microsoft-otel-openai-agents"])( + "does not copy ambient gen_ai.operation.name baggage for supported scope %s without an initial operation", + (tracerName) => { + const span = startSpan(provider, { + tracerName, + spanName: "unmodeled operation", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: "chat", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-1", + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes["custom.one"]).toBe("value-1"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + expect(attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBeUndefined(); + }, + ); + it("keeps exact tracer scope matching only", () => { const span = startSpan(provider, { tracerName: "microsoft-otel-langchain.child", @@ -144,7 +168,7 @@ describe("A365SpanProcessor", () => { it("recognizes span-name boundaries for invoke-agent operations only", () => { const copied = startSpan(provider, { - tracerName: "microsoft-otel-langchain", + tracerName: "test", spanName: "invoke_agent planner", baggage: { [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", @@ -162,7 +186,7 @@ describe("A365SpanProcessor", () => { memoryExporter.reset(); const untouched = startSpan(provider, { - tracerName: "microsoft-otel-langchain", + tracerName: "test", spanName: "invoke_agent_toolbox", baggage: { [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-456", @@ -176,6 +200,24 @@ describe("A365SpanProcessor", () => { expect(untouchedAttrs[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); }); + it("does not copy ambient gen_ai.operation.name baggage for span-name inferred invoke_agent spans", () => { + const span = startSpan(provider, { + tracerName: "microsoft-otel-langchain", + spanName: "invoke_agent planner", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: "chat", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBe("caller-123"); + expect(attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBeUndefined(); + }); + it("prefers explicit unknown operations over span-name inference", () => { const span = startSpan(provider, { tracerName: "microsoft-otel-langchain", From c48fd0859f56ed72b98e87404c12fb08806ba4b5 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 12:25:18 -0600 Subject: [PATCH 16/24] fix(a365): register configured OpenAI tracer scope Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b01797b-8c8b-4ab7-aba9-96a5e7b7a5a0 --- src/distro/distro.ts | 6 +++++- test/internal/unit/main.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/distro/distro.ts b/src/distro/distro.ts index ceb7cb19..5e13b189 100644 --- a/src/distro/distro.ts +++ b/src/distro/distro.ts @@ -324,7 +324,11 @@ export function useMicrosoftOpenTelemetry(options?: MicrosoftOpenTelemetryOption // telemetry.sdk.* attributes to span attributes. Always registered when // A365 is enabled, even if the HTTP exporter is suppressed, so downstream // exporters (Azure Monitor, OTLP, …) still receive the enriched spans. - spanProcessors.push(new A365SpanProcessor()); + const configuredOpenAiTracerName = + config.instrumentationOptions?.openaiAgents?.tracerName; + spanProcessors.push( + new A365SpanProcessor(configuredOpenAiTracerName ? [configuredOpenAiTracerName] : []), + ); if (a365Config.enableObservabilityExporter) { const a365Exporter = new Agent365Exporter({ clusterCategory: a365Config.clusterCategory, diff --git a/test/internal/unit/main.test.ts b/test/internal/unit/main.test.ts index 00be56d2..ca54ace7 100644 --- a/test/internal/unit/main.test.ts +++ b/test/internal/unit/main.test.ts @@ -1211,6 +1211,39 @@ describe("Main functions", () => { await shutdownMicrosoftOpenTelemetry(); }); + it("passes a configured OpenAI tracer name to A365SpanProcessor", async () => { + useMicrosoftOpenTelemetry({ + azureMonitor: { enabled: false }, + enableConsoleExporters: false, + a365: { + enabled: true, + tokenResolver: () => "token", + }, + instrumentationOptions: { + openaiAgents: { + enabled: false, + tracerName: "custom-openai-scope", + }, + langchain: { enabled: false }, + }, + }); + + const internalSdk = _getSdkInstance(); + const tracerProvider = (internalSdk as any)["_tracerProvider"]; + const registeredProcessors = + tracerProvider?.["_activeSpanProcessor"]?.["_spanProcessors"] || []; + const processor = registeredProcessors.find( + (candidate: any) => candidate.constructor?.name === "A365SpanProcessor", + ); + + assert.isDefined(processor); + assert.isTrue( + processor["genAiInstrumentationScopeNames"].has("custom-openai-scope"), + ); + + await shutdownMicrosoftOpenTelemetry(); + }); + it("registers A365SpanProcessor but not Agent365Exporter when a365.enableObservabilityExporter is false (default)", async () => { useMicrosoftOpenTelemetry({ azureMonitor: { enabled: false }, From 5e991658c70e7da61550e943edd528290a56ae93 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 12:41:15 -0600 Subject: [PATCH 17/24] test(a365): cover GenAI scope fallback lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b01797b-8c8b-4ab7-aba9-96a5e7b7a5a0 --- A365_DOCUMENTATION.md | 68 ++++++++++--------- src/a365/processors/A365SpanProcessor.ts | 3 +- src/distro/distro.ts | 3 +- test/internal/functional/genai-distro.test.ts | 24 ++++++- .../functional/genai-openai-distro.test.ts | 47 +++++++++---- .../unit/a365/a365SpanProcessor.test.ts | 31 ++++++++- test/internal/unit/main.test.ts | 4 +- 7 files changed, 121 insertions(+), 59 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 62214273..42df9e88 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -29,9 +29,7 @@ const invokeScope = InvokeAgentScope.start( requestParameters: { model: "gpt-4o", outputType: "json", - systemInstructions: [ - { type: "text", content: "You are a helpful assistant." }, - ], + systemInstructions: [{ type: "text", content: "You are a helpful assistant." }], }, }, { agentId: "agent-1", tenantId: "tenant-1", providerName: "openai" }, @@ -72,26 +70,26 @@ propagate `request.sessionId` directly. `InvokeAgentScope.start()` captures request parameters immediately, while `recordResponseParameters()` captures response and usage values after the agent completes. -| Input field | Emitted attribute key | -| --- | --- | -| `requestParameters.model` | `gen_ai.request.model` | -| `requestParameters.seed` | `gen_ai.request.seed` | -| `requestParameters.choiceCount` | `gen_ai.request.choice.count` | -| `requestParameters.frequencyPenalty` | `gen_ai.request.frequency_penalty` | -| `requestParameters.maxTokens` | `gen_ai.request.max_tokens` | -| `requestParameters.presencePenalty` | `gen_ai.request.presence_penalty` | -| `requestParameters.stopSequences` | `gen_ai.request.stop_sequences` | -| `requestParameters.temperature` | `gen_ai.request.temperature` | -| `requestParameters.topP` | `gen_ai.request.top_p` | -| `requestParameters.dataSourceId` | `gen_ai.data_source.id` | -| `requestParameters.outputType` | `gen_ai.output.type` | -| `requestParameters.systemInstructions` | `gen_ai.system_instructions` (JSON-serialized parts array) | -| `responseParameters.finishReasons` | `gen_ai.response.finish_reasons` | -| `responseParameters.inputTokens` | `gen_ai.usage.input_tokens` | -| `responseParameters.outputTokens` | `gen_ai.usage.output_tokens` | -| `responseParameters.cacheWriteInputTokens` | `gen_ai.usage.cache_write.input_tokens` | -| `responseParameters.cacheReadInputTokens` | `gen_ai.usage.cache_read.input_tokens` | -| `agentDetails.providerName` | `gen_ai.provider.name` | +| Input field | Emitted attribute key | +| ------------------------------------------ | ---------------------------------------------------------- | +| `requestParameters.model` | `gen_ai.request.model` | +| `requestParameters.seed` | `gen_ai.request.seed` | +| `requestParameters.choiceCount` | `gen_ai.request.choice.count` | +| `requestParameters.frequencyPenalty` | `gen_ai.request.frequency_penalty` | +| `requestParameters.maxTokens` | `gen_ai.request.max_tokens` | +| `requestParameters.presencePenalty` | `gen_ai.request.presence_penalty` | +| `requestParameters.stopSequences` | `gen_ai.request.stop_sequences` | +| `requestParameters.temperature` | `gen_ai.request.temperature` | +| `requestParameters.topP` | `gen_ai.request.top_p` | +| `requestParameters.dataSourceId` | `gen_ai.data_source.id` | +| `requestParameters.outputType` | `gen_ai.output.type` | +| `requestParameters.systemInstructions` | `gen_ai.system_instructions` (JSON-serialized parts array) | +| `responseParameters.finishReasons` | `gen_ai.response.finish_reasons` | +| `responseParameters.inputTokens` | `gen_ai.usage.input_tokens` | +| `responseParameters.outputTokens` | `gen_ai.usage.output_tokens` | +| `responseParameters.cacheWriteInputTokens` | `gen_ai.usage.cache_write.input_tokens` | +| `responseParameters.cacheReadInputTokens` | `gen_ai.usage.cache_read.input_tokens` | +| `agentDetails.providerName` | `gen_ai.provider.name` | System instructions may contain sensitive content. Only capture them when you intend to store prompt text and have reviewed downstream access controls. @@ -134,6 +132,10 @@ baggageScope.run(() => { - Automatic baggage-to-span enrichment only runs for recognized GenAI spans whose `gen_ai.operation.name` is `invoke_agent`, `execute_tool`, `output_messages`, `apply_guardrail`, `chat`, `Chat`, `TextCompletion`, or `GenerateContent`. +- For the built-in LangChain and OpenAI Agents instrumentations, enrichment also recognizes + their exact instrumentation scope names when the final GenAI operation is not available at + span start. A configured OpenAI Agents `tracerName` is registered as an exact supported + scope. Scope prefixes and unrelated child scopes are not matched. - Explicit span attributes win over baggage. If a span already has a value for a registered custom key, the span value is preserved. @@ -205,17 +207,17 @@ network-only delivery. It applies only to the A365 HTTP exporter, so set ### Durable Delivery Defaults -| Option | Default | Notes | -| ------------------------------------ | ------------------------- | --------------------------------------------------------------------------------- | -| `enabled` | `true` | Durable delivery stays on unless you explicitly disable it | +| Option | Default | Notes | +| ------------------------------------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `true` | Durable delivery stays on unless you explicitly disable it | | `storageDirectory` | auto | Uses the configured directory, or creates a secure platform-specific default root plus a stable per-application `app-` partition | -| `maxStorageBytes` | `50 * 1024 * 1024` | Bounds pending, quarantined, active leased, and non-stale temporary records within the current `app-` partition only | -| `maxRecordAgeMilliseconds` | `2 * 24 * 60 * 60 * 1000` | Expired records are pruned before capacity eviction, within the current `app-` partition only | -| `replayIntervalMilliseconds` | `2 * 60 * 1000` | Scheduled replay cadence | -| `maxReplayBatchSize` | `10` | Maximum records claimed per replay pass | -| `leaseDurationMilliseconds` | `2 * 60 * 1000` | Reclaims stale replay leases | -| `shutdownTimeoutMilliseconds` | `10_000` | Shared shutdown budget for accepted live exports and admitted durable handoff completion | -| `tokenResolutionTimeoutMilliseconds` | `30_000` | Timeout per replay token-resolution attempt | +| `maxStorageBytes` | `50 * 1024 * 1024` | Bounds pending, quarantined, active leased, and non-stale temporary records within the current `app-` partition only | +| `maxRecordAgeMilliseconds` | `2 * 24 * 60 * 60 * 1000` | Expired records are pruned before capacity eviction, within the current `app-` partition only | +| `replayIntervalMilliseconds` | `2 * 60 * 1000` | Scheduled replay cadence | +| `maxReplayBatchSize` | `10` | Maximum records claimed per replay pass | +| `leaseDurationMilliseconds` | `2 * 60 * 1000` | Reclaims stale replay leases | +| `shutdownTimeoutMilliseconds` | `10_000` | Shared shutdown budget for accepted live exports and admitted durable handoff completion | +| `tokenResolutionTimeoutMilliseconds` | `30_000` | Timeout per replay token-resolution attempt | ### Operational Notes diff --git a/src/a365/processors/A365SpanProcessor.ts b/src/a365/processors/A365SpanProcessor.ts index 1f6e2e9f..35b49a9f 100644 --- a/src/a365/processors/A365SpanProcessor.ts +++ b/src/a365/processors/A365SpanProcessor.ts @@ -103,7 +103,8 @@ export class A365SpanProcessor implements BaseSpanProcessor { return; } - const explicitOperation = spanRecord.attributes?.[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]; + const explicitOperation = + spanRecord.attributes?.[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]; const recognizedExplicitOperation = typeof explicitOperation === "string" && GEN_AI_OPERATION_NAMES.has(explicitOperation) ? explicitOperation diff --git a/src/distro/distro.ts b/src/distro/distro.ts index 5e13b189..cebf6d5e 100644 --- a/src/distro/distro.ts +++ b/src/distro/distro.ts @@ -324,8 +324,7 @@ export function useMicrosoftOpenTelemetry(options?: MicrosoftOpenTelemetryOption // telemetry.sdk.* attributes to span attributes. Always registered when // A365 is enabled, even if the HTTP exporter is suppressed, so downstream // exporters (Azure Monitor, OTLP, …) still receive the enriched spans. - const configuredOpenAiTracerName = - config.instrumentationOptions?.openaiAgents?.tracerName; + const configuredOpenAiTracerName = config.instrumentationOptions?.openaiAgents?.tracerName; spanProcessors.push( new A365SpanProcessor(configuredOpenAiTracerName ? [configuredOpenAiTracerName] : []), ); diff --git a/test/internal/functional/genai-distro.test.ts b/test/internal/functional/genai-distro.test.ts index 1dd3fbcb..93b3387d 100644 --- a/test/internal/functional/genai-distro.test.ts +++ b/test/internal/functional/genai-distro.test.ts @@ -9,7 +9,12 @@ import { BasicTracerProvider, } from "@opentelemetry/sdk-trace-base"; import { trace, type ProxyTracerProvider } from "@opentelemetry/api"; -import { useMicrosoftOpenTelemetry, shutdownMicrosoftOpenTelemetry } from "../../../src/index.js"; +import { + BaggageBuilder, + OpenTelemetryConstants, + useMicrosoftOpenTelemetry, + shutdownMicrosoftOpenTelemetry, +} from "../../../src/index.js"; import { LangChainTraceInstrumentor } from "../../../src/genai/instrumentations/langchain/langchainTraceInstrumentor.js"; import { ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_CHAT } from "../../../src/genai/index.js"; @@ -74,6 +79,10 @@ describe("GenAI distro integration", () => { useMicrosoftOpenTelemetry({ tracesPerSecond: 0, samplingRatio: 1, + a365: { + enabled: true, + tokenResolver: () => "token", + }, azureMonitor: { enabled: false }, enableConsoleExporters: false, spanProcessors: [new SimpleSpanProcessor(exporter)], @@ -97,8 +106,15 @@ describe("GenAI distro integration", () => { ) as any; const run = makeLangChainRun(); - await langChainTracer.onRunCreate(run); - await langChainTracer._endTrace(run); + const baggageScope = new BaggageBuilder() + .tenantId("tenant-123") + .customAttribute("custom.scope", "langchain") + .build(); + + await baggageScope.run(async () => { + await langChainTracer.onRunCreate(run); + await langChainTracer._endTrace(run); + }); await flushGlobalTracerProvider(); const spans = exporter.getFinishedSpans(); @@ -108,5 +124,7 @@ describe("GenAI distro integration", () => { ); expect(chatSpan).toBeDefined(); expect(chatSpan?.instrumentationScope.name).toBe("microsoft-otel-langchain"); + expect(chatSpan?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(chatSpan?.attributes["custom.scope"]).toBe("langchain"); }); }); diff --git a/test/internal/functional/genai-openai-distro.test.ts b/test/internal/functional/genai-openai-distro.test.ts index 32a491f4..98499cd3 100644 --- a/test/internal/functional/genai-openai-distro.test.ts +++ b/test/internal/functional/genai-openai-distro.test.ts @@ -9,7 +9,12 @@ import { } from "@opentelemetry/sdk-trace-base"; import { trace, type ProxyTracerProvider } from "@opentelemetry/api"; import * as OpenAIAgents from "@openai/agents"; -import { useMicrosoftOpenTelemetry, shutdownMicrosoftOpenTelemetry } from "../../../src/index.js"; +import { + BaggageBuilder, + OpenTelemetryConstants, + useMicrosoftOpenTelemetry, + shutdownMicrosoftOpenTelemetry, +} from "../../../src/index.js"; import { OpenAIAgentsTraceInstrumentor } from "../../../src/genai/instrumentations/openai/openAIAgentsTraceInstrumentor.js"; import { ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_CHAT } from "../../../src/genai/index.js"; @@ -39,11 +44,19 @@ describe("OpenAI Agents distro integration", () => { useMicrosoftOpenTelemetry({ tracesPerSecond: 0, samplingRatio: 1, + a365: { + enabled: true, + tokenResolver: () => "token", + }, azureMonitor: { enabled: false }, enableConsoleExporters: false, spanProcessors: [new SimpleSpanProcessor(exporter)], instrumentationOptions: { - openaiAgents: { enabled: true, isContentRecordingEnabled: true }, + openaiAgents: { + enabled: true, + tracerName: "custom-openai-scope", + isContentRecordingEnabled: true, + }, langchain: { enabled: false }, }, }); @@ -57,10 +70,14 @@ describe("OpenAI Agents distro integration", () => { expect(OpenAIAgents.getCurrentTrace()).toBeNull(); }); - await vi.waitFor(async () => { - exporter.reset(); - OpenAIAgentsTraceInstrumentor.enable(); + exporter.reset(); + OpenAIAgentsTraceInstrumentor.enable(); + const baggageScope = new BaggageBuilder() + .tenantId("tenant-123") + .customAttribute("custom.scope", "openai") + .build(); + await baggageScope.run(async () => { await OpenAIAgents.withTrace("genai-openai-integration", async () => { await OpenAIAgents.withGenerationSpan( async () => { @@ -76,15 +93,17 @@ describe("OpenAI Agents distro integration", () => { } as any, ); }); - - await flushGlobalTracerProvider(); - const spans = exporter.getFinishedSpans(); - expect(spans.length).toBeGreaterThan(0); - const chatSpan = spans.find( - (s) => s.attributes[ATTR_GEN_AI_OPERATION_NAME] === GEN_AI_OPERATION_CHAT, - ); - expect(chatSpan).toBeDefined(); - expect(chatSpan?.instrumentationScope.name).toBe("microsoft-otel-openai-agents"); }); + + await flushGlobalTracerProvider(); + const spans = exporter.getFinishedSpans(); + expect(spans.length).toBeGreaterThan(0); + const chatSpan = spans.find( + (s) => s.attributes[ATTR_GEN_AI_OPERATION_NAME] === GEN_AI_OPERATION_CHAT, + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan?.instrumentationScope.name).toBe("custom-openai-scope"); + expect(chatSpan?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(chatSpan?.attributes["custom.scope"]).toBe("openai"); }); }); diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts index d99a18b9..70f26d5b 100644 --- a/test/internal/unit/a365/a365SpanProcessor.test.ts +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -179,9 +179,7 @@ describe("A365SpanProcessor", () => { const copiedAttrs = memoryExporter.getFinishedSpans()[0].attributes; expect(copiedAttrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); - expect(copiedAttrs[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBe( - "caller-123", - ); + expect(copiedAttrs[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBe("caller-123"); memoryExporter.reset(); @@ -263,6 +261,33 @@ describe("A365SpanProcessor", () => { await customProvider.shutdown(); }); + it("copies generic and registered custom baggage for provisional chain spans from supported scopes", () => { + const span = startSpan(provider, { + tracerName: "microsoft-otel-openai-agents", + spanName: "mcp_tools listing", + operationName: "chain", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.scope", + "custom.scope": "openai", + }, + }); + span.setAttribute( + OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY, + OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, + ); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe( + OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, + ); + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes["custom.scope"]).toBe("openai"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + }); + it("should not mutate spans without gen_ai.operation.name", () => { const baggageEntries = { [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", diff --git a/test/internal/unit/main.test.ts b/test/internal/unit/main.test.ts index ca54ace7..2fb7250c 100644 --- a/test/internal/unit/main.test.ts +++ b/test/internal/unit/main.test.ts @@ -1237,9 +1237,7 @@ describe("Main functions", () => { ); assert.isDefined(processor); - assert.isTrue( - processor["genAiInstrumentationScopeNames"].has("custom-openai-scope"), - ); + assert.isTrue(processor["genAiInstrumentationScopeNames"].has("custom-openai-scope")); await shutdownMicrosoftOpenTelemetry(); }); From 641a003a8d87b12a619a00f84fc05dce676d5cfc Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 12:44:10 -0600 Subject: [PATCH 18/24] test(a365): cover custom scope descendant regression Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b01797b-8c8b-4ab7-aba9-96a5e7b7a5a0 --- .../unit/a365/a365SpanProcessor.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts index 70f26d5b..848b21a3 100644 --- a/test/internal/unit/a365/a365SpanProcessor.test.ts +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -261,6 +261,32 @@ describe("A365SpanProcessor", () => { await customProvider.shutdown(); }); + it("does not treat custom scope descendants as supported scopes", async () => { + const customProcessor = new A365SpanProcessor(["custom-openai-scope"]); + const customMemoryExporter = new InMemorySpanExporter(); + const customProvider = new BasicTracerProvider({ + spanProcessors: [customProcessor, new SimpleSpanProcessor(customMemoryExporter)], + }); + + const span = startSpan(customProvider, { + tracerName: "custom-openai-scope.child", + spanName: "unmodeled operation", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-1", + }, + }); + span.end(); + + const attributes = customMemoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); + expect(attributes["custom.one"]).toBeUndefined(); + expect(attributes[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBeUndefined(); + + await customProvider.shutdown(); + }); + it("copies generic and registered custom baggage for provisional chain spans from supported scopes", () => { const span = startSpan(provider, { tracerName: "microsoft-otel-openai-agents", From 7fd3fa34b7f5c35ce78dfeb72a9d960324af34d8 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 12:55:06 -0600 Subject: [PATCH 19/24] Fix A365 custom scope baggage contracts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b01797b-8c8b-4ab7-aba9-96a5e7b7a5a0 --- A365_DOCUMENTATION.md | 6 +- src/a365/processors/A365SpanProcessor.ts | 17 +-- src/distro/distro.ts | 4 +- .../functional/genai-openai-distro.test.ts | 117 +++++++++--------- .../unit/a365/a365SpanProcessor.test.ts | 80 ++++++++++++ test/internal/unit/main.test.ts | 110 ++++++++-------- 6 files changed, 216 insertions(+), 118 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 42df9e88..b7a8cafb 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -135,7 +135,11 @@ baggageScope.run(() => { - For the built-in LangChain and OpenAI Agents instrumentations, enrichment also recognizes their exact instrumentation scope names when the final GenAI operation is not available at span start. A configured OpenAI Agents `tracerName` is registered as an exact supported - scope. Scope prefixes and unrelated child scopes are not matched. + scope byte-for-byte, including whitespace or empty strings. Scope prefixes and unrelated child + scopes are not matched. +- Invoke-agent-only baggage keys stay invoke-agent-only even when registered through + `_internal.custom_keys`; unknown or non-`invoke_agent` GenAI spans never receive those caller + agent attributes. - Explicit span attributes win over baggage. If a span already has a value for a registered custom key, the span value is preserved. diff --git a/src/a365/processors/A365SpanProcessor.ts b/src/a365/processors/A365SpanProcessor.ts index 35b49a9f..ab4239e7 100644 --- a/src/a365/processors/A365SpanProcessor.ts +++ b/src/a365/processors/A365SpanProcessor.ts @@ -26,6 +26,8 @@ const DEFAULT_GEN_AI_INSTRUMENTATION_SCOPE_NAMES: readonly string[] = [ "microsoft-otel-openai-agents", ]; +const INVOKE_AGENT_ATTRIBUTE_NAMES = new Set(INVOKE_AGENT_ATTRIBUTES); + function getOperationFromSpanName(spanName: unknown): string | undefined { if (typeof spanName !== "string") { return undefined; @@ -51,6 +53,10 @@ function getRegisteredCustomKeys(value: string | undefined): string[] { .filter((key) => key && key !== INTERNAL_CUSTOM_KEYS_METADATA_KEY); } +function shouldCopyRegisteredCustomKey(key: string, isInvokeAgent: boolean): boolean { + return isInvokeAgent || !INVOKE_AGENT_ATTRIBUTE_NAMES.has(key); +} + /** * Copies relevant baggage entries to span attributes on span start. * @@ -65,10 +71,7 @@ export class A365SpanProcessor implements BaseSpanProcessor { constructor(additionalGenAiInstrumentationScopeNames: Iterable = []) { for (const scopeName of additionalGenAiInstrumentationScopeNames) { - const normalizedScopeName = scopeName.trim(); - if (normalizedScopeName) { - this.genAiInstrumentationScopeNames.add(normalizedScopeName); - } + this.genAiInstrumentationScopeNames.add(scopeName); } } @@ -135,9 +138,9 @@ export class A365SpanProcessor implements BaseSpanProcessor { if (isInvokeAgent) { INVOKE_AGENT_ATTRIBUTES.forEach((key) => targetKeys.add(key)); } - getRegisteredCustomKeys(baggageMap.get(INTERNAL_CUSTOM_KEYS_METADATA_KEY)).forEach((key) => - targetKeys.add(key), - ); + getRegisteredCustomKeys(baggageMap.get(INTERNAL_CUSTOM_KEYS_METADATA_KEY)) + .filter((key) => shouldCopyRegisteredCustomKey(key, isInvokeAgent)) + .forEach((key) => targetKeys.add(key)); targetKeys.delete(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY); // Set telemetry SDK attributes diff --git a/src/distro/distro.ts b/src/distro/distro.ts index cebf6d5e..59026971 100644 --- a/src/distro/distro.ts +++ b/src/distro/distro.ts @@ -326,7 +326,9 @@ export function useMicrosoftOpenTelemetry(options?: MicrosoftOpenTelemetryOption // exporters (Azure Monitor, OTLP, …) still receive the enriched spans. const configuredOpenAiTracerName = config.instrumentationOptions?.openaiAgents?.tracerName; spanProcessors.push( - new A365SpanProcessor(configuredOpenAiTracerName ? [configuredOpenAiTracerName] : []), + new A365SpanProcessor( + configuredOpenAiTracerName !== undefined ? [configuredOpenAiTracerName] : [], + ), ); if (a365Config.enableObservabilityExporter) { const a365Exporter = new Agent365Exporter({ diff --git a/test/internal/functional/genai-openai-distro.test.ts b/test/internal/functional/genai-openai-distro.test.ts index 98499cd3..88f5eeb0 100644 --- a/test/internal/functional/genai-openai-distro.test.ts +++ b/test/internal/functional/genai-openai-distro.test.ts @@ -40,70 +40,73 @@ describe("OpenAI Agents distro integration", () => { vi.restoreAllMocks(); }); - it("wires OpenAI Agents via distro init and emits spans with microsoft-otel-openai-agents scope", async () => { - useMicrosoftOpenTelemetry({ - tracesPerSecond: 0, - samplingRatio: 1, - a365: { - enabled: true, - tokenResolver: () => "token", - }, - azureMonitor: { enabled: false }, - enableConsoleExporters: false, - spanProcessors: [new SimpleSpanProcessor(exporter)], - instrumentationOptions: { - openaiAgents: { + it.each([" custom-openai-scope "])( + "wires OpenAI Agents via distro init with exact tracer name %j", + async (tracerName) => { + useMicrosoftOpenTelemetry({ + tracesPerSecond: 0, + samplingRatio: 1, + a365: { enabled: true, - tracerName: "custom-openai-scope", - isContentRecordingEnabled: true, + tokenResolver: () => "token", }, - langchain: { enabled: false }, - }, - }); + azureMonitor: { enabled: false }, + enableConsoleExporters: false, + spanProcessors: [new SimpleSpanProcessor(exporter)], + instrumentationOptions: { + openaiAgents: { + enabled: true, + tracerName, + isContentRecordingEnabled: true, + }, + langchain: { enabled: false }, + }, + }); - // OpenAI instrumentor initialization is kicked off asynchronously during distro startup. - await vi.waitFor(() => { - expect(() => OpenAIAgentsTraceInstrumentor.enable()).not.toThrow(); - }); + // OpenAI instrumentor initialization is kicked off asynchronously during distro startup. + await vi.waitFor(() => { + expect(() => OpenAIAgentsTraceInstrumentor.enable()).not.toThrow(); + }); - await vi.waitFor(() => { - expect(OpenAIAgents.getCurrentTrace()).toBeNull(); - }); + await vi.waitFor(() => { + expect(OpenAIAgents.getCurrentTrace()).toBeNull(); + }); - exporter.reset(); - OpenAIAgentsTraceInstrumentor.enable(); - const baggageScope = new BaggageBuilder() - .tenantId("tenant-123") - .customAttribute("custom.scope", "openai") - .build(); + exporter.reset(); + OpenAIAgentsTraceInstrumentor.enable(); + const baggageScope = new BaggageBuilder() + .tenantId("tenant-123") + .customAttribute("custom.scope", "openai") + .build(); - await baggageScope.run(async () => { - await OpenAIAgents.withTrace("genai-openai-integration", async () => { - await OpenAIAgents.withGenerationSpan( - async () => { - return; - }, - { - spanData: { - model: "gpt-4o", - usage: { input_tokens: 10, output_tokens: 5 }, - input: [{ role: "user", content: "hello" }], - output: [{ role: "assistant", content: "hi" }], + await baggageScope.run(async () => { + await OpenAIAgents.withTrace("genai-openai-integration", async () => { + await OpenAIAgents.withGenerationSpan( + async () => { + return; }, - } as any, - ); + { + spanData: { + model: "gpt-4o", + usage: { input_tokens: 10, output_tokens: 5 }, + input: [{ role: "user", content: "hello" }], + output: [{ role: "assistant", content: "hi" }], + }, + } as any, + ); + }); }); - }); - await flushGlobalTracerProvider(); - const spans = exporter.getFinishedSpans(); - expect(spans.length).toBeGreaterThan(0); - const chatSpan = spans.find( - (s) => s.attributes[ATTR_GEN_AI_OPERATION_NAME] === GEN_AI_OPERATION_CHAT, - ); - expect(chatSpan).toBeDefined(); - expect(chatSpan?.instrumentationScope.name).toBe("custom-openai-scope"); - expect(chatSpan?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); - expect(chatSpan?.attributes["custom.scope"]).toBe("openai"); - }); + await flushGlobalTracerProvider(); + const spans = exporter.getFinishedSpans(); + expect(spans.length).toBeGreaterThan(0); + const chatSpan = spans.find( + (s) => s.attributes[ATTR_GEN_AI_OPERATION_NAME] === GEN_AI_OPERATION_CHAT, + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan?.instrumentationScope.name).toBe(tracerName); + expect(chatSpan?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(chatSpan?.attributes["custom.scope"]).toBe("openai"); + }, + ); }); diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts index 848b21a3..542dc668 100644 --- a/test/internal/unit/a365/a365SpanProcessor.test.ts +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -236,6 +236,40 @@ describe("A365SpanProcessor", () => { expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); }); + it.each([ + { + name: "supported-scope provisional operations", + tracerName: "microsoft-otel-openai-agents", + spanName: "mcp_tools listing", + operationName: "chain", + }, + { + name: "recognized non-invoke operations", + tracerName: "test", + spanName: "chat span", + operationName: OpenTelemetryConstants.CHAT_OPERATION_NAME, + }, + ])( + "does not copy invoke-agent-only baggage registered as custom for $name", + ({ tracerName, spanName, operationName }) => { + const span = startSpan(provider, { + tracerName, + spanName, + operationName, + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + }, + ); + it("supports constructor-provided custom scopes", async () => { const customProcessor = new A365SpanProcessor(["custom-openai-scope"]); const customMemoryExporter = new InMemorySpanExporter(); @@ -261,6 +295,34 @@ describe("A365SpanProcessor", () => { await customProvider.shutdown(); }); + it.each([" custom-openai-scope ", ""])( + "preserves constructor-provided custom scope %j exactly", + async (customScopeName) => { + const customProcessor = new A365SpanProcessor([customScopeName]); + const customMemoryExporter = new InMemorySpanExporter(); + const customProvider = new BasicTracerProvider({ + spanProcessors: [customProcessor, new SimpleSpanProcessor(customMemoryExporter)], + }); + + const span = startSpan(customProvider, { + tracerName: customScopeName, + spanName: "unmodeled operation", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-1", + }, + }); + span.end(); + + const attributes = customMemoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes["custom.one"]).toBe("value-1"); + + await customProvider.shutdown(); + }, + ); + it("does not treat custom scope descendants as supported scopes", async () => { const customProcessor = new A365SpanProcessor(["custom-openai-scope"]); const customMemoryExporter = new InMemorySpanExporter(); @@ -687,6 +749,24 @@ describe("A365SpanProcessor", () => { expect(attrs[INTERNAL_CUSTOM_KEYS_METADATA_KEY]).toBeUndefined(); }); + it("should still copy invoke-agent-only baggage on invoke_agent spans when the key is registered as custom", () => { + const testSpan = startGenAiSpan( + provider, + OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, + { + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + }, + ); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0].attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBe( + "caller-123", + ); + }); + it("should not allow registered custom baggage to overwrite telemetry SDK attributes", () => { const testSpan = startGenAiSpan(provider, "chat", { [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: diff --git a/test/internal/unit/main.test.ts b/test/internal/unit/main.test.ts index 2fb7250c..9bcfa6c8 100644 --- a/test/internal/unit/main.test.ts +++ b/test/internal/unit/main.test.ts @@ -1211,36 +1211,39 @@ describe("Main functions", () => { await shutdownMicrosoftOpenTelemetry(); }); - it("passes a configured OpenAI tracer name to A365SpanProcessor", async () => { - useMicrosoftOpenTelemetry({ - azureMonitor: { enabled: false }, - enableConsoleExporters: false, - a365: { - enabled: true, - tokenResolver: () => "token", - }, - instrumentationOptions: { - openaiAgents: { - enabled: false, - tracerName: "custom-openai-scope", + it.each(["custom-openai-scope", " custom-openai-scope ", ""])( + "passes configured OpenAI tracer name %j to A365SpanProcessor unchanged", + async (tracerName) => { + useMicrosoftOpenTelemetry({ + azureMonitor: { enabled: false }, + enableConsoleExporters: false, + a365: { + enabled: true, + tokenResolver: () => "token", }, - langchain: { enabled: false }, - }, - }); + instrumentationOptions: { + openaiAgents: { + enabled: false, + tracerName, + }, + langchain: { enabled: false }, + }, + }); - const internalSdk = _getSdkInstance(); - const tracerProvider = (internalSdk as any)["_tracerProvider"]; - const registeredProcessors = - tracerProvider?.["_activeSpanProcessor"]?.["_spanProcessors"] || []; - const processor = registeredProcessors.find( - (candidate: any) => candidate.constructor?.name === "A365SpanProcessor", - ); + const internalSdk = _getSdkInstance(); + const tracerProvider = (internalSdk as any)["_tracerProvider"]; + const registeredProcessors = + tracerProvider?.["_activeSpanProcessor"]?.["_spanProcessors"] || []; + const processor = registeredProcessors.find( + (candidate: any) => candidate.constructor?.name === "A365SpanProcessor", + ); - assert.isDefined(processor); - assert.isTrue(processor["genAiInstrumentationScopeNames"].has("custom-openai-scope")); + assert.isDefined(processor); + assert.isTrue(processor["genAiInstrumentationScopeNames"].has(tracerName)); - await shutdownMicrosoftOpenTelemetry(); - }); + await shutdownMicrosoftOpenTelemetry(); + }, + ); it("registers A365SpanProcessor but not Agent365Exporter when a365.enableObservabilityExporter is false (default)", async () => { useMicrosoftOpenTelemetry({ @@ -1621,36 +1624,39 @@ describe("Main functions", () => { _resetA365LoggerForTest(); }); - it("initializes OpenAI Agents instrumentation when enabled", async () => { - const instrumentSpy = vi.spyOn(OpenAIAgentsTraceInstrumentor, "instrument"); + it.each(["openai-agent-auto-instrumentation", " openai-agent-auto-instrumentation ", ""])( + "initializes OpenAI Agents instrumentation with exact tracer name %j", + async (tracerName) => { + const instrumentSpy = vi.spyOn(OpenAIAgentsTraceInstrumentor, "instrument"); - useMicrosoftOpenTelemetry({ - azureMonitor: { enabled: false }, - enableConsoleExporters: false, - instrumentationOptions: { - openaiAgents: { - enabled: true, - tracerName: "openai-agent-auto-instrumentation", - tracerVersion: "1.0.0", - isContentRecordingEnabled: true, + useMicrosoftOpenTelemetry({ + azureMonitor: { enabled: false }, + enableConsoleExporters: false, + instrumentationOptions: { + openaiAgents: { + enabled: true, + tracerName, + tracerVersion: "1.0.0", + isContentRecordingEnabled: true, + }, + langchain: { enabled: false }, }, - langchain: { enabled: false }, - }, - }); + }); - await vi.waitFor(() => { - expect(instrumentSpy).toHaveBeenCalledWith( - expect.objectContaining({ - enabled: true, - tracerName: "openai-agent-auto-instrumentation", - tracerVersion: "1.0.0", - isContentRecordingEnabled: true, - }), - ); - }); + await vi.waitFor(() => { + expect(instrumentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + tracerName, + tracerVersion: "1.0.0", + isContentRecordingEnabled: true, + }), + ); + }); - await shutdownMicrosoftOpenTelemetry(); - }); + await shutdownMicrosoftOpenTelemetry(); + }, + ); it("initializes LangChain instrumentation when enabled", async () => { const instrumentSpy = vi.spyOn(LangChainTraceInstrumentor, "instrument"); From 9b454ef802334697f4cfe5b99e47740b5e2ab988 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 12:59:15 -0600 Subject: [PATCH 20/24] chore: remove internal scope planning artifacts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b01797b-8c8b-4ab7-aba9-96a5e7b7a5a0 --- ...6-09-18-a365-genai-scope-classification.md | 439 ------------------ ...-a365-genai-scope-classification-design.md | 77 --- 2 files changed, 516 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-18-a365-genai-scope-classification.md delete mode 100644 docs/superpowers/specs/2026-09-18-a365-genai-scope-classification-design.md diff --git a/docs/superpowers/plans/2026-09-18-a365-genai-scope-classification.md b/docs/superpowers/plans/2026-09-18-a365-genai-scope-classification.md deleted file mode 100644 index c86ad9d2..00000000 --- a/docs/superpowers/plans/2026-09-18-a365-genai-scope-classification.md +++ /dev/null @@ -1,439 +0,0 @@ -# A365 GenAI Scope Classification Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Enrich supported LangChain and OpenAI Agents spans when their final GenAI operation is unavailable at span start, including configured OpenAI tracer names. - -**Architecture:** `A365SpanProcessor` will classify spans from recognized initial operations, exact span-name boundaries, or an exact supported instrumentation scope. The processor owns the two default JavaScript scope names and accepts additional exact names from distro configuration; exporter filtering remains based on final operation names. - -**Tech Stack:** TypeScript, OpenTelemetry JS SDK, Vitest, npm. - -## Global Constraints - -- Match instrumentation scope names exactly; do not accept prefixes or dotted descendants. -- Recognize `microsoft-otel-langchain`, `microsoft-otel-openai-agents`, and the resolved custom OpenAI Agents `tracerName`. -- Explicit unrecognized operations remain authoritative over span-name inference. -- Unknown operations under supported scopes receive generic and registered custom baggage, but not invoke-agent-only baggage. -- Do not change A365 exporter eligibility or read operation classification from ambient baggage. - ---- - -### Task 1: Add exact-scope span classification - -**Files:** -- Modify: `test/internal/unit/a365/a365SpanProcessor.test.ts` -- Modify: `src/a365/processors/A365SpanProcessor.ts` - -**Interfaces:** -- Consumes: `GEN_AI_OPERATION_NAMES: ReadonlySet`, `Span.instrumentationScope.name` from the SDK span implementation. -- Produces: `new A365SpanProcessor(additionalGenAiInstrumentationScopeNames?: Iterable)`. - -- [ ] **Step 1: Write failing processor tests** - -Add test helpers that allow the tracer scope, span name, and optional initial operation to be selected: - -```ts -function startSpan( - provider: BasicTracerProvider, - { - tracerName = "test", - spanName, - operationName, - baggage = {}, - }: { - tracerName?: string; - spanName: string; - operationName?: string; - baggage?: Record; - }, -) { - const ctx = propagation.setBaggage(context.active(), createBaggage(baggage)); - return provider.getTracer(tracerName).startSpan( - spanName, - { - kind: SpanKind.CLIENT, - ...(operationName - ? { - attributes: { - [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: operationName, - }, - } - : {}), - }, - ctx, - ); -} -``` - -Add cases asserting: - -```ts -it.each(["microsoft-otel-langchain", "microsoft-otel-openai-agents"])( - "copies generic and registered custom baggage for supported scope %s without an initial operation", - (tracerName) => { - const span = startSpan(provider, { - tracerName, - spanName: "unmodeled operation", - baggage: { - [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", - [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", - [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", - "custom.one": "value-1", - }, - }); - span.end(); - - const attributes = memoryExporter.getFinishedSpans()[0].attributes; - expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); - expect(attributes["custom.one"]).toBe("value-1"); - expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); - }, -); -``` - -Also add tests for: - -```ts -// Exact matching only. -tracerName: "microsoft-otel-langchain.child" // untouched - -// Span-name boundary recognition. -spanName: "invoke_agent planner" // invoke-agent baggage copied -spanName: "invoke_agent_toolbox" // untouched - -// Explicit unknown operation blocks span-name inference. -tracerName: "microsoft-otel-langchain" -spanName: "invoke_agent planner" -operationName: "chain" // generic/custom copied; invoke-agent baggage omitted - -// Constructor-provided custom scope. -const customProcessor = new A365SpanProcessor(["custom-openai-scope"]); -``` - -- [ ] **Step 2: Run the focused test and verify failure** - -Run: - -```powershell -$nodeDir = 'C:\Users\nikhilc\AppData\Roaming\nvm\v22.14.0' -$env:PATH = "$nodeDir;$env:PATH" -& "$nodeDir\npx.cmd" vitest run test/internal/unit/a365/a365SpanProcessor.test.ts -``` - -Expected: the new supported-scope and custom-scope cases fail because the processor currently requires a recognized initial operation. - -- [ ] **Step 3: Implement exact-scope classification** - -In `A365SpanProcessor.ts`, add: - -```ts -const DEFAULT_GEN_AI_INSTRUMENTATION_SCOPE_NAMES: readonly string[] = [ - "microsoft-otel-langchain", - "microsoft-otel-openai-agents", -]; - -function getOperationFromSpanName(spanName: unknown): string | undefined { - if (typeof spanName !== "string") { - return undefined; - } - - for (const operationName of GEN_AI_OPERATION_NAMES) { - if (spanName === operationName || spanName.startsWith(`${operationName} `)) { - return operationName; - } - } - - return undefined; -} -``` - -Add an exact-name set: - -```ts -private readonly genAiInstrumentationScopeNames = new Set( - DEFAULT_GEN_AI_INSTRUMENTATION_SCOPE_NAMES, -); - -constructor(additionalGenAiInstrumentationScopeNames: Iterable = []) { - for (const scopeName of additionalGenAiInstrumentationScopeNames) { - const normalizedScopeName = scopeName.trim(); - if (normalizedScopeName) { - this.genAiInstrumentationScopeNames.add(normalizedScopeName); - } - } -} -``` - -Replace the current operation-only gate with: - -```ts -const spanRecord = span as Span & { - attributes?: Record; - name?: string; - instrumentationScope?: { name?: string }; -}; -const explicitOperation = spanRecord.attributes?.[ - OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY -]; -const recognizedExplicitOperation = - typeof explicitOperation === "string" && GEN_AI_OPERATION_NAMES.has(explicitOperation) - ? explicitOperation - : undefined; -const inferredOperation = - explicitOperation === undefined ? getOperationFromSpanName(spanRecord.name) : undefined; -const operationName = recognizedExplicitOperation ?? inferredOperation; -const supportedScope = - typeof spanRecord.instrumentationScope?.name === "string" && - this.genAiInstrumentationScopeNames.has(spanRecord.instrumentationScope.name); - -if (!operationName && !supportedScope) { - return; -} -``` - -Use only the classified operation for invoke-agent baggage: - -```ts -const isInvokeAgent = - operationName === OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME; -``` - -- [ ] **Step 4: Run focused tests and verify success** - -Run: - -```powershell -& "$nodeDir\npx.cmd" vitest run test/internal/unit/a365/a365SpanProcessor.test.ts -``` - -Expected: all `A365SpanProcessor` tests pass, including exact-scope and precedence cases. - -- [ ] **Step 5: Commit processor classification** - -```powershell -git add src/a365/processors/A365SpanProcessor.ts test/internal/unit/a365/a365SpanProcessor.test.ts -git commit -m "fix(a365): classify supported GenAI instrumentation scopes" -``` - ---- - -### Task 2: Wire configured OpenAI tracer names - -**Files:** -- Modify: `test/internal/unit/main.test.ts:1185-1212` -- Modify: `src/distro/distro.ts:321-328` - -**Interfaces:** -- Consumes: `A365SpanProcessor(additionalGenAiInstrumentationScopeNames?: Iterable)`. -- Produces: distro registration that passes `config.instrumentationOptions.openaiAgents?.tracerName`. - -- [ ] **Step 1: Write a failing distro configuration test** - -Add a test near the existing A365 processor registration case: - -```ts -it("passes a configured OpenAI tracer name to A365SpanProcessor", async () => { - useMicrosoftOpenTelemetry({ - azureMonitor: { enabled: false }, - enableConsoleExporters: false, - a365: { - enabled: true, - tokenResolver: () => "token", - }, - instrumentationOptions: { - openaiAgents: { - enabled: false, - tracerName: "custom-openai-scope", - }, - langchain: { enabled: false }, - }, - }); - - const internalSdk = _getSdkInstance(); - const tracerProvider = (internalSdk as any)["_tracerProvider"]; - const registeredProcessors = - tracerProvider?.["_activeSpanProcessor"]?.["_spanProcessors"] || []; - const processor = registeredProcessors.find( - (candidate: any) => candidate.constructor?.name === "A365SpanProcessor", - ); - - assert.isDefined(processor); - assert.isTrue(processor["genAiInstrumentationScopeNames"].has("custom-openai-scope")); - - await shutdownMicrosoftOpenTelemetry(); -}); -``` - -- [ ] **Step 2: Run the test and verify failure** - -Run: - -```powershell -& "$nodeDir\npx.cmd" vitest run --config vitest.unit.config.ts test/internal/unit/main.test.ts -``` - -Expected: the new assertion fails because distro initialization currently constructs `A365SpanProcessor` without the configured tracer name. - -- [ ] **Step 3: Pass the resolved custom scope** - -Replace the registration in `src/distro/distro.ts` with: - -```ts -const configuredOpenAiTracerName = - config.instrumentationOptions.openaiAgents?.tracerName; -spanProcessors.push( - new A365SpanProcessor( - configuredOpenAiTracerName ? [configuredOpenAiTracerName] : [], - ), -); -``` - -- [ ] **Step 4: Run the focused unit test** - -Run: - -```powershell -& "$nodeDir\npx.cmd" vitest run --config vitest.unit.config.ts test/internal/unit/main.test.ts -``` - -Expected: all main unit tests pass. - -- [ ] **Step 5: Commit configuration wiring** - -```powershell -git add src/distro/distro.ts test/internal/unit/main.test.ts -git commit -m "fix(a365): register configured OpenAI tracer scope" -``` - ---- - -### Task 3: Verify real producer lifecycle and documentation - -**Files:** -- Modify: `test/internal/functional/genai-distro.test.ts` -- Modify: `test/internal/functional/genai-openai-distro.test.ts` -- Modify: `A365_DOCUMENTATION.md:131-140` - -**Interfaces:** -- Consumes: scope-aware `A365SpanProcessor` registered through `useMicrosoftOpenTelemetry`. -- Produces: functional regression coverage for actual LangChain and OpenAI Agents adapters. - -- [ ] **Step 1: Add failing LangChain enrichment coverage** - -Update the existing functional initialization to enable A365 and run the adapter under baggage: - -```ts -const baggage = propagation - .createBaggage() - .setEntry(OpenTelemetryConstants.TENANT_ID_KEY, { value: "tenant-123" }) - .setEntry("_internal.custom_keys", { value: "custom.scope" }) - .setEntry("custom.scope", { value: "langchain" }); -const ctx = propagation.setBaggage(context.active(), baggage); - -await context.with(ctx, async () => { - await langChainTracer.onRunCreate(run); - await langChainTracer._endTrace(run); -}); -``` - -Configure: - -```ts -a365: { - enabled: true, - tokenResolver: () => "token", -}, -``` - -Assert the finished LangChain span contains: - -```ts -expect(chatSpan?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); -expect(chatSpan?.attributes["custom.scope"]).toBe("langchain"); -``` - -- [ ] **Step 2: Add custom OpenAI tracer enrichment coverage** - -Configure the OpenAI functional test with: - -```ts -a365: { - enabled: true, - tokenResolver: () => "token", -}, -instrumentationOptions: { - openaiAgents: { - enabled: true, - tracerName: "custom-openai-scope", - isContentRecordingEnabled: true, - }, - langchain: { enabled: false }, -}, -``` - -Run the existing OpenAI generation under baggage registered with -`_internal.custom_keys`, then assert: - -```ts -expect(chatSpan?.instrumentationScope.name).toBe("custom-openai-scope"); -expect(chatSpan?.attributes["custom.scope"]).toBe("openai"); -``` - -Add a processor-level MCP lifecycle case in -`test/internal/unit/a365/a365SpanProcessor.test.ts` using the supported OpenAI -scope, initial operation `chain`, and a later `span.setAttribute()` to -`execute_tool`. Assert generic/custom baggage was applied at start and -invoke-agent-only baggage was omitted. - -- [ ] **Step 3: Run functional and processor tests** - -Run: - -```powershell -& "$nodeDir\npx.cmd" vitest run --config vitest.functional.config.ts test/internal/functional/genai-distro.test.ts test/internal/functional/genai-openai-distro.test.ts -& "$nodeDir\npx.cmd" vitest run test/internal/unit/a365/a365SpanProcessor.test.ts -``` - -Expected: all selected tests pass. The source-defined scope identities and emitted functional spans agree, so no external service sample run is required. - -- [ ] **Step 4: Document scope fallback** - -Add to `A365_DOCUMENTATION.md`: - -```md -For the built-in LangChain and OpenAI Agents instrumentations, enrichment also -recognizes their exact instrumentation scope names when the final GenAI -operation is not available at span start. A configured OpenAI Agents -`tracerName` is registered as an exact supported scope. Scope prefixes and -unrelated child scopes are not matched. -``` - -- [ ] **Step 5: Run complete verification** - -Run: - -```powershell -& "$nodeDir\npm.cmd" run format -& "$nodeDir\npm.cmd" run lint -& "$nodeDir\npm.cmd" run build -& "$nodeDir\npm.cmd" test -git diff --check -``` - -Expected: formatting, lint, build, and all tests succeed with no merge markers or whitespace errors. - -- [ ] **Step 6: Commit functional coverage and documentation** - -```powershell -git add test/internal/functional/genai-distro.test.ts test/internal/functional/genai-openai-distro.test.ts test/internal/unit/a365/a365SpanProcessor.test.ts A365_DOCUMENTATION.md -git commit -m "test(a365): cover GenAI scope fallback lifecycle" -``` - -- [ ] **Step 7: Push and verify the PR** - -```powershell -git push origin feature/a365-custom-baggage -gh pr view 242 --repo microsoft/opentelemetry-distro-javascript --json headRefOid,mergeable,mergeStateStatus,statusCheckRollup -``` - -Expected: the remote head matches local `HEAD`, the PR is mergeable, and CI starts for the pushed commits. diff --git a/docs/superpowers/specs/2026-09-18-a365-genai-scope-classification-design.md b/docs/superpowers/specs/2026-09-18-a365-genai-scope-classification-design.md deleted file mode 100644 index 87399755..00000000 --- a/docs/superpowers/specs/2026-09-18-a365-genai-scope-classification-design.md +++ /dev/null @@ -1,77 +0,0 @@ -# A365 GenAI Scope Classification Design - -## Goal - -Ensure A365 baggage enrichment reaches spans from supported JavaScript GenAI -instrumentations when `gen_ai.operation.name` is unavailable or provisional at -span start, without classifying unrelated spans as GenAI. - -## Supported instrumentation scopes - -The classifier recognizes exact scope names owned by this distribution: - -- `microsoft-otel-langchain` -- `microsoft-otel-openai-agents` -- The resolved `instrumentationOptions.openaiAgents.tracerName`, when configured - -`Agent365Sdk` does not require scope fallback because manual A365 scopes provide -their operation as an initial span attribute. Scope names are matched exactly; -prefixes and dotted descendants are not accepted implicitly. - -## Classification precedence - -At `A365SpanProcessor.onStart()`: - -1. A recognized explicit `gen_ai.operation.name` classifies the span with that - known operation. -2. An explicit but unrecognized operation remains authoritative over span-name - inference. A supported instrumentation scope may still classify the span as - GenAI with an unknown operation. -3. When no explicit operation exists, an exact recognized operation at the - beginning of the span name, followed by the end of the name or a space, - classifies the span with that operation. -4. Otherwise, an exact supported instrumentation scope classifies the span as - GenAI with an unknown operation. -5. Spans with no recognized operation, name, or scope remain untouched. - -Known `invoke_agent` operations receive generic, invoke-agent-specific, and -registered custom baggage. GenAI spans with an unknown operation receive only -generic and registered custom baggage. - -## Configuration flow - -`A365SpanProcessor` accepts an optional iterable of additional supported scope -names. It always includes the two distribution-owned defaults. Distro -initialization passes the resolved OpenAI Agents `tracerName` from -`config.instrumentationOptions`, which includes defaults applied by existing -configuration resolution. - -No global registry is introduced. Instrumentors remain responsible only for -producing spans, and exporter filtering continues to use final recognized -operation names. - -## Testing - -Unit tests cover: - -- Recognized operations independent of instrumentation scope. -- LangChain spans whose operation is added after `startSpan()`. -- OpenAI Agents spans starting with the provisional `chain` operation. -- Generic/custom enrichment without invoke-agent-only baggage for unknown - operations under supported scopes. -- The configured custom OpenAI tracer name. -- Unrelated scopes, scope prefix collisions, and span-name prefix collisions. -- Explicit unrecognized operations remaining authoritative over span names. - -Functional instrumentation tests assert baggage enrichment on spans emitted by -the LangChain and OpenAI Agents adapters. Source-defined scope names are -deterministic, so running external service samples is unnecessary unless these -tests reveal a mismatch. - -## Non-goals - -- Recognizing Python-specific scope roots such as `agent_framework`, - `semantic_kernel`, or `opentelemetry.instrumentation.openai_v2`. -- Classifying spans from arbitrary descendants of a recognized scope prefix. -- Changing A365 exporter eligibility or accepting operation names from ambient - baggage. From ec37d5c7b16797eb99cf396bbdc81d60c2cdf9a9 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 15:11:37 -0600 Subject: [PATCH 21/24] fix(a365): limit scope fallback to built-in instrumentations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b01797b-8c8b-4ab7-aba9-96a5e7b7a5a0 --- A365_DOCUMENTATION.md | 5 +- src/a365/processors/A365SpanProcessor.ts | 6 - src/distro/distro.ts | 7 +- .../functional/genai-openai-distro.test.ts | 116 +++++++++--------- .../unit/a365/a365SpanProcessor.test.ts | 79 ------------ test/internal/unit/main.test.ts | 56 ++++----- 6 files changed, 86 insertions(+), 183 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index b7a8cafb..6eb8ea4a 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -134,9 +134,8 @@ baggageScope.run(() => { `apply_guardrail`, `chat`, `Chat`, `TextCompletion`, or `GenerateContent`. - For the built-in LangChain and OpenAI Agents instrumentations, enrichment also recognizes their exact instrumentation scope names when the final GenAI operation is not available at - span start. A configured OpenAI Agents `tracerName` is registered as an exact supported - scope byte-for-byte, including whitespace or empty strings. Scope prefixes and unrelated child - scopes are not matched. + span start. Configured custom tracer names, scope prefixes, and unrelated child scopes are not + matched. - Invoke-agent-only baggage keys stay invoke-agent-only even when registered through `_internal.custom_keys`; unknown or non-`invoke_agent` GenAI spans never receive those caller agent attributes. diff --git a/src/a365/processors/A365SpanProcessor.ts b/src/a365/processors/A365SpanProcessor.ts index ab4239e7..70f95bda 100644 --- a/src/a365/processors/A365SpanProcessor.ts +++ b/src/a365/processors/A365SpanProcessor.ts @@ -69,12 +69,6 @@ export class A365SpanProcessor implements BaseSpanProcessor { DEFAULT_GEN_AI_INSTRUMENTATION_SCOPE_NAMES, ); - constructor(additionalGenAiInstrumentationScopeNames: Iterable = []) { - for (const scopeName of additionalGenAiInstrumentationScopeNames) { - this.genAiInstrumentationScopeNames.add(scopeName); - } - } - /** * Called when a span is started. * Copies relevant baggage entries to span attributes. diff --git a/src/distro/distro.ts b/src/distro/distro.ts index 59026971..ceb7cb19 100644 --- a/src/distro/distro.ts +++ b/src/distro/distro.ts @@ -324,12 +324,7 @@ export function useMicrosoftOpenTelemetry(options?: MicrosoftOpenTelemetryOption // telemetry.sdk.* attributes to span attributes. Always registered when // A365 is enabled, even if the HTTP exporter is suppressed, so downstream // exporters (Azure Monitor, OTLP, …) still receive the enriched spans. - const configuredOpenAiTracerName = config.instrumentationOptions?.openaiAgents?.tracerName; - spanProcessors.push( - new A365SpanProcessor( - configuredOpenAiTracerName !== undefined ? [configuredOpenAiTracerName] : [], - ), - ); + spanProcessors.push(new A365SpanProcessor()); if (a365Config.enableObservabilityExporter) { const a365Exporter = new Agent365Exporter({ clusterCategory: a365Config.clusterCategory, diff --git a/test/internal/functional/genai-openai-distro.test.ts b/test/internal/functional/genai-openai-distro.test.ts index 88f5eeb0..d3bf585e 100644 --- a/test/internal/functional/genai-openai-distro.test.ts +++ b/test/internal/functional/genai-openai-distro.test.ts @@ -40,73 +40,69 @@ describe("OpenAI Agents distro integration", () => { vi.restoreAllMocks(); }); - it.each([" custom-openai-scope "])( - "wires OpenAI Agents via distro init with exact tracer name %j", - async (tracerName) => { - useMicrosoftOpenTelemetry({ - tracesPerSecond: 0, - samplingRatio: 1, - a365: { + it("wires OpenAI Agents via distro init with the built-in instrumentation scope", async () => { + useMicrosoftOpenTelemetry({ + tracesPerSecond: 0, + samplingRatio: 1, + a365: { + enabled: true, + tokenResolver: () => "token", + }, + azureMonitor: { enabled: false }, + enableConsoleExporters: false, + spanProcessors: [new SimpleSpanProcessor(exporter)], + instrumentationOptions: { + openaiAgents: { enabled: true, - tokenResolver: () => "token", + isContentRecordingEnabled: true, }, - azureMonitor: { enabled: false }, - enableConsoleExporters: false, - spanProcessors: [new SimpleSpanProcessor(exporter)], - instrumentationOptions: { - openaiAgents: { - enabled: true, - tracerName, - isContentRecordingEnabled: true, - }, - langchain: { enabled: false }, - }, - }); + langchain: { enabled: false }, + }, + }); - // OpenAI instrumentor initialization is kicked off asynchronously during distro startup. - await vi.waitFor(() => { - expect(() => OpenAIAgentsTraceInstrumentor.enable()).not.toThrow(); - }); + // OpenAI instrumentor initialization is kicked off asynchronously during distro startup. + await vi.waitFor(() => { + expect(() => OpenAIAgentsTraceInstrumentor.enable()).not.toThrow(); + }); - await vi.waitFor(() => { - expect(OpenAIAgents.getCurrentTrace()).toBeNull(); - }); + await vi.waitFor(() => { + expect(OpenAIAgents.getCurrentTrace()).toBeNull(); + }); - exporter.reset(); - OpenAIAgentsTraceInstrumentor.enable(); - const baggageScope = new BaggageBuilder() - .tenantId("tenant-123") - .customAttribute("custom.scope", "openai") - .build(); + exporter.reset(); + OpenAIAgentsTraceInstrumentor.enable(); + const baggageScope = new BaggageBuilder() + .tenantId("tenant-123") + .customAttribute("custom.scope", "openai") + .build(); - await baggageScope.run(async () => { - await OpenAIAgents.withTrace("genai-openai-integration", async () => { - await OpenAIAgents.withGenerationSpan( - async () => { - return; + await baggageScope.run(async () => { + await OpenAIAgents.withTrace("genai-openai-integration", async () => { + await OpenAIAgents.withGenerationSpan( + async () => { + return; + }, + { + spanData: { + model: "gpt-4o", + usage: { input_tokens: 10, output_tokens: 5 }, + input: [{ role: "user", content: "hello" }], + output: [{ role: "assistant", content: "hi" }], }, - { - spanData: { - model: "gpt-4o", - usage: { input_tokens: 10, output_tokens: 5 }, - input: [{ role: "user", content: "hello" }], - output: [{ role: "assistant", content: "hi" }], - }, - } as any, - ); - }); + } as any, + ); }); + }); - await flushGlobalTracerProvider(); - const spans = exporter.getFinishedSpans(); - expect(spans.length).toBeGreaterThan(0); - const chatSpan = spans.find( - (s) => s.attributes[ATTR_GEN_AI_OPERATION_NAME] === GEN_AI_OPERATION_CHAT, - ); - expect(chatSpan).toBeDefined(); - expect(chatSpan?.instrumentationScope.name).toBe(tracerName); - expect(chatSpan?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); - expect(chatSpan?.attributes["custom.scope"]).toBe("openai"); - }, - ); + await flushGlobalTracerProvider(); + const spans = exporter.getFinishedSpans(); + expect(spans.length).toBeGreaterThan(0); + const chatSpan = spans.find( + (s) => s.attributes[ATTR_GEN_AI_OPERATION_NAME] === GEN_AI_OPERATION_CHAT, + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan?.instrumentationScope.name).toBe("microsoft-otel-openai-agents"); + expect(chatSpan?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(chatSpan?.attributes["custom.scope"]).toBe("openai"); + }); }); diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts index 542dc668..0299eddb 100644 --- a/test/internal/unit/a365/a365SpanProcessor.test.ts +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -270,85 +270,6 @@ describe("A365SpanProcessor", () => { }, ); - it("supports constructor-provided custom scopes", async () => { - const customProcessor = new A365SpanProcessor(["custom-openai-scope"]); - const customMemoryExporter = new InMemorySpanExporter(); - const customProvider = new BasicTracerProvider({ - spanProcessors: [customProcessor, new SimpleSpanProcessor(customMemoryExporter)], - }); - - const span = startSpan(customProvider, { - tracerName: "custom-openai-scope", - spanName: "unmodeled operation", - baggage: { - [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", - [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", - "custom.one": "value-1", - }, - }); - span.end(); - - const attributes = customMemoryExporter.getFinishedSpans()[0].attributes; - expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); - expect(attributes["custom.one"]).toBe("value-1"); - - await customProvider.shutdown(); - }); - - it.each([" custom-openai-scope ", ""])( - "preserves constructor-provided custom scope %j exactly", - async (customScopeName) => { - const customProcessor = new A365SpanProcessor([customScopeName]); - const customMemoryExporter = new InMemorySpanExporter(); - const customProvider = new BasicTracerProvider({ - spanProcessors: [customProcessor, new SimpleSpanProcessor(customMemoryExporter)], - }); - - const span = startSpan(customProvider, { - tracerName: customScopeName, - spanName: "unmodeled operation", - baggage: { - [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", - [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", - "custom.one": "value-1", - }, - }); - span.end(); - - const attributes = customMemoryExporter.getFinishedSpans()[0].attributes; - expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); - expect(attributes["custom.one"]).toBe("value-1"); - - await customProvider.shutdown(); - }, - ); - - it("does not treat custom scope descendants as supported scopes", async () => { - const customProcessor = new A365SpanProcessor(["custom-openai-scope"]); - const customMemoryExporter = new InMemorySpanExporter(); - const customProvider = new BasicTracerProvider({ - spanProcessors: [customProcessor, new SimpleSpanProcessor(customMemoryExporter)], - }); - - const span = startSpan(customProvider, { - tracerName: "custom-openai-scope.child", - spanName: "unmodeled operation", - baggage: { - [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", - [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", - "custom.one": "value-1", - }, - }); - span.end(); - - const attributes = customMemoryExporter.getFinishedSpans()[0].attributes; - expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); - expect(attributes["custom.one"]).toBeUndefined(); - expect(attributes[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBeUndefined(); - - await customProvider.shutdown(); - }); - it("copies generic and registered custom baggage for provisional chain spans from supported scopes", () => { const span = startSpan(provider, { tracerName: "microsoft-otel-openai-agents", diff --git a/test/internal/unit/main.test.ts b/test/internal/unit/main.test.ts index 9bcfa6c8..40221361 100644 --- a/test/internal/unit/main.test.ts +++ b/test/internal/unit/main.test.ts @@ -1211,39 +1211,37 @@ describe("Main functions", () => { await shutdownMicrosoftOpenTelemetry(); }); - it.each(["custom-openai-scope", " custom-openai-scope ", ""])( - "passes configured OpenAI tracer name %j to A365SpanProcessor unchanged", - async (tracerName) => { - useMicrosoftOpenTelemetry({ - azureMonitor: { enabled: false }, - enableConsoleExporters: false, - a365: { - enabled: true, - tokenResolver: () => "token", - }, - instrumentationOptions: { - openaiAgents: { - enabled: false, - tracerName, - }, - langchain: { enabled: false }, + it("does not register configured OpenAI tracer names as A365 fallback scopes", async () => { + const tracerName = "custom-openai-scope"; + useMicrosoftOpenTelemetry({ + azureMonitor: { enabled: false }, + enableConsoleExporters: false, + a365: { + enabled: true, + tokenResolver: () => "token", + }, + instrumentationOptions: { + openaiAgents: { + enabled: false, + tracerName, }, - }); + langchain: { enabled: false }, + }, + }); - const internalSdk = _getSdkInstance(); - const tracerProvider = (internalSdk as any)["_tracerProvider"]; - const registeredProcessors = - tracerProvider?.["_activeSpanProcessor"]?.["_spanProcessors"] || []; - const processor = registeredProcessors.find( - (candidate: any) => candidate.constructor?.name === "A365SpanProcessor", - ); + const internalSdk = _getSdkInstance(); + const tracerProvider = (internalSdk as any)["_tracerProvider"]; + const registeredProcessors = + tracerProvider?.["_activeSpanProcessor"]?.["_spanProcessors"] || []; + const processor = registeredProcessors.find( + (candidate: any) => candidate.constructor?.name === "A365SpanProcessor", + ); - assert.isDefined(processor); - assert.isTrue(processor["genAiInstrumentationScopeNames"].has(tracerName)); + assert.isDefined(processor); + assert.isFalse(processor["genAiInstrumentationScopeNames"].has(tracerName)); - await shutdownMicrosoftOpenTelemetry(); - }, - ); + await shutdownMicrosoftOpenTelemetry(); + }); it("registers A365SpanProcessor but not Agent365Exporter when a365.enableObservabilityExporter is false (default)", async () => { useMicrosoftOpenTelemetry({ From aae5e60794dee29df09c0985bbacbbe6c32db170 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 22 Sep 2026 13:39:44 -0600 Subject: [PATCH 22/24] fix(a365): scope invoke server baggage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a44c89d4-df14-4be5-8c3c-938d09824bc9 --- src/a365/processors/util.ts | 3 +++ .../unit/a365/a365SpanProcessor.test.ts | 24 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/a365/processors/util.ts b/src/a365/processors/util.ts index 60774737..a67ab761 100644 --- a/src/a365/processors/util.ts +++ b/src/a365/processors/util.ts @@ -53,4 +53,7 @@ export const INVOKE_AGENT_ATTRIBUTES: readonly string[] = [ consts.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, consts.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, consts.GEN_AI_CALLER_AGENT_VERSION_KEY, + // Server address/port for invoke agent target + consts.SERVER_ADDRESS_KEY, + consts.SERVER_PORT_KEY, ]; diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts index 0299eddb..e7c82a8f 100644 --- a/test/internal/unit/a365/a365SpanProcessor.test.ts +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -259,7 +259,13 @@ describe("A365SpanProcessor", () => { baggage: { [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", - [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, + [OpenTelemetryConstants.SERVER_ADDRESS_KEY]: "agent.example.com", + [OpenTelemetryConstants.SERVER_PORT_KEY]: "8443", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: [ + OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, + OpenTelemetryConstants.SERVER_ADDRESS_KEY, + OpenTelemetryConstants.SERVER_PORT_KEY, + ].join(","), }, }); span.end(); @@ -267,9 +273,23 @@ describe("A365SpanProcessor", () => { const attributes = memoryExporter.getFinishedSpans()[0].attributes; expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + expect(attributes[OpenTelemetryConstants.SERVER_ADDRESS_KEY]).toBeUndefined(); + expect(attributes[OpenTelemetryConstants.SERVER_PORT_KEY]).toBeUndefined(); }, ); + it("copies invoke-agent server baggage only onto invoke_agent spans", () => { + const span = startGenAiSpan(provider, OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, { + [OpenTelemetryConstants.SERVER_ADDRESS_KEY]: "agent.example.com", + [OpenTelemetryConstants.SERVER_PORT_KEY]: "8443", + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.SERVER_ADDRESS_KEY]).toBe("agent.example.com"); + expect(attributes[OpenTelemetryConstants.SERVER_PORT_KEY]).toBe("8443"); + }); + it("copies generic and registered custom baggage for provisional chain spans from supported scopes", () => { const span = startSpan(provider, { tracerName: "microsoft-otel-openai-agents", @@ -736,6 +756,8 @@ describe("A365SpanProcessor", () => { expect(INVOKE_AGENT_ATTRIBUTES).toContain( OpenTelemetryConstants.GEN_AI_CALLER_AGENT_VERSION_KEY, ); + expect(INVOKE_AGENT_ATTRIBUTES).toContain(OpenTelemetryConstants.SERVER_ADDRESS_KEY); + expect(INVOKE_AGENT_ATTRIBUTES).toContain(OpenTelemetryConstants.SERVER_PORT_KEY); }); it("should include blueprint ID and agent version in generic attributes", () => { From 6c13e62164a25844edfa27def3c35728d4bf7cfd Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 22 Sep 2026 13:47:38 -0600 Subject: [PATCH 23/24] fix(a365): align invoke server baggage builder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a44c89d4-df14-4be5-8c3c-938d09824bc9 --- src/a365/middleware/BaggageBuilder.ts | 2 -- test/internal/unit/a365/baggageBuilder.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/a365/middleware/BaggageBuilder.ts b/src/a365/middleware/BaggageBuilder.ts index 83efa55f..eb6c552d 100644 --- a/src/a365/middleware/BaggageBuilder.ts +++ b/src/a365/middleware/BaggageBuilder.ts @@ -225,8 +225,6 @@ export class BaggageBuilder { this.set(OpenTelemetryConstants.SERVER_ADDRESS_KEY, address); if (port !== undefined && port !== 443) { this.set(OpenTelemetryConstants.SERVER_PORT_KEY, port.toString()); - } else { - this.pairs.delete(OpenTelemetryConstants.SERVER_PORT_KEY); } return this; } diff --git a/test/internal/unit/a365/baggageBuilder.test.ts b/test/internal/unit/a365/baggageBuilder.test.ts index 02d0c09c..d281569c 100644 --- a/test/internal/unit/a365/baggageBuilder.test.ts +++ b/test/internal/unit/a365/baggageBuilder.test.ts @@ -366,7 +366,7 @@ describe("BaggageBuilder", () => { }, ); - it("should clear previously set non-443 port when port is 443", () => { + it("should preserve a previously set port when a later call uses the default port", () => { const builder = new BaggageBuilder(); builder.invokeAgentServer("api.example.com", 8080); builder.invokeAgentServer("api.example.com", 443); @@ -376,7 +376,7 @@ describe("BaggageBuilder", () => { expect(bag?.getEntry(OpenTelemetryConstants.SERVER_ADDRESS_KEY)?.value).toBe( "api.example.com", ); - expect(bag?.getEntry(OpenTelemetryConstants.SERVER_PORT_KEY)).toBeUndefined(); + expect(bag?.getEntry(OpenTelemetryConstants.SERVER_PORT_KEY)?.value).toBe("8080"); }); it("should return self for method chaining", () => { From b3dfdaed687f727607861104c3d5a5a852112ece Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 22 Sep 2026 15:21:55 -0600 Subject: [PATCH 24/24] fix(a365): validate invoke server baggage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a44c89d4-df14-4be5-8c3c-938d09824bc9 --- src/a365/middleware/BaggageBuilder.ts | 2 ++ src/a365/processors/A365SpanProcessor.ts | 16 +++++++++++++++- .../internal/unit/a365/a365SpanProcessor.test.ts | 15 ++++++++++++++- test/internal/unit/a365/baggageBuilder.test.ts | 10 +++++----- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/a365/middleware/BaggageBuilder.ts b/src/a365/middleware/BaggageBuilder.ts index eb6c552d..83efa55f 100644 --- a/src/a365/middleware/BaggageBuilder.ts +++ b/src/a365/middleware/BaggageBuilder.ts @@ -225,6 +225,8 @@ export class BaggageBuilder { this.set(OpenTelemetryConstants.SERVER_ADDRESS_KEY, address); if (port !== undefined && port !== 443) { this.set(OpenTelemetryConstants.SERVER_PORT_KEY, port.toString()); + } else { + this.pairs.delete(OpenTelemetryConstants.SERVER_PORT_KEY); } return this; } diff --git a/src/a365/processors/A365SpanProcessor.ts b/src/a365/processors/A365SpanProcessor.ts index 70f95bda..0da2f41e 100644 --- a/src/a365/processors/A365SpanProcessor.ts +++ b/src/a365/processors/A365SpanProcessor.ts @@ -57,6 +57,15 @@ function shouldCopyRegisteredCustomKey(key: string, isInvokeAgent: boolean): boo return isInvokeAgent || !INVOKE_AGENT_ATTRIBUTE_NAMES.has(key); } +function getSpanAttributeValue(key: string, value: string): string | number | undefined { + if (key !== OpenTelemetryConstants.SERVER_PORT_KEY) { + return value; + } + + const port = Number(value); + return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : undefined; +} + /** * Copies relevant baggage entries to span attributes on span start. * @@ -172,8 +181,13 @@ export class A365SpanProcessor implements BaseSpanProcessor { continue; } + const attributeValue = getSpanAttributeValue(key, value); + if (attributeValue === undefined) { + continue; + } + try { - span.setAttribute(key, value); + span.setAttribute(key, attributeValue); } catch { // Ignore errors setting attributes } diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts index e7c82a8f..d846cc31 100644 --- a/test/internal/unit/a365/a365SpanProcessor.test.ts +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -287,9 +287,22 @@ describe("A365SpanProcessor", () => { const attributes = memoryExporter.getFinishedSpans()[0].attributes; expect(attributes[OpenTelemetryConstants.SERVER_ADDRESS_KEY]).toBe("agent.example.com"); - expect(attributes[OpenTelemetryConstants.SERVER_PORT_KEY]).toBe("8443"); + expect(attributes[OpenTelemetryConstants.SERVER_PORT_KEY]).toBe(8443); }); + it.each(["not-a-port", "8443.5", "0", "65536"])( + "does not copy invalid invoke-agent server port baggage %s", + (port) => { + const span = startGenAiSpan(provider, OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, { + [OpenTelemetryConstants.SERVER_PORT_KEY]: port, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.SERVER_PORT_KEY]).toBeUndefined(); + }, + ); + it("copies generic and registered custom baggage for provisional chain spans from supported scopes", () => { const span = startSpan(provider, { tracerName: "microsoft-otel-openai-agents", diff --git a/test/internal/unit/a365/baggageBuilder.test.ts b/test/internal/unit/a365/baggageBuilder.test.ts index d281569c..24b04bc1 100644 --- a/test/internal/unit/a365/baggageBuilder.test.ts +++ b/test/internal/unit/a365/baggageBuilder.test.ts @@ -366,17 +366,17 @@ describe("BaggageBuilder", () => { }, ); - it("should preserve a previously set port when a later call uses the default port", () => { + it("should clear a previously set port when a later call uses the default port", () => { const builder = new BaggageBuilder(); - builder.invokeAgentServer("api.example.com", 8080); - builder.invokeAgentServer("api.example.com", 443); + builder.invokeAgentServer("old.example.com", 8080); + builder.invokeAgentServer("new.example.com", 443); const scope = builder.build(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const bag = propagation.getBaggage((scope as any).contextWithBaggage); expect(bag?.getEntry(OpenTelemetryConstants.SERVER_ADDRESS_KEY)?.value).toBe( - "api.example.com", + "new.example.com", ); - expect(bag?.getEntry(OpenTelemetryConstants.SERVER_PORT_KEY)?.value).toBe("8080"); + expect(bag?.getEntry(OpenTelemetryConstants.SERVER_PORT_KEY)).toBeUndefined(); }); it("should return self for method chaining", () => {