diff --git a/src/a365/exporter/utils.ts b/src/a365/exporter/utils.ts index 01d94db..6c750fa 100644 --- a/src/a365/exporter/utils.ts +++ b/src/a365/exporter/utils.ts @@ -31,7 +31,7 @@ const MESSAGE_ROLE_SYSTEM = "system"; * Known genAI operation names produced by the SDK scopes and auto-instrumentation. * Only spans whose gen_ai.operation.name matches one of these values are exported. */ -const GEN_AI_OPERATION_NAMES: ReadonlySet = new Set([ +export const GEN_AI_OPERATION_NAMES: ReadonlySet = new Set([ GEN_AI_OPERATION_INVOKE_AGENT, // 'invoke_agent' GEN_AI_OPERATION_EXECUTE_TOOL, // 'execute_tool' GEN_AI_OPERATION_OUTPUT_MESSAGES, // 'output_messages' diff --git a/src/a365/processors/A365SpanProcessor.ts b/src/a365/processors/A365SpanProcessor.ts index abdd16c..792b4c8 100644 --- a/src/a365/processors/A365SpanProcessor.ts +++ b/src/a365/processors/A365SpanProcessor.ts @@ -18,6 +18,7 @@ import type { ReadableSpan, } from "@opentelemetry/sdk-trace-base"; import { OpenTelemetryConstants } from "../constants.js"; +import { GEN_AI_OPERATION_NAMES } from "../exporter/utils.js"; import { GENERIC_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES } from "./util.js"; /** @@ -31,6 +32,9 @@ export class A365SpanProcessor implements BaseSpanProcessor { /** * Called when a span is started. * Copies relevant baggage entries to span attributes. + * Only GenAI spans are processed (those with a known `gen_ai.operation.name` + * span attribute: invoke_agent, execute_tool, chat, output_messages); + * all other spans pass through unmodified. */ onStart(span: Span, parentContext?: Context): void { const ctx = parentContext; @@ -56,6 +60,16 @@ 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). + // 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)) { + return; + } + const baggageMap = new Map(); baggage.getAllEntries().forEach(([key, entry]) => { if (entry.value) { @@ -64,15 +78,10 @@ export class A365SpanProcessor implements BaseSpanProcessor { }); // Determine if this is an invoke_agent operation - const operationName = - baggageMap.get(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY) || - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (span as any).attributes?.[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any const spanName = (span as any).name || ""; const isInvokeAgent = - operationName === OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME || + operationNameAttr === OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME || spanName.startsWith(OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME); // Build target key set diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts index fd0892a..f64d3b8 100644 --- a/test/internal/unit/a365/a365SpanProcessor.test.ts +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -16,6 +16,42 @@ import { INVOKE_AGENT_ATTRIBUTES, } from "../../../../src/a365/index.js"; +/** + * Helper: creates a baggage instance with the given entries. + */ +function createBaggage(entries: Record) { + let baggage = propagation.createBaggage(); + for (const [key, value] of Object.entries(entries)) { + baggage = baggage.setEntry(key, { value }); + } + return baggage; +} + +/** + * Helper: starts a GenAI span with `gen_ai.operation.name` as a span attribute + * and the given baggage entries in context. + */ +function startGenAiSpan( + provider: BasicTracerProvider, + operationName: string, + baggage: Record = {}, + spanName?: string, +) { + const bag = createBaggage(baggage); + const ctx = propagation.setBaggage(context.active(), bag); + const tracer = provider.getTracer("test"); + return tracer.startSpan( + spanName ?? `${operationName} span`, + { + kind: SpanKind.CLIENT, + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: operationName, + }, + }, + ctx, + ); +} + describe("A365SpanProcessor", () => { let provider: BasicTracerProvider; let processor: A365SpanProcessor; @@ -33,8 +69,8 @@ describe("A365SpanProcessor", () => { await provider.shutdown(); }); - describe("baggage to span attribute enrichment", () => { - it("should copy generic attributes from baggage to span", () => { + describe("GenAI span filtering", () => { + it("should not mutate spans without gen_ai.operation.name", () => { const baggageEntries = { [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: "agent-789", @@ -48,69 +84,179 @@ describe("A365SpanProcessor", () => { const ctx = propagation.setBaggage(context.active(), baggage); const tracer = provider.getTracer("test"); - const testSpan = tracer.startSpan("test-span", { kind: SpanKind.CLIENT }, ctx); + const testSpan = tracer.startSpan("HTTP GET /api/data", { kind: SpanKind.CLIENT }, ctx); testSpan.end(); const spans = memoryExporter.getFinishedSpans(); expect(spans).toHaveLength(1); const attrs = spans[0].attributes; - expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); - expect(attrs[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe("agent-789"); + expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); + expect(attrs[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBeUndefined(); + expect(attrs[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBeUndefined(); + expect(attrs[OpenTelemetryConstants.SESSION_ID_KEY]).toBeUndefined(); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBeUndefined(); }); - it("should copy sessionId from baggage to span", () => { + it("should not mutate spans when baggage has no gen_ai.operation.name even with other A365 baggage", () => { let baggage = propagation.createBaggage(); + baggage = baggage.setEntry(OpenTelemetryConstants.TENANT_ID_KEY, { value: "tenant-123" }); + baggage = baggage.setEntry(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, { + value: "agent-abc", + }); baggage = baggage.setEntry(OpenTelemetryConstants.SESSION_ID_KEY, { - value: "session-abc", + value: "session-xyz", }); const ctx = propagation.setBaggage(context.active(), baggage); + const tracer = provider.getTracer("test"); - const testSpan = tracer.startSpan("test-span", { kind: SpanKind.CLIENT }, ctx); + const testSpan = tracer.startSpan("db-query", { kind: SpanKind.CLIENT }, ctx); testSpan.end(); const spans = memoryExporter.getFinishedSpans(); expect(spans).toHaveLength(1); const attrs = spans[0].attributes; - expect(attrs[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("session-abc"); + expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); + expect(attrs[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBeUndefined(); + expect(attrs[OpenTelemetryConstants.SESSION_ID_KEY]).toBeUndefined(); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBeUndefined(); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY]).toBeUndefined(); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY]).toBeUndefined(); }); - it("should copy sessionDescription from baggage to span", () => { + it("should process spans that have gen_ai.operation.name as a span attribute", () => { + // Baggage has no gen_ai.operation.name, but the span itself does let baggage = propagation.createBaggage(); - baggage = baggage.setEntry(OpenTelemetryConstants.SESSION_DESCRIPTION_KEY, { - value: "Test session description", - }); + baggage = baggage.setEntry(OpenTelemetryConstants.TENANT_ID_KEY, { value: "tenant-123" }); const ctx = propagation.setBaggage(context.active(), baggage); - const tracer = provider.getTracer("test"); - const testSpan = tracer.startSpan("test-span", { kind: SpanKind.CLIENT }, ctx); + + const tracer = provider.getTracer("microsoft-otel-openai-agents"); + const testSpan = tracer.startSpan( + "invoke_agent test", + { + kind: SpanKind.CLIENT, + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: "invoke_agent", + }, + }, + ctx, + ); testSpan.end(); const spans = memoryExporter.getFinishedSpans(); expect(spans).toHaveLength(1); const attrs = spans[0].attributes; - expect(attrs[OpenTelemetryConstants.SESSION_DESCRIPTION_KEY]).toBe( - "Test session description", + expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBe( + OpenTelemetryConstants.TELEMETRY_SDK_NAME_VALUE, ); }); - it("should copy invoke agent attributes for invoke_agent operations", () => { - const baggageEntries = { - [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: - OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, + it("should process spans from any tracer source when gen_ai.operation.name span attribute is set", () => { + const bag = createBaggage({ [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", - [OpenTelemetryConstants.USER_ID_KEY]: "caller-456", - }; + }); + const ctx = propagation.setBaggage(context.active(), bag); - let baggage = propagation.createBaggage(); - for (const [key, value] of Object.entries(baggageEntries)) { - baggage = baggage.setEntry(key, { value }); - } + // Use a non-A365 tracer name (e.g. LangChain instrumentor) + const tracer = provider.getTracer("microsoft-otel-langchain"); + const testSpan = tracer.startSpan( + "chat span", + { + kind: SpanKind.CLIENT, + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: "chat", + }, + }, + ctx, + ); + testSpan.end(); - const ctx = propagation.setBaggage(context.active(), baggage); + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + const attrs = spans[0].attributes; + expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBe( + OpenTelemetryConstants.TELEMETRY_SDK_NAME_VALUE, + ); + }); + + it("should not mutate spans with an unknown gen_ai.operation.name value", () => { + const bag = createBaggage({ + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + }); + const ctx = propagation.setBaggage(context.active(), bag); const tracer = provider.getTracer("test"); - const testSpan = tracer.startSpan("invoke_agent test", { kind: SpanKind.CLIENT }, ctx); + const testSpan = tracer.startSpan( + "unknown-op span", + { + kind: SpanKind.CLIENT, + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: "unknown_operation", + }, + }, + ctx, + ); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + const attrs = spans[0].attributes; + expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBeUndefined(); + }); + }); + + describe("baggage to span attribute enrichment", () => { + it("should copy generic attributes from baggage to span", () => { + const testSpan = startGenAiSpan(provider, "chat", { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: "agent-789", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + const attrs = spans[0].attributes; + expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attrs[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe("agent-789"); + }); + + it("should copy sessionId from baggage to span", () => { + const testSpan = startGenAiSpan(provider, "chat", { + [OpenTelemetryConstants.SESSION_ID_KEY]: "session-abc", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0].attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("session-abc"); + }); + + it("should copy sessionDescription from baggage to span", () => { + const testSpan = startGenAiSpan(provider, "chat", { + [OpenTelemetryConstants.SESSION_DESCRIPTION_KEY]: "Test session description", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0].attributes[OpenTelemetryConstants.SESSION_DESCRIPTION_KEY]).toBe( + "Test session description", + ); + }); + + it("should copy invoke agent attributes for invoke_agent operations", () => { + const testSpan = startGenAiSpan( + provider, + OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, + { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.USER_ID_KEY]: "caller-456", + }, + ); testSpan.end(); const spans = memoryExporter.getFinishedSpans(); @@ -121,12 +267,10 @@ describe("A365SpanProcessor", () => { }); it("should not overwrite existing span attributes", () => { - let baggage = propagation.createBaggage(); - baggage = baggage.setEntry(OpenTelemetryConstants.TENANT_ID_KEY, { - value: "tenant-from-baggage", + const bag = createBaggage({ + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-from-baggage", }); - - const ctx = propagation.setBaggage(context.active(), baggage); + const ctx = propagation.setBaggage(context.active(), bag); const tracer = provider.getTracer("test"); const testSpan = tracer.startSpan( @@ -134,6 +278,7 @@ describe("A365SpanProcessor", () => { { kind: SpanKind.CLIENT, attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: "chat", [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-existing", }, }, @@ -143,31 +288,35 @@ describe("A365SpanProcessor", () => { const spans = memoryExporter.getFinishedSpans(); expect(spans).toHaveLength(1); - const attrs = spans[0].attributes; - expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-existing"); + expect(spans[0].attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-existing"); }); it("should ignore empty baggage values", () => { - let baggage = propagation.createBaggage(); - baggage = baggage.setEntry(OpenTelemetryConstants.TENANT_ID_KEY, { value: "" }); - - const ctx = propagation.setBaggage(context.active(), baggage); + const bag = createBaggage({ + [OpenTelemetryConstants.TENANT_ID_KEY]: "", + }); + const ctx = propagation.setBaggage(context.active(), bag); const tracer = provider.getTracer("test"); - const testSpan = tracer.startSpan("test-span", { kind: SpanKind.CLIENT }, ctx); + const testSpan = tracer.startSpan( + "test-span", + { + kind: SpanKind.CLIENT, + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: "chat", + }, + }, + ctx, + ); testSpan.end(); const spans = memoryExporter.getFinishedSpans(); expect(spans).toHaveLength(1); - const attrs = spans[0].attributes; - expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); + expect(spans[0].attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); }); - it("should set telemetry SDK attributes", () => { - const baggage = propagation.createBaggage(); - const ctx = propagation.setBaggage(context.active(), baggage); - const tracer = provider.getTracer("test"); - const testSpan = tracer.startSpan("test-span", { kind: SpanKind.CLIENT }, ctx); + it("should set telemetry SDK attributes on GenAI spans", () => { + const testSpan = startGenAiSpan(provider, "chat"); testSpan.end(); const spans = memoryExporter.getFinishedSpans(); @@ -183,6 +332,33 @@ describe("A365SpanProcessor", () => { OpenTelemetryConstants.TELEMETRY_SDK_VERSION_VALUE, ); }); + + it("should enrich all four GenAI operation types", () => { + const operations = [ + OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, + OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, + OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME, + OpenTelemetryConstants.CHAT_OPERATION_NAME, + ]; + + for (const op of operations) { + memoryExporter.reset(); + const span = startGenAiSpan(provider, op, { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: "agent-abc", + }); + span.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + const attrs = spans[0].attributes; + expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attrs[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe("agent-abc"); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBe( + OpenTelemetryConstants.TELEMETRY_SDK_NAME_VALUE, + ); + } + }); }); describe("attribute registry application", () => {