diff --git a/CHANGELOG.md b/CHANGELOG.md index 1229eab..acb4251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Bugs Fixed +- A365: centralize shared request attributes in the base scope, including session and conversation IDs, channel details, and `operationSource` as `service.name`, while retaining last-write-wins `recordAttributes()` behavior. [#243](https://github.com/microsoft/opentelemetry-distro-javascript/pull/243) - Default `InvokeAgentScope` spans to `SpanKind.INTERNAL` while preserving explicit span-kind overrides. [#241](https://github.com/microsoft/opentelemetry-distro-javascript/pull/241) ### Features Added diff --git a/src/a365/contracts.ts b/src/a365/contracts.ts index 5867ee4..d2382a1 100644 --- a/src/a365/contracts.ts +++ b/src/a365/contracts.ts @@ -306,6 +306,8 @@ export interface Channel { export interface Request { /** The input content (prompt / messages) for the request. */ content?: InputMessagesParam; + /** Name of the service or framework that initiated the operation. */ + operationSource?: string; /** Identifier of the session this request belongs to. */ sessionId?: string; /** The channel the request originated from. */ diff --git a/src/a365/scopes/ApplyGuardrailScope.ts b/src/a365/scopes/ApplyGuardrailScope.ts index 0de1e22..9c5e058 100644 --- a/src/a365/scopes/ApplyGuardrailScope.ts +++ b/src/a365/scopes/ApplyGuardrailScope.ts @@ -69,6 +69,7 @@ export class ApplyGuardrailScope extends OpenTelemetryScope { agentDetails, resolvedSpanDetails, userDetails, + request, ); // Required attributes @@ -119,11 +120,6 @@ export class ApplyGuardrailScope extends OpenTelemetryScope { if (typeof request.content === "string") { this.setTagMaybe(OpenTelemetryConstants.SECURITY_CONTENT_INPUT_VALUE_KEY, request.content); } - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - if (request.channel) { - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel.description); - } } } diff --git a/src/a365/scopes/ExecuteToolScope.ts b/src/a365/scopes/ExecuteToolScope.ts index b33d3c5..5774e23 100644 --- a/src/a365/scopes/ExecuteToolScope.ts +++ b/src/a365/scopes/ExecuteToolScope.ts @@ -59,6 +59,7 @@ export class ExecuteToolScope extends OpenTelemetryScope { agentDetails, resolvedSpanDetails, userDetails, + request, ); const { toolName, arguments: args, toolCallId, description, toolType, endpoint } = details; @@ -74,11 +75,6 @@ export class ExecuteToolScope extends OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_CALL_ID_KEY, toolCallId); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_DESCRIPTION_KEY, description); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request.sessionId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); - if (endpoint) { this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, endpoint.host); if (endpoint.port && endpoint.port !== 443) { diff --git a/src/a365/scopes/InferenceScope.ts b/src/a365/scopes/InferenceScope.ts index a53d7c5..b75ec5e 100644 --- a/src/a365/scopes/InferenceScope.ts +++ b/src/a365/scopes/InferenceScope.ts @@ -57,6 +57,7 @@ export class InferenceScope extends OpenTelemetryScope { agentDetails, resolvedSpanDetails, userDetails, + request, ); // Core inference information @@ -73,12 +74,6 @@ export class InferenceScope extends OpenTelemetryScope { details.thoughtProcess, ); - // Conversation and channel - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request.sessionId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); - // Endpoint if (details.endpoint) { this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, details.endpoint.host); diff --git a/src/a365/scopes/InvokeAgentScope.ts b/src/a365/scopes/InvokeAgentScope.ts index 528b412..6048487 100644 --- a/src/a365/scopes/InvokeAgentScope.ts +++ b/src/a365/scopes/InvokeAgentScope.ts @@ -71,11 +71,9 @@ export class InvokeAgentScope extends OpenTelemetryScope { agentDetails, resolvedSpanDetails, callerDetails?.userDetails, + request, ); - // Session ID - this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request.sessionId); - // Endpoint if (invokeScopeDetails.endpoint) { this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, invokeScopeDetails.endpoint.host); @@ -84,15 +82,6 @@ export class InvokeAgentScope extends OpenTelemetryScope { } } - // Channel - if (request.channel) { - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel.description); - } - - // Conversation ID - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - // Request content as input messages if (request.content != null) { this.recordInputMessages(request.content); diff --git a/src/a365/scopes/OpenTelemetryScope.ts b/src/a365/scopes/OpenTelemetryScope.ts index 80cf2ce..98081c7 100644 --- a/src/a365/scopes/OpenTelemetryScope.ts +++ b/src/a365/scopes/OpenTelemetryScope.ts @@ -14,6 +14,7 @@ import type { AgentDetails, UserDetails, SpanDetails, + Request, InputMessagesParam, OutputMessagesParam, } from "../contracts.js"; @@ -59,6 +60,7 @@ export abstract class OpenTelemetryScope { * @param agentDetails Optional agent details. Tenant ID is read from `agentDetails.tenantId`. * @param spanDetails Optional span configuration including parent context, start/end times, span kind, and span links. * @param userDetails Optional human caller identity details. + * @param request Optional request context shared by all scope types. */ protected constructor( operationName: string, @@ -66,6 +68,7 @@ export abstract class OpenTelemetryScope { agentDetails?: AgentDetails, spanDetails?: SpanDetails, userDetails?: UserDetails, + request?: Request, ) { const parentContext = spanDetails?.parentContext; const startTime = spanDetails?.startTime; @@ -101,6 +104,13 @@ export abstract class OpenTelemetryScope { } this.customEndTime = endTime; + // Set shared request context + this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request?.sessionId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request?.conversationId); + this.setTagMaybe(OpenTelemetryConstants.SERVICE_NAME_KEY, request?.operationSource); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request?.channel?.name); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request?.channel?.description); + // Set agent details if (agentDetails) { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, agentDetails.agentId); @@ -165,7 +175,7 @@ export abstract class OpenTelemetryScope { this.span.recordException(error); } - /** Records multiple attribute key/value pairs. */ + /** Records multiple attribute key/value pairs using last-write-wins semantics. */ public recordAttributes( attributes: Iterable<[string, AttributeValue]> | Record | null | undefined, @@ -173,17 +183,9 @@ export abstract class OpenTelemetryScope { if (!attributes) return; if (Symbol.iterator in Object(attributes) && typeof attributes !== "string") { - for (const [key, value] of attributes as Iterable<[string, AttributeValue]>) { - if (key && typeof key === "string" && key.trim()) { - this.span.setAttribute(key, value); - } - } + this.recordAttributeEntries(attributes as Iterable<[string, AttributeValue]>); } else if (typeof attributes === "object") { - for (const key of Object.keys(attributes as Record)) { - if (key && key.trim()) { - this.span.setAttribute(key, (attributes as Record)[key]); - } - } + this.recordAttributeEntries(Object.entries(attributes as Record)); } } @@ -211,6 +213,20 @@ export abstract class OpenTelemetryScope { } } + private recordAttributeEntries(attributes: Iterable<[string, AttributeValue]>): void { + for (const [key, value] of attributes) { + if (!OpenTelemetryScope.isNonBlankAttributeKey(key)) { + continue; + } + + this.span.setAttribute(key, value); + } + } + + private static isNonBlankAttributeKey(key: string): boolean { + return typeof key === "string" && key.trim().length > 0; + } + /** * Adds an event to the current span. * @param name The event name diff --git a/src/a365/scopes/OutputScope.ts b/src/a365/scopes/OutputScope.ts index 296e5d4..2561228 100644 --- a/src/a365/scopes/OutputScope.ts +++ b/src/a365/scopes/OutputScope.ts @@ -60,15 +60,10 @@ export class OutputScope extends OpenTelemetryScope { agentDetails, resolvedSpanDetails, userDetails, + request, ); - // Set initial output messages this._setOutput(response.messages); - - // Conversation and channel - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); } /** diff --git a/test/internal/unit/a365/applyGuardrailScope.test.ts b/test/internal/unit/a365/applyGuardrailScope.test.ts index 39670b2..a0337a7 100644 --- a/test/internal/unit/a365/applyGuardrailScope.test.ts +++ b/test/internal/unit/a365/applyGuardrailScope.test.ts @@ -104,6 +104,33 @@ describe("ApplyGuardrailScope", () => { expect(getFinishedSpan().kind).toBe(SpanKind.CLIENT); }); + it("should write shared request context attributes", () => { + const scope = ApplyGuardrailScope.start( + { targetType: GuardrailTargetType.LlmInput, decisionType: GuardrailDecisionType.Allow }, + testAgentDetails, + { + content: "Guard this prompt", + conversationId: "guardrail-conversation", + sessionId: "guardrail-session", + operationSource: "guardrail-host", + channel: { name: "Teams", description: "https://teams.example" }, + }, + ); + scope.dispose(); + + const attributes = getFinishedSpan().attributes; + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe( + "guardrail-conversation", + ); + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("guardrail-session"); + expect(attributes[OpenTelemetryConstants.SERVICE_NAME_KEY]).toBe("guardrail-host"); + expect(attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("Teams"); + expect(attributes[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe("https://teams.example"); + expect(attributes[OpenTelemetryConstants.SECURITY_CONTENT_INPUT_VALUE_KEY]).toBe( + "Guard this prompt", + ); + }); + it("should throw when agentDetails.tenantId is missing", () => { expect(() => ApplyGuardrailScope.start( diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts index 15e4ecf..9b0ccd4 100644 --- a/test/internal/unit/a365/scopes.test.ts +++ b/test/internal/unit/a365/scopes.test.ts @@ -1200,6 +1200,34 @@ describe("Request content and message serialization (span attributes)", () => { }); }); + describe("shared request context span attributes", () => { + it("should write request.operationSource to service.name", () => { + const scope = InvokeAgentScope.start( + { ...testRequest, operationSource: "agent-framework" }, + {}, + testAgentDetails, + ); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.SERVICE_NAME_KEY]).toBe( + "agent-framework", + ); + }); + + it("should write request.sessionId on output spans", () => { + const scope = OutputScope.start( + { ...testRequest, sessionId: "session-output-123" }, + { messages: "Hello" }, + testAgentDetails, + ); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe( + "session-output-123", + ); + }); + }); + describe("InvokeAgentScope – GenAI request and response parameters", () => { it("should record all request attributes and response-at-start attributes", () => { const requestParameters: GenAiRequestParameters = { @@ -1761,6 +1789,143 @@ describe("Request content and message serialization (span attributes)", () => { }); }); +describe("recordAttributes last-write-wins behavior", () => { + const testAgentDetails: AgentDetails = { + agentId: "test-agent", + agentName: "Test Agent", + tenantId: "test-tenant-456", + }; + + beforeEach(() => { + sharedExporter.reset(); + }); + + const getLastSpan = (): ReadableSpan => { + const spans = sharedExporter.getFinishedSpans(); + expect(spans.length).toBeGreaterThanOrEqual(1); + return spans[spans.length - 1]; + }; + + it("should overwrite builder-populated attributes when recordAttributes sees the same keys", () => { + const scope = InvokeAgentScope.start( + { + conversationId: "conv-owned", + sessionId: "session-owned", + operationSource: "service-owned", + channel: { name: "Teams", description: "https://teams.example" }, + }, + {}, + testAgentDetails, + ); + + scope.recordAttributes({ + [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]: "Override Agent", + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]: "override-conv", + [OpenTelemetryConstants.SESSION_ID_KEY]: "override-session", + [OpenTelemetryConstants.SERVICE_NAME_KEY]: "override-service", + "custom.attribute": "custom value", + }); + scope.dispose(); + + const attributes = getLastSpan().attributes; + expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe("Override Agent"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("override-conv"); + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("override-session"); + expect(attributes[OpenTelemetryConstants.SERVICE_NAME_KEY]).toBe("override-service"); + expect(attributes["custom.attribute"]).toBe("custom value"); + }); + + it("should overwrite the span builder operation name", () => { + const scope = ExecuteToolScope.start( + { conversationId: "conv-op-name" }, + { toolName: "search" }, + testAgentDetails, + ); + + scope.recordAttributes({ + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: + OpenTelemetryConstants.CHAT_OPERATION_NAME, + }); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe( + OpenTelemetryConstants.CHAT_OPERATION_NAME, + ); + }); + + it("should accept known keys that were absent when the scope was created", () => { + const scope = InferenceScope.start( + { conversationId: "conv-late-known", channel: { name: "Teams" } }, + { operationName: InferenceOperationType.CHAT, model: "gpt-4" }, + testAgentDetails, + ); + + scope.recordAttributes({ + [OpenTelemetryConstants.CHANNEL_LINK_KEY]: "https://teams.example/deep-link", + }); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe( + "https://teams.example/deep-link", + ); + }); + + it("should let a generic write overwrite a late typed setter", () => { + const scope = InferenceScope.start( + { conversationId: "conv-late-owned" }, + { operationName: InferenceOperationType.CHAT, model: "gpt-4" }, + testAgentDetails, + ); + + scope.recordAttributes({ + [OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY]: 10, + }); + scope.recordInputTokens(20); + scope.recordAttributes({ + [OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY]: 30, + }); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY]).toBe(30); + }); + + it("should keep custom recordAttributes keys last-write-wins across repeated calls", () => { + const scope = ExecuteToolScope.start( + { conversationId: "conv-custom-repeat" }, + { toolName: "search" }, + testAgentDetails, + ); + + scope.recordAttributes({ "custom.repeat": "first" }); + scope.recordAttributes({ "custom.repeat": "second" }); + scope.dispose(); + + expect(getLastSpan().attributes["custom.repeat"]).toBe("second"); + }); + + it("should support iterable attributes while skipping blank keys", () => { + const scope = InvokeAgentScope.start( + { conversationId: "conv-iterable", channel: { name: "Teams" } }, + {}, + testAgentDetails, + ); + + scope.recordAttributes([ + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, "override-conv"], + ["", "ignored"], + [" ", "also ignored"], + ["custom.iterable", 42], + ]); + scope.dispose(); + + const attributes = getLastSpan().attributes; + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("override-conv"); + expect(attributes["custom.iterable"]).toBe(42); + expect(attributes[""]).toBeUndefined(); + expect(attributes[" "]).toBeUndefined(); + }); +}); + // Validate attribute key constant values use the new schema namespace. describe("Attribute key schema values", () => { it("caller keys use user.* / client.* namespace", () => { diff --git a/test/internal/unit/a365/scopesAfterDistroInit.test.ts b/test/internal/unit/a365/scopesAfterDistroInit.test.ts index ca8f064..a553062 100644 --- a/test/internal/unit/a365/scopesAfterDistroInit.test.ts +++ b/test/internal/unit/a365/scopesAfterDistroInit.test.ts @@ -14,7 +14,7 @@ * span-creation time instead of caching it in a static field. */ import { describe, it, expect, afterEach } from "vitest"; -import { trace, context as otelContext } from "@opentelemetry/api"; +import { trace, context as otelContext, propagation } from "@opentelemetry/api"; import { InMemorySpanExporter, SimpleSpanProcessor, @@ -29,6 +29,8 @@ import { InferenceScope, ExecuteToolScope, OutputScope, + A365SpanProcessor, + OpenTelemetryConstants, InferenceOperationType, } from "../../../../src/a365/index.js"; import type { AgentDetails } from "../../../../src/a365/index.js"; @@ -50,7 +52,7 @@ function simulateDistroInit(): InMemorySpanExporter { // ── Step 2: Register a new provider (like NodeSDK.start()) ────── const exporter = new InMemorySpanExporter(); const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], + spanProcessors: [new A365SpanProcessor(), new SimpleSpanProcessor(exporter)], }); const contextManager = new AsyncLocalStorageContextManager(); @@ -61,6 +63,14 @@ function simulateDistroInit(): InMemorySpanExporter { return exporter; } +function withBaggage(entries: Record, callback: () => void): void { + let baggage = propagation.createBaggage(); + for (const [key, value] of Object.entries(entries)) { + baggage = baggage.setEntry(key, { value }); + } + otelContext.with(propagation.setBaggage(otelContext.active(), baggage), callback); +} + describe("A365 scopes after distro global-state reset", () => { const agentDetails: AgentDetails = { agentId: "test-agent", @@ -135,6 +145,69 @@ describe("A365 scopes after distro global-state reset", () => { expect(spans.length).toBe(1); }); + it("explicit request context should override baggage attributes", () => { + const exporter = simulateDistroInit(); + + withBaggage( + { + [OpenTelemetryConstants.SESSION_ID_KEY]: "baggage-session", + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]: "baggage-conversation", + [OpenTelemetryConstants.SERVICE_NAME_KEY]: "baggage-service", + [OpenTelemetryConstants.CHANNEL_NAME_KEY]: "Baggage Channel", + [OpenTelemetryConstants.CHANNEL_LINK_KEY]: "https://baggage.example", + }, + () => { + const scope = InvokeAgentScope.start( + { + sessionId: "request-session", + conversationId: "request-conversation", + operationSource: "request-service", + channel: { name: "Request Channel", description: "https://request.example" }, + }, + {}, + agentDetails, + ); + scope.dispose(); + }, + ); + + const attributes = exporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("request-session"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe( + "request-conversation", + ); + expect(attributes[OpenTelemetryConstants.SERVICE_NAME_KEY]).toBe("request-service"); + expect(attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("Request Channel"); + expect(attributes[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe("https://request.example"); + }); + + it("absent request context should preserve baggage attributes", () => { + const exporter = simulateDistroInit(); + + withBaggage( + { + [OpenTelemetryConstants.SESSION_ID_KEY]: "baggage-session", + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]: "baggage-conversation", + [OpenTelemetryConstants.SERVICE_NAME_KEY]: "baggage-service", + [OpenTelemetryConstants.CHANNEL_NAME_KEY]: "Baggage Channel", + [OpenTelemetryConstants.CHANNEL_LINK_KEY]: "https://baggage.example", + }, + () => { + const scope = InvokeAgentScope.start({}, {}, agentDetails); + scope.dispose(); + }, + ); + + const attributes = exporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("baggage-session"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe( + "baggage-conversation", + ); + expect(attributes[OpenTelemetryConstants.SERVICE_NAME_KEY]).toBe("baggage-service"); + expect(attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("Baggage Channel"); + expect(attributes[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe("https://baggage.example"); + }); + it("ConsoleSpanExporter scenario: spans reach user-provided processors", () => { const exporter = simulateDistroInit();