diff --git a/samples/README.md b/samples/README.md index c7bb2d8c..a9182690 100644 --- a/samples/README.md +++ b/samples/README.md @@ -26,6 +26,7 @@ These sample programs show how to use the `@microsoft/opentelemetry` distributio | [openaiInstrumentation.ts][openaiinstrumentation] | Demonstrates how to enable OpenAI Agents SDK instrumentation to trace GenAI operations. | | [a365Export.ts][a365export] | Demonstrates A365 observability export: token resolver setup, dual export with Azure Monitor, and span routing by tenant/agent. | | [a365ManualScopes.ts][a365manualscopes] | Traces a full agent turn with manual scopes (InvokeAgent → Inference → ExecuteTool → Inference → Output) and cross-service context propagation. | +| [a365HostingMiddleware.ts][a365hostingmiddleware] | Demonstrates A365 hosting middleware (BaggageMiddleware, OutputLoggingMiddleware, ObservabilityHostingManager, ScopeUtils). | ## Prerequisites @@ -90,3 +91,4 @@ APPLICATIONINSIGHTS_CONNECTION_STRING="" node dist/basic [openaiinstrumentation]: https://github.com/Azure/opentelemetry-distro-javascript/blob/main/samples/src/openaiInstrumentation.ts [a365export]: https://github.com/Azure/opentelemetry-distro-javascript/blob/main/samples/src/a365Export.ts [a365manualscopes]: https://github.com/Azure/opentelemetry-distro-javascript/blob/main/samples/src/a365ManualScopes.ts +[a365hostingmiddleware]: https://github.com/Azure/opentelemetry-distro-javascript/blob/main/samples/src/a365HostingMiddleware.ts diff --git a/samples/src/a365HostingMiddleware.ts b/samples/src/a365HostingMiddleware.ts new file mode 100644 index 00000000..b1e5ca06 --- /dev/null +++ b/samples/src/a365HostingMiddleware.ts @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * @summary Demonstrates how to use A365 hosting observability middleware. + * + * This sample shows how the hosting package works with an `@microsoft/agents-hosting` + * compatible adapter. The middleware automatically: + * - Extracts baggage (tenant, agent, caller, channel, conversation) from TurnContext + * - Creates OutputScope spans for outgoing messages + * - Links output spans to a parent InvokeAgentScope + * + * Since this sample does not require a running agents-hosting server, it uses + * mock objects that satisfy the TurnContextLike/MiddlewareLike interfaces. + */ + +import { + useMicrosoftOpenTelemetry, + shutdownMicrosoftOpenTelemetry, + + // Hosting middleware + BaggageMiddleware, + OutputLoggingMiddleware, + ObservabilityHostingManager, + A365_PARENT_SPAN_KEY, + A365_AUTH_TOKEN_KEY, + + // Hosting utilities + BaggageBuilderUtils, + ScopeUtils, + BaggageBuilder, + + // Scopes — used to create the parent InvokeAgentScope + InvokeAgentScope, + InferenceScope, + InferenceOperationType, +} from "@microsoft/opentelemetry"; +import type { + TurnContextLike, + ActivityLike, + MiddlewareLike, + AgentDetails, + InferenceDetails, +} from "@microsoft/opentelemetry"; +import "dotenv/config"; + +// ──────────────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────────────── + +async function myTokenResolver(agentId: string, tenantId: string): Promise { + console.log(` [auth] Resolving token for agent=${agentId}, tenant=${tenantId}`); + return process.env.A365_BEARER_TOKEN || ""; +} + +/** + * Creates a mock TurnContext that looks like an incoming agents-hosting request. + * In production, this object is created by the adapter from an incoming Activity. + */ +function createMockTurnContext(): TurnContextLike { + const activity: ActivityLike = { + type: "message", + text: "What's the weather in Seattle?", + channelId: "msteams", + channelIdSubChannel: "general", + serviceUrl: "https://smba.trafficmanager.net/teams/", + from: { + aadObjectId: "user-aad-object-id-123", + name: "Jane Doe", + agenticUserId: "jane@contoso.com", + agenticAppBlueprintId: "caller-blueprint-001", + }, + recipient: { + aadObjectId: "agent-aad-object-id-456", + name: "WeatherBot", + role: "An agent that answers weather questions", + }, + conversation: { + id: "conv-12345", + }, + isAgenticRequest: () => true, + getAgenticInstanceId: () => "weather-agent-001", + getAgenticTenantId: () => "contoso-tenant-id", + getAgenticUser: () => "weatherbot@contoso.com", + }; + + const turnState = new Map(); + const sendHandlers: Array<(ctx: TurnContextLike, activities: ActivityLike[], next: () => Promise) => Promise> = []; + + return { + activity, + turnState, + onSendActivities(handler) { + sendHandlers.push(handler); + }, + // Simulated sendActivity — runs through registered handlers + async sendActivity(text: string): Promise { + const outgoing: ActivityLike[] = [{ type: "message", text }]; + const sendNext = async () => { + console.log(` [adapter] Sending message: "${text}"`); + return [{ id: "msg-001" }]; + }; + // Run through handlers (last registered first, like real adapter) + let chain = sendNext; + for (const h of [...sendHandlers].reverse()) { + const prev = chain; + chain = () => h(this as unknown as TurnContextLike, outgoing, prev as () => Promise); + } + await chain(); + }, + } as TurnContextLike & { sendActivity(text: string): Promise }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Demo 1: ObservabilityHostingManager (simplest setup) +// ──────────────────────────────────────────────────────────────────────────── + +function demoHostingManager(): void { + console.log("\n=== Demo 1: ObservabilityHostingManager ===\n"); + + // In production this would be your real adapter instance + const registeredMiddleware: MiddlewareLike[] = []; + const mockAdapter = { + use(...mws: MiddlewareLike[]) { + registeredMiddleware.push(...mws); + console.log(` [adapter] Registered ${mws.length} middleware(s)`); + }, + }; + + const manager = new ObservabilityHostingManager(); + manager.configure(mockAdapter, { + enableBaggage: true, + enableOutputLogging: true, + }); + + console.log(` Registered middleware count: ${registeredMiddleware.length}`); + console.log(` Types: ${registeredMiddleware.map((m) => m.constructor.name).join(", ")}`); + + // Calling configure again is a no-op + manager.configure(mockAdapter, { enableBaggage: true }); + console.log(" Second configure() call was ignored (as expected)."); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Demo 2: BaggageMiddleware — automatic baggage propagation +// ──────────────────────────────────────────────────────────────────────────── + +async function demoBaggageMiddleware(): Promise { + console.log("\n=== Demo 2: BaggageMiddleware ===\n"); + + const middleware = new BaggageMiddleware(); + const ctx = createMockTurnContext(); + + await middleware.onTurn(ctx, async () => { + // Inside here, baggage is active in the OTel context. + // Any spans created will be enriched by A365SpanProcessor. + console.log(" Inside middleware — baggage is active"); + console.log(" Creating a span that will inherit baggage attributes..."); + + // The BaggageBuilder can also be used manually for additional entries + const builder = new BaggageBuilder().sessionId("session-xyz"); + BaggageBuilderUtils.fromTurnContext(builder, ctx); + + const scope = builder.build(); + await scope.run(async () => { + console.log(" Inside BaggageScope — additional baggage applied"); + }); + }); + + console.log(" Middleware turn completed."); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Demo 3: OutputLoggingMiddleware — auto OutputScope on outgoing messages +// ──────────────────────────────────────────────────────────────────────────── + +async function demoOutputLoggingMiddleware(): Promise { + console.log("\n=== Demo 3: OutputLoggingMiddleware ===\n"); + + const middleware = new OutputLoggingMiddleware(); + const ctx = createMockTurnContext() as TurnContextLike & { sendActivity(text: string): Promise }; + + // Set the auth token so the middleware can derive agent details + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + + // Create an InvokeAgentScope and store its ref in turnState + const invokeScope = InvokeAgentScope.start( + { conversationId: "conv-12345" }, + {}, + { + agentId: "weather-agent-001", + agentName: "WeatherBot", + tenantId: "contoso-tenant-id", + }, + ); + ctx.turnState.set(A365_PARENT_SPAN_KEY, invokeScope.getParentSpanRef()); + console.log(` InvokeAgentScope started (traceId: ${invokeScope.getSpanContext().traceId})`); + + await middleware.onTurn(ctx, async () => { + // Simulate agent processing and sending a response + console.log(" Agent processing request..."); + await ctx.sendActivity("It's 62°F and partly cloudy in Seattle."); + console.log(" OutputLoggingMiddleware created an OutputScope span for the outgoing message."); + }); + + invokeScope.dispose(); + console.log(" InvokeAgentScope ended."); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Demo 4: ScopeUtils — create scopes from TurnContext +// ──────────────────────────────────────────────────────────────────────────── + +async function demoScopeUtils(): Promise { + console.log("\n=== Demo 4: ScopeUtils — scopes from TurnContext ===\n"); + + const ctx = createMockTurnContext(); + const authToken = ""; + + // Derive agent/caller details + const agent = ScopeUtils.deriveAgentDetails(ctx, authToken); + console.log(` Derived agent: ${agent?.agentName} (${agent?.agentId})`); + + const caller = ScopeUtils.deriveCallerDetails(ctx); + console.log(` Derived caller: ${caller?.userName} (${caller?.userId})`); + + const conversationId = ScopeUtils.deriveConversationId(ctx); + console.log(` Derived conversationId: ${conversationId}`); + + const channel = ScopeUtils.deriveChannelObject(ctx); + console.log(` Derived channel: ${channel.name} / ${channel.description}`); + + // Create an InferenceScope directly from TurnContext + const inferenceDetails: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: "gpt-4o", + providerName: "azure-openai", + }; + + const inferenceScope = ScopeUtils.populateInferenceScopeFromTurnContext( + inferenceDetails, + ctx, + authToken, + ); + console.log(` InferenceScope created from TurnContext (traceId: ${inferenceScope.getSpanContext().traceId})`); + console.log(" Input messages from activity.text were auto-recorded."); + + // Simulate response + inferenceScope.recordOutputMessages(["It's 62°F and partly cloudy in Seattle."]); + inferenceScope.recordInputTokens(45); + inferenceScope.recordOutputTokens(12); + inferenceScope.recordFinishReasons(["stop"]); + inferenceScope.dispose(); + + console.log(" InferenceScope completed."); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Demo 5: Full agent turn with middleware +// ──────────────────────────────────────────────────────────────────────────── + +async function demoFullAgentTurn(): Promise { + console.log("\n=== Demo 5: Full Agent Turn with Middleware ===\n"); + + const ctx = createMockTurnContext() as TurnContextLike & { sendActivity(text: string): Promise }; + const authToken = ""; + + // Register middleware + const baggageMiddleware = new BaggageMiddleware(); + const outputMiddleware = new OutputLoggingMiddleware(); + + // Simulate the adapter pipeline: baggage → output → agent logic + await baggageMiddleware.onTurn(ctx, async () => { + console.log(" [baggage] Baggage context active"); + + ctx.turnState.set(A365_AUTH_TOKEN_KEY, authToken); + + await outputMiddleware.onTurn(ctx, async () => { + console.log(" [output] Output logging middleware active"); + + // Agent logic + const agentDetails: AgentDetails = ScopeUtils.buildInvokeAgentDetails( + { agentName: "WeatherBot" } as AgentDetails, + ctx, + authToken, + ); + + const invokeScope = InvokeAgentScope.start( + { conversationId: ctx.activity.conversation?.id }, + {}, + agentDetails, + ); + ctx.turnState.set(A365_PARENT_SPAN_KEY, invokeScope.getParentSpanRef()); + + try { + console.log(` [agent] Processing: "${ctx.activity.text}"`); + + // LLM inference + const inference = ScopeUtils.populateInferenceScopeFromTurnContext( + { operationName: InferenceOperationType.CHAT, model: "gpt-4o", providerName: "azure-openai" }, + ctx, + authToken, + ); + inference.recordOutputMessages(["It's 62°F and partly cloudy."]); + inference.recordFinishReasons(["stop"]); + inference.dispose(); + + // Send response — OutputLoggingMiddleware will create an OutputScope span + await ctx.sendActivity("It's 62°F and partly cloudy in Seattle."); + + invokeScope.recordResponse("It's 62°F and partly cloudy in Seattle."); + } catch (err) { + invokeScope.recordError(err as Error); + throw err; + } finally { + invokeScope.dispose(); + } + }); + }); + + console.log(" Full turn completed."); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Main +// ──────────────────────────────────────────────────────────────────────────── + +async function main(): Promise { + useMicrosoftOpenTelemetry({ + azureMonitor: { + azureMonitorExporterOptions: { + connectionString: + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING || "", + }, + }, + a365: { + enabled: true, + tokenResolver: myTokenResolver, + clusterCategory: "dev", + }, + }); + + console.log("=== A365 Hosting Middleware Demo ==="); + + demoHostingManager(); + await demoBaggageMiddleware(); + await demoOutputLoggingMiddleware(); + await demoScopeUtils(); + await demoFullAgentTurn(); + + console.log("\n=== All demos completed ==="); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + await shutdownMicrosoftOpenTelemetry(); + console.log("Done. Check your telemetry backend for traces."); +} + +main().catch(console.error); diff --git a/src/a365/constants.ts b/src/a365/constants.ts index 7c89fd51..a7cf587e 100644 --- a/src/a365/constants.ts +++ b/src/a365/constants.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { MICROSOFT_OPENTELEMETRY_VERSION } from "../types.js"; + /** * OpenTelemetry constants for A365 observability. * @@ -23,16 +25,37 @@ export class OpenTelemetryConstants { public static readonly AZ_NAMESPACE_KEY = "az.namespace"; public static readonly SERVER_ADDRESS_KEY = "server.address"; public static readonly SERVER_PORT_KEY = "server.port"; + public static readonly AZURE_RP_NAMESPACE_VALUE = "Microsoft.CognitiveServices"; // ── Source / SDK identity ───────────────────────────────────────── public static readonly SOURCE_NAME = "Agent365Sdk"; + // ── Feature flags / env var names ───────────────────────────────── + public static readonly ENABLE_OPENTELEMETRY_SWITCH = "Azure.Experimental.EnableActivitySource"; + public static readonly TRACE_CONTENTS_SWITCH = "Azure.Experimental.TraceGenAIMessageContent"; + public static readonly TRACE_CONTENTS_ENVIRONMENT_VARIABLE = + "AZURE_TRACING_GEN_AI_CONTENT_RECORDING_ENABLED"; + public static readonly ENABLE_OBSERVABILITY = "ENABLE_OBSERVABILITY"; + public static readonly ENABLE_A365_OBSERVABILITY_EXPORTER = "ENABLE_A365_OBSERVABILITY_EXPORTER"; + public static readonly ENABLE_A365_OBSERVABILITY = "ENABLE_A365_OBSERVABILITY"; + public static readonly ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = + "ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT"; + // ── GenAI core attributes ──────────────────────────────────────── public static readonly GEN_AI_OPERATION_NAME_KEY = "gen_ai.operation.name"; public static readonly GEN_AI_REQUEST_MODEL_KEY = "gen_ai.request.model"; public static readonly GEN_AI_RESPONSE_MODEL_KEY = "gen_ai.response.model"; public static readonly GEN_AI_RESPONSE_FINISH_REASONS_KEY = "gen_ai.response.finish_reasons"; public static readonly GEN_AI_PROVIDER_NAME_KEY = "gen_ai.provider.name"; + public static readonly GEN_AI_REQUEST_MAX_TOKENS_KEY = "gen_ai.request.max_tokens"; + public static readonly GEN_AI_REQUEST_TEMPERATURE_KEY = "gen_ai.request.temperature"; + public static readonly GEN_AI_REQUEST_TOP_P_KEY = "gen_ai.request.top_p"; + public static readonly GEN_AI_CHOICE = "gen_ai.choice"; + + // ── GenAI metrics ──────────────────────────────────────────────── + public static readonly GEN_AI_CLIENT_OPERATION_DURATION_METRIC_NAME = + "gen_ai.client.operation.duration"; + public static readonly GEN_AI_CLIENT_TOKEN_USAGE_METRIC_NAME = "gen_ai.client.token.usage"; // ── GenAI usage ────────────────────────────────────────────────── public static readonly GEN_AI_USAGE_INPUT_TOKENS_KEY = "gen_ai.usage.input_tokens"; @@ -53,6 +76,7 @@ export class OpenTelemetryConstants { public static readonly GEN_AI_AGENT_PLATFORM_ID_KEY = "microsoft.a365.agent.platform.id"; public static readonly GEN_AI_AGENT_THOUGHT_PROCESS_KEY = "microsoft.a365.agent.thought.process"; public static readonly GEN_AI_ICON_URI_KEY = "gen_ai.agent365.icon_uri"; + public static readonly AGENT_ID_KEY = "gen_ai.agent.id"; // ── GenAI conversation / session ───────────────────────────────── public static readonly GEN_AI_CONVERSATION_ID_KEY = "gen_ai.conversation.id"; @@ -93,6 +117,10 @@ export class OpenTelemetryConstants { public static readonly GEN_AI_AGENT_EMAIL_KEY = "microsoft.agent.user.email"; public static readonly GEN_AI_AGENT_BLUEPRINT_ID_KEY = "microsoft.a365.agent.blueprint.id"; + // ── Execution context ──────────────────────────────────────────── + public static readonly GEN_AI_TASK_ID_KEY = "gen_ai.task.id"; + public static readonly GEN_AI_EXECUTION_PAYLOAD_KEY = "gen_ai.execution.payload"; + // ── Channel dimensions ─────────────────────────────────────────── public static readonly CHANNEL_NAME_KEY = "microsoft.channel.name"; public static readonly CHANNEL_LINK_KEY = "microsoft.channel.link"; @@ -110,5 +138,5 @@ export class OpenTelemetryConstants { public static readonly TELEMETRY_SDK_VERSION_KEY = "telemetry.sdk.version"; public static readonly TELEMETRY_SDK_NAME_VALUE = "A365ObservabilitySDK"; public static readonly TELEMETRY_SDK_LANGUAGE_VALUE = "nodejs"; - public static readonly TELEMETRY_SDK_VERSION_VALUE = "0.1.0"; + public static readonly TELEMETRY_SDK_VERSION_VALUE = MICROSOFT_OPENTELEMETRY_VERSION; } diff --git a/src/a365/exporter/Agent365Exporter.ts b/src/a365/exporter/Agent365Exporter.ts index e1f4c7ca..f0b65c12 100644 --- a/src/a365/exporter/Agent365Exporter.ts +++ b/src/a365/exporter/Agent365Exporter.ts @@ -129,9 +129,8 @@ export class Agent365Exporter implements SpanExporter { const payload = this.buildExportRequest(spans); const body = JSON.stringify(payload); - const endpointPath = this.options.useS2SEndpoint - ? `/observabilityService/tenants/${encodeURIComponent(tenantId)}/agents/${encodeURIComponent(agentId)}/traces` - : `/observability/tenants/${encodeURIComponent(tenantId)}/agents/${encodeURIComponent(agentId)}/traces`; + const servicePrefix = this.options.useS2SEndpoint ? "/observabilityService" : "/observability"; + const endpointPath = `${servicePrefix}/tenants/${encodeURIComponent(tenantId)}/otlp/agents/${encodeURIComponent(agentId)}/traces`; const baseUrl = this.options.domainOverride ?? resolveAgent365Endpoint(this.options.clusterCategory); diff --git a/src/a365/hosting/baggageBuilderUtils.ts b/src/a365/hosting/baggageBuilderUtils.ts new file mode 100644 index 00000000..cb92fcd5 --- /dev/null +++ b/src/a365/hosting/baggageBuilderUtils.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Utilities to populate BaggageBuilder from a TurnContext. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability-hosting/src/utils/BaggageBuilderUtils.ts + */ + +import { BaggageBuilder } from "../middleware/BaggageBuilder.js"; +import type { TurnContextLike } from "./types.js"; +import { + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getChannelBaggagePairs, + getConversationIdAndItemLinkPairs, +} from "./turnContextUtils.js"; + +/** + * Utilities to populate BaggageBuilder from a TurnContext. + */ +export class BaggageBuilderUtils { + /** + * Populate all supported baggage pairs from the provided TurnContext. + */ + static fromTurnContext(builder: BaggageBuilder, turnContext: TurnContextLike): BaggageBuilder { + if (!turnContext) { + throw new Error("turnContext is required"); + } + this.setCallerBaggage(builder, turnContext); + this.setTargetAgentBaggage(builder, turnContext); + this.setTenantIdBaggage(builder, turnContext); + this.setChannelBaggage(builder, turnContext); + this.setConversationIdBaggage(builder, turnContext); + return builder; + } + + static setCallerBaggage(builder: BaggageBuilder, turnContext: TurnContextLike): BaggageBuilder { + builder.setPairs(getCallerBaggagePairs(turnContext)); + return builder; + } + + static setTargetAgentBaggage( + builder: BaggageBuilder, + turnContext: TurnContextLike, + ): BaggageBuilder { + builder.setPairs(getTargetAgentBaggagePairs(turnContext)); + return builder; + } + + static setTenantIdBaggage(builder: BaggageBuilder, turnContext: TurnContextLike): BaggageBuilder { + builder.setPairs(getTenantIdPair(turnContext)); + return builder; + } + + static setChannelBaggage(builder: BaggageBuilder, turnContext: TurnContextLike): BaggageBuilder { + builder.setPairs(getChannelBaggagePairs(turnContext)); + return builder; + } + + static setConversationIdBaggage( + builder: BaggageBuilder, + turnContext: TurnContextLike, + ): BaggageBuilder { + builder.setPairs(getConversationIdAndItemLinkPairs(turnContext)); + return builder; + } +} diff --git a/src/a365/hosting/baggageMiddleware.ts b/src/a365/hosting/baggageMiddleware.ts new file mode 100644 index 00000000..960890ea --- /dev/null +++ b/src/a365/hosting/baggageMiddleware.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Middleware that propagates OpenTelemetry baggage context derived from TurnContext. + * Async replies (ContinueConversation events) are passed through without baggage setup. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability-hosting/src/middleware/BaggageMiddleware.ts + */ + +import { BaggageBuilder } from "../middleware/BaggageBuilder.js"; +import type { TurnContextLike, MiddlewareLike } from "./types.js"; +import { + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getChannelBaggagePairs, + getConversationIdAndItemLinkPairs, +} from "./turnContextUtils.js"; + +/** + * Middleware that propagates OpenTelemetry baggage context derived from TurnContext. + * Async replies (ContinueConversation) are passed through without baggage setup. + */ +export class BaggageMiddleware implements MiddlewareLike { + async onTurn(context: TurnContextLike, next: () => Promise): Promise { + const isAsyncReply = + context.activity?.type === "event" && context.activity?.name === "ContinueConversation"; + + if (isAsyncReply) { + await next(); + return; + } + + const baggageScope = new BaggageBuilder() + .setPairs(getCallerBaggagePairs(context)) + .setPairs(getTargetAgentBaggagePairs(context)) + .setPairs(getTenantIdPair(context)) + .setPairs(getChannelBaggagePairs(context)) + .setPairs(getConversationIdAndItemLinkPairs(context)) + .build(); + + await baggageScope.run(async () => { + await next(); + }); + } +} diff --git a/src/a365/hosting/index.ts b/src/a365/hosting/index.ts new file mode 100644 index 00000000..8522539c --- /dev/null +++ b/src/a365/hosting/index.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { BaggageBuilderUtils } from "./baggageBuilderUtils.js"; +export { ScopeUtils } from "./scopeUtils.js"; +export { + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getChannelBaggagePairs, + getConversationIdAndItemLinkPairs, + resolveEmbodiedAgentIds, +} from "./turnContextUtils.js"; +export { BaggageMiddleware } from "./baggageMiddleware.js"; +export { + OutputLoggingMiddleware, + A365_PARENT_SPAN_KEY, + A365_AUTH_TOKEN_KEY, +} from "./outputLoggingMiddleware.js"; +export { ObservabilityHostingManager } from "./observabilityHostingManager.js"; +export type { ObservabilityHostingOptions } from "./observabilityHostingManager.js"; +export type { + TurnContextLike, + ActivityLike, + MiddlewareLike, + SendActivitiesHandler, +} from "./types.js"; diff --git a/src/a365/hosting/observabilityHostingManager.ts b/src/a365/hosting/observabilityHostingManager.ts new file mode 100644 index 00000000..aaeab639 --- /dev/null +++ b/src/a365/hosting/observabilityHostingManager.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Manager for configuring hosting-layer observability middleware. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts + */ + +import { Logger } from "../../shared/logging/index.js"; +import { BaggageMiddleware } from "./baggageMiddleware.js"; +import { OutputLoggingMiddleware } from "./outputLoggingMiddleware.js"; +import type { MiddlewareLike } from "./types.js"; + +/** + * Configuration options for the hosting observability layer. + */ +export interface ObservabilityHostingOptions { + /** Enable baggage propagation middleware. Defaults to false. */ + enableBaggage?: boolean; + + /** Enable output logging middleware for tracing outgoing messages. Defaults to false. */ + enableOutputLogging?: boolean; +} + +/** + * Manager for configuring hosting-layer observability middleware. + * + * @example + * ```typescript + * const manager = new ObservabilityHostingManager(); + * manager.configure(adapter, { enableOutputLogging: true }); + * ``` + */ +export class ObservabilityHostingManager { + private _configured = false; + + /** + * Registers observability middleware on the adapter. + * Subsequent calls are ignored. + */ + configure( + adapter: { use(...middlewares: Array): void }, + options: ObservabilityHostingOptions, + ): void { + if (this._configured) { + Logger.getInstance().warn( + "[ObservabilityHostingManager] Already configured. Subsequent configure() calls are ignored.", + ); + return; + } + + const enableBaggage = options.enableBaggage === true; + const enableOutputLogging = options.enableOutputLogging === true; + + if (enableBaggage) { + adapter.use(new BaggageMiddleware()); + Logger.getInstance().info("[ObservabilityHostingManager] BaggageMiddleware registered."); + } + if (enableOutputLogging) { + adapter.use(new OutputLoggingMiddleware()); + Logger.getInstance().info( + "[ObservabilityHostingManager] OutputLoggingMiddleware registered.", + ); + } + + Logger.getInstance().info( + `[ObservabilityHostingManager] Configured. Baggage: ${enableBaggage}, OutputLogging: ${enableOutputLogging}.`, + ); + this._configured = true; + } +} diff --git a/src/a365/hosting/outputLoggingMiddleware.ts b/src/a365/hosting/outputLoggingMiddleware.ts new file mode 100644 index 00000000..a36dcd6e --- /dev/null +++ b/src/a365/hosting/outputLoggingMiddleware.ts @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Middleware that creates OutputScope spans for outgoing messages. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts + */ + +import { OutputScope } from "../scopes/index.js"; +import { ScopeUtils } from "./scopeUtils.js"; +import { Logger } from "../../shared/logging/index.js"; +import type { + AgentDetails, + UserDetails, + Request, + SpanDetails, + ParentSpanRef, +} from "../contracts.js"; +import type { + TurnContextLike, + MiddlewareLike, + SendActivitiesHandler, + ActivityLike, +} from "./types.js"; + +/** + * TurnState key for the parent span reference. + * Set this in `turnState` to link OutputScope spans as children of an InvokeAgentScope. + */ +export const A365_PARENT_SPAN_KEY = "A365ParentSpanId"; + +/** + * TurnState key for the auth token. + * Set this in `turnState` so middleware can resolve the agent blueprint ID + * from token claims (used for embodied/agentic requests). + */ +export const A365_AUTH_TOKEN_KEY = "A365AuthToken"; + +/** + * Middleware that creates {@link OutputScope} spans for outgoing messages. + * Links to a parent span when {@link A365_PARENT_SPAN_KEY} is set in turnState. + * + * **Privacy note:** Outgoing message content is captured verbatim + * as span attributes and exported to the configured telemetry backend. + */ +export class OutputLoggingMiddleware implements MiddlewareLike { + async onTurn(context: TurnContextLike, next: () => Promise): Promise { + const authToken = (context.turnState.get(A365_AUTH_TOKEN_KEY) as string) ?? ""; + const agentDetails = ScopeUtils.deriveAgentDetails(context, authToken); + + if (!agentDetails || !agentDetails.tenantId) { + await next(); + return; + } + + const userDetails = ScopeUtils.deriveCallerDetails(context); + const conversationId = ScopeUtils.deriveConversationId(context); + const channel = ScopeUtils.deriveChannelObject(context); + + const request: Request = { + conversationId, + channel, + }; + + if (context.onSendActivities) { + context.onSendActivities( + this._createSendHandler(context, agentDetails, userDetails, request), + ); + } + + await next(); + } + + private _createSendHandler( + turnContext: TurnContextLike, + agentDetails: AgentDetails, + userDetails?: UserDetails, + request?: Request, + ): SendActivitiesHandler { + return async ( + _ctx: TurnContextLike, + activities: ActivityLike[], + sendNext: () => Promise, + ) => { + const messages = activities.filter((a) => a.type === "message" && a.text).map((a) => a.text!); + + if (messages.length === 0) { + return await sendNext(); + } + + const parentSpanRef = turnContext.turnState.get(A365_PARENT_SPAN_KEY) as + | ParentSpanRef + | undefined; + if (!parentSpanRef) { + Logger.getInstance().warn( + `[OutputLoggingMiddleware] No parent span ref in turnState under '${A365_PARENT_SPAN_KEY}'. OutputScope will not be linked to a parent.`, + ); + } + + const spanDetails: SpanDetails | undefined = parentSpanRef + ? { parentContext: parentSpanRef } + : undefined; + + const outputScope = OutputScope.start( + request ?? {}, + { messages }, + agentDetails, + userDetails, + spanDetails, + ); + try { + return await sendNext(); + } catch (error) { + outputScope.recordError( + error instanceof Error + ? error + : new Error(typeof error === "string" ? error : JSON.stringify(error)), + ); + throw error; + } finally { + outputScope.dispose(); + } + }; + } +} diff --git a/src/a365/hosting/scopeUtils.ts b/src/a365/hosting/scopeUtils.ts new file mode 100644 index 00000000..f73bf2ed --- /dev/null +++ b/src/a365/hosting/scopeUtils.ts @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Utilities to populate scope parameters from a TurnContext. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability-hosting/src/utils/ScopeUtils.ts + */ + +import type { SpanKind, TimeInput } from "@opentelemetry/api"; +import type { TurnContextLike } from "./types.js"; +import { resolveEmbodiedAgentIds } from "./turnContextUtils.js"; +import { InvokeAgentScope, InferenceScope, ExecuteToolScope } from "../scopes/index.js"; +import type { + AgentDetails, + UserDetails, + CallerDetails, + Request, + SpanDetails, + InvokeAgentScopeDetails, + ToolCallDetails, + InferenceDetails, +} from "../contracts.js"; + +/** + * Unified utilities to populate scope tags from a TurnContext. + */ +export class ScopeUtils { + // ── Context-derived helpers ───────────────────────────────────── + + /** + * Derive target agent details from the activity recipient. + */ + public static deriveAgentDetails( + turnContext: TurnContextLike, + authToken: string, + ): AgentDetails | undefined { + const recipient = turnContext?.activity?.recipient; + if (!recipient) return undefined; + const { agentId, agentBlueprintId } = resolveEmbodiedAgentIds(turnContext, authToken); + return { + agentId, + agentName: recipient.name, + agentAUID: recipient.aadObjectId, + agentBlueprintId, + agentEmail: turnContext?.activity?.getAgenticUser?.(), + agentDescription: recipient.role, + tenantId: turnContext?.activity?.getAgenticTenantId?.(), + } as AgentDetails; + } + + /** + * Derive caller agent details from the activity from. + */ + public static deriveCallerAgent(turnContext: TurnContextLike): AgentDetails | undefined { + const from = turnContext?.activity?.from; + if (!from) return undefined; + return { + agentBlueprintId: from.agenticAppBlueprintId, + agentName: from.name, + agentAUID: from.aadObjectId, + agentDescription: from.role, + tenantId: from.tenantId, + agentId: from.agenticAppId, + agentEmail: from.agenticUserId, + } as AgentDetails; + } + + /** + * Derive caller identity details (id, email, name, tenant) from the activity from. + */ + public static deriveCallerDetails(turnContext: TurnContextLike): UserDetails | undefined { + const from = turnContext?.activity?.from; + if (!from) return undefined; + return { + userId: from.aadObjectId, + userEmail: from.agenticUserId, + userName: from.name, + tenantId: from.tenantId, + } as UserDetails; + } + + /** + * Derive conversation id from the TurnContext. + */ + public static deriveConversationId(turnContext: TurnContextLike): string | undefined { + return turnContext?.activity?.conversation?.id; + } + + /** + * Derive channel (name and description) from the TurnContext. + */ + public static deriveChannelObject(turnContext: TurnContextLike): { + name?: string; + description?: string; + } { + return { + name: turnContext?.activity?.channelId, + description: turnContext?.activity?.channelIdSubChannel as string | undefined, + }; + } + + // ── Scope factory helpers ─────────────────────────────────────── + + private static setInputMessageTags( + scope: InvokeAgentScope | InferenceScope, + turnContext: TurnContextLike, + ): void { + if (turnContext?.activity?.text) { + scope.recordInputMessages([turnContext.activity.text]); + } + } + + /** + * Create an `InferenceScope` enriched with values from the TurnContext. + */ + static populateInferenceScopeFromTurnContext( + details: InferenceDetails, + turnContext: TurnContextLike, + authToken: string, + startTime?: TimeInput, + endTime?: TimeInput, + ): InferenceScope { + const agent = ScopeUtils.deriveAgentDetails(turnContext, authToken); + const caller = ScopeUtils.deriveCallerDetails(turnContext); + const conversationId = ScopeUtils.deriveConversationId(turnContext); + const channel = ScopeUtils.deriveChannelObject(turnContext); + + if (!agent) { + throw new Error( + "populateInferenceScopeFromTurnContext: Missing agent details on TurnContext (recipient)", + ); + } + + const hasChannel = channel.name !== undefined || channel.description !== undefined; + const request: Request = { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + }; + + const spanDetails: SpanDetails | undefined = + startTime || endTime ? { startTime, endTime } : undefined; + + const scope = InferenceScope.start(request, details, agent, caller, spanDetails); + this.setInputMessageTags(scope, turnContext); + return scope; + } + + /** + * Create an `InvokeAgentScope` enriched with values from the TurnContext. + */ + static populateInvokeAgentScopeFromTurnContext( + details: AgentDetails, + scopeDetails: InvokeAgentScopeDetails, + turnContext: TurnContextLike, + authToken: string, + startTime?: TimeInput, + endTime?: TimeInput, + spanKind?: SpanKind, + ): InvokeAgentScope { + const callerAgent = ScopeUtils.deriveCallerAgent(turnContext); + const caller = ScopeUtils.deriveCallerDetails(turnContext); + const conversationId = ScopeUtils.deriveConversationId(turnContext); + const channel = ScopeUtils.deriveChannelObject(turnContext); + + const agentDetails = ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); + + const hasChannel = channel.name !== undefined || channel.description !== undefined; + const request: Request = { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + }; + + const callerDetails: CallerDetails = { + userDetails: caller, + callerAgentDetails: callerAgent, + }; + + const spanDetailsObj: SpanDetails | undefined = + startTime || endTime || spanKind ? { startTime, endTime, spanKind } : undefined; + + const scope = InvokeAgentScope.start( + request, + scopeDetails, + agentDetails, + callerDetails, + spanDetailsObj, + ); + this.setInputMessageTags(scope, turnContext); + return scope; + } + + /** + * Build agent details by merging provided details with TurnContext. + */ + public static buildInvokeAgentDetails( + details: AgentDetails, + turnContext: TurnContextLike, + authToken: string, + ): AgentDetails { + return ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); + } + + private static buildInvokeAgentDetailsCore( + details: AgentDetails, + turnContext: TurnContextLike, + authToken: string, + ): AgentDetails { + const derivedAgentDetails = ScopeUtils.deriveAgentDetails(turnContext, authToken); + return { + ...details, + ...(derivedAgentDetails ?? {}), + }; + } + + /** + * Create an `ExecuteToolScope` enriched with values from the TurnContext. + */ + static populateExecuteToolScopeFromTurnContext( + details: ToolCallDetails, + turnContext: TurnContextLike, + authToken: string, + startTime?: TimeInput, + endTime?: TimeInput, + spanKind?: SpanKind, + ): ExecuteToolScope { + const agent = ScopeUtils.deriveAgentDetails(turnContext, authToken); + const caller = ScopeUtils.deriveCallerDetails(turnContext); + const conversationId = ScopeUtils.deriveConversationId(turnContext); + const channel = ScopeUtils.deriveChannelObject(turnContext); + + if (!agent) { + throw new Error( + "populateExecuteToolScopeFromTurnContext: Missing agent details on TurnContext (recipient)", + ); + } + + const hasChannel = channel.name !== undefined || channel.description !== undefined; + const request: Request = { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + }; + + const spanDetailsObj: SpanDetails | undefined = + startTime || endTime || spanKind ? { startTime, endTime, spanKind } : undefined; + + return ExecuteToolScope.start(request, details, agent, caller, spanDetailsObj); + } +} diff --git a/src/a365/hosting/turnContextUtils.ts b/src/a365/hosting/turnContextUtils.ts new file mode 100644 index 00000000..d9c4ae1d --- /dev/null +++ b/src/a365/hosting/turnContextUtils.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * TurnContext utility methods for extracting OpenTelemetry baggage pairs. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability-hosting/src/utils/TurnContextUtils.ts + */ + +import { OpenTelemetryConstants } from "../constants.js"; +import type { TurnContextLike } from "./types.js"; + +function normalizePairs(pairs: Array<[string, string | undefined]>): Array<[string, string]> { + return pairs + .filter(([, v]) => v != null && String(v).trim() !== "") + .map(([k, v]) => [k, String(v)]); +} + +/** + * Extracts caller-related OpenTelemetry baggage pairs from the TurnContext. + */ +export function getCallerBaggagePairs(turnContext: TurnContextLike): Array<[string, string]> { + if (!turnContext?.activity?.from) { + return []; + } + const from = turnContext.activity.from; + const pairs: Array<[string, string | undefined]> = [ + [OpenTelemetryConstants.USER_ID_KEY, from.aadObjectId], + [OpenTelemetryConstants.USER_NAME_KEY, from.name], + [OpenTelemetryConstants.USER_EMAIL_KEY, from.agenticUserId], + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, from.agenticAppBlueprintId], + ]; + return normalizePairs(pairs); +} + +/** + * Resolves the agent instance ID and blueprint ID for embodied (agentic) agents. + * For non-embodied agents, both fields are undefined. + * @param turnContext Activity context + * @param authToken Auth token for resolving blueprint ID from token claims. + */ +export function resolveEmbodiedAgentIds( + turnContext: TurnContextLike, + authToken: string, +): { agentId: string | undefined; agentBlueprintId: string | undefined } { + const isAgentic = turnContext?.activity?.isAgenticRequest?.(); + const rawAgentId = isAgentic ? turnContext.activity.getAgenticInstanceId?.() : undefined; + const rawBlueprintId = isAgentic ? getAgentIdFromToken(authToken) : undefined; + return { + agentId: rawAgentId || undefined, + agentBlueprintId: rawBlueprintId || undefined, + }; +} + +/** + * Extracts agent/recipient-related OpenTelemetry baggage pairs from the TurnContext. + */ +export function getTargetAgentBaggagePairs( + turnContext: TurnContextLike, + authToken?: string, +): Array<[string, string]> { + if (!turnContext?.activity?.recipient) { + return []; + } + const recipient = turnContext.activity.recipient; + const { agentId } = authToken + ? resolveEmbodiedAgentIds(turnContext, authToken) + : { + agentId: turnContext.activity?.isAgenticRequest?.() + ? turnContext.activity.getAgenticInstanceId?.() + : undefined, + }; + const pairs: Array<[string, string | undefined]> = [ + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, agentId], + [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, recipient.name], + [OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, recipient.role], + [OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, recipient.aadObjectId], + ]; + return normalizePairs(pairs); +} + +/** + * Extracts the tenant ID baggage key-value pair. + */ +export function getTenantIdPair(turnContext: TurnContextLike): Array<[string, string]> { + const tenantId = turnContext.activity?.getAgenticTenantId?.(); + return tenantId ? [[OpenTelemetryConstants.TENANT_ID_KEY, tenantId]] : []; +} + +/** + * Extracts channel baggage pairs from the TurnContext. + */ +export function getChannelBaggagePairs(turnContext: TurnContextLike): Array<[string, string]> { + if (!turnContext) { + return []; + } + const pairs: Array<[string, string | undefined]> = [ + [OpenTelemetryConstants.CHANNEL_NAME_KEY, turnContext.activity?.channelId], + [ + OpenTelemetryConstants.CHANNEL_LINK_KEY, + turnContext.activity?.channelIdSubChannel as string | undefined, + ], + ]; + return normalizePairs(pairs); +} + +/** + * Extracts conversation ID and item link baggage pairs. + */ +export function getConversationIdAndItemLinkPairs( + turnContext: TurnContextLike, +): Array<[string, string]> { + if (!turnContext) { + return []; + } + const conversationId = turnContext.activity?.conversation?.id; + const itemLink = turnContext.activity?.serviceUrl; + const pairs: Array<[string, string | undefined]> = [ + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, conversationId], + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ITEM_LINK_KEY, itemLink], + ]; + return normalizePairs(pairs); +} + +// --------------------------------------------------------------------------- +// Inline token decode (replaces @microsoft/agents-a365-runtime Utility) +// --------------------------------------------------------------------------- + +/** + * Decode the JWT and return the best available agent identifier. + * Priority: xms_par_app_azp > appid > azp. + * + * WARNING: NO SIGNATURE VERIFICATION — suitable only for logging/diagnostics. + */ +function getAgentIdFromToken(token: string): string { + if (!token || token.trim() === "") return ""; + try { + const parts = token.split("."); + if (parts.length < 2) return ""; + const padded = parts[1] + "=".repeat((4 - (parts[1].length % 4)) % 4); + const decoded = JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as Record< + string, + unknown + >; + return ( + (decoded["xms_par_app_azp"] as string) || + (decoded["appid"] as string) || + (decoded["azp"] as string) || + "" + ); + } catch { + return ""; + } +} diff --git a/src/a365/hosting/types.ts b/src/a365/hosting/types.ts new file mode 100644 index 00000000..848767f4 --- /dev/null +++ b/src/a365/hosting/types.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Minimal interfaces for hosting-layer observability middleware. + * + * These types mirror the shapes from `@microsoft/agents-hosting` and + * `@microsoft/agents-activity` so the hosting utilities can be used + * without taking a dependency on those packages directly. Consumers + * using agents-hosting can pass their TurnContext/Middleware instances + * directly since they satisfy these interfaces structurally. + */ + +/** Minimal activity shape required by hosting observability utilities. */ +export interface ActivityLike { + type?: string; + name?: string; + text?: string; + channelId?: string; + channelIdSubChannel?: string | unknown; + serviceUrl?: string; + from?: { + aadObjectId?: string; + name?: string; + role?: string; + tenantId?: string; + agenticUserId?: string; + agenticAppBlueprintId?: string; + agenticAppId?: string; + }; + recipient?: { + aadObjectId?: string; + name?: string; + role?: string; + }; + conversation?: { + id?: string; + }; + isAgenticRequest?: () => boolean; + getAgenticInstanceId?: () => string; + getAgenticTenantId?: () => string; + getAgenticUser?: () => string; +} + +/** Minimal turn context shape required by hosting observability utilities. */ +export interface TurnContextLike { + activity: ActivityLike; + turnState: Map; + onSendActivities?(handler: SendActivitiesHandler): void; +} + +/** Handler for intercepting outgoing activities. */ +export type SendActivitiesHandler = ( + ctx: TurnContextLike, + activities: ActivityLike[], + sendNext: () => Promise, +) => Promise; + +/** Middleware interface compatible with agents-hosting adapters. */ +export interface MiddlewareLike { + onTurn(context: TurnContextLike, next: () => Promise): Promise; +} diff --git a/src/a365/index.ts b/src/a365/index.ts index cf574dee..1c86f7dd 100644 --- a/src/a365/index.ts +++ b/src/a365/index.ts @@ -88,3 +88,27 @@ export type { PerRequestSpanProcessorOptions } from "./processors/index.js"; // ── Token context ─────────────────────────────────────────────────────────── export { runWithExportToken, updateExportToken, getExportToken } from "./context/tokenContext.js"; + +// ── Hosting utilities ─────────────────────────────────────────────────────── +export { + BaggageBuilderUtils, + ScopeUtils, + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getChannelBaggagePairs, + getConversationIdAndItemLinkPairs, + resolveEmbodiedAgentIds, + BaggageMiddleware, + OutputLoggingMiddleware, + A365_PARENT_SPAN_KEY, + A365_AUTH_TOKEN_KEY, + ObservabilityHostingManager, +} from "./hosting/index.js"; +export type { + ObservabilityHostingOptions, + TurnContextLike, + ActivityLike, + MiddlewareLike, + SendActivitiesHandler, +} from "./hosting/index.js"; diff --git a/src/a365/middleware/BaggageBuilder.ts b/src/a365/middleware/BaggageBuilder.ts index dcc2d166..cf62ed61 100644 --- a/src/a365/middleware/BaggageBuilder.ts +++ b/src/a365/middleware/BaggageBuilder.ts @@ -177,7 +177,7 @@ export class BaggageBuilder { * Set multiple baggage pairs from a dictionary or iterable. * @param pairs Dictionary or iterable of key-value pairs */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + setPairs( pairs: Record | Iterable<[string, any]> | null | undefined, ): BaggageBuilder { diff --git a/src/index.ts b/src/index.ts index 1fcba412..9e7d1736 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,6 +83,30 @@ export type { } from "./a365/index.js"; export type { PerRequestSpanProcessorOptions } from "./a365/index.js"; +// ── Re-exports from A365 hosting utilities ────────────────────────────────── +export { + BaggageBuilderUtils, + ScopeUtils, + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getChannelBaggagePairs, + getConversationIdAndItemLinkPairs, + resolveEmbodiedAgentIds, + BaggageMiddleware, + OutputLoggingMiddleware, + A365_PARENT_SPAN_KEY, + A365_AUTH_TOKEN_KEY, + ObservabilityHostingManager, +} from "./a365/index.js"; +export type { + ObservabilityHostingOptions, + TurnContextLike, + ActivityLike, + MiddlewareLike, + SendActivitiesHandler, +} from "./a365/index.js"; + // ── Re-exports from types ─────────────────────────────────────────────────── export type { OpenAIAgentsInstrumentationConfig, LangChainInstrumentationConfig } from "./types.js"; diff --git a/test/internal/unit/a365/agent365Exporter.test.ts b/test/internal/unit/a365/agent365Exporter.test.ts index 7457d4ce..d6706d21 100644 --- a/test/internal/unit/a365/agent365Exporter.test.ts +++ b/test/internal/unit/a365/agent365Exporter.test.ts @@ -124,7 +124,7 @@ describe("Agent365Exporter", () => { assert.strictEqual(fetchSpy.mock.calls.length, 1); const [url, options] = fetchSpy.mock.calls[0]; - assert.ok(url.includes(`/observability/tenants/${TENANT_ID}/agents/${AGENT_ID}/traces`)); + assert.ok(url.includes(`/observability/tenants/${TENANT_ID}/otlp/agents/${AGENT_ID}/traces`)); assert.strictEqual(options.method, "POST"); assert.strictEqual(options.headers["authorization"], "Bearer test-token"); assert.strictEqual(options.headers["x-ms-tenant-id"], TENANT_ID); @@ -186,6 +186,7 @@ describe("Agent365Exporter", () => { const [url] = fetchSpy.mock.calls[0]; assert.ok(url.startsWith("https://agent365.svc.cloud.microsoft/observability/tenants/")); + assert.ok(url.includes("/otlp/agents/")); }); it("should use S2S endpoint when configured", async () => { @@ -200,7 +201,7 @@ describe("Agent365Exporter", () => { const [url] = fetchSpy.mock.calls[0]; assert.ok(url.includes("/observabilityService/tenants/")); - assert.ok(url.includes(`/agents/${AGENT_ID}/traces`)); + assert.ok(url.includes(`/otlp/agents/${AGENT_ID}/traces`)); }); it("should use S2S endpoint with domain override", async () => { @@ -216,6 +217,7 @@ describe("Agent365Exporter", () => { const [url, options] = fetchSpy.mock.calls[0]; assert.ok(url.startsWith("https://custom.domain/observabilityService/tenants/")); + assert.ok(url.includes("/otlp/agents/")); assert.strictEqual(options.headers["authorization"], "Bearer tok-s2s-custom"); assert.strictEqual(options.headers["x-ms-tenant-id"], TENANT_ID); }); diff --git a/test/internal/unit/a365/hosting/baggageBuilderUtils.test.ts b/test/internal/unit/a365/hosting/baggageBuilderUtils.test.ts new file mode 100644 index 00000000..662c2ae3 --- /dev/null +++ b/test/internal/unit/a365/hosting/baggageBuilderUtils.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { context as otelContext } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { + BaggageBuilderUtils, + BaggageBuilder, + OpenTelemetryConstants, +} from "../../../../../src/a365/index.js"; +import type { TurnContextLike } from "../../../../../src/a365/index.js"; + +let contextManager: AsyncLocalStorageContextManager; + +beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); +}); + +afterAll(() => { + contextManager.disable(); + otelContext.disable(); +}); + +function makeMockTurnContext(): TurnContextLike { + return { + activity: { + from: { + aadObjectId: "user-oid", + name: "User One", + agenticUserId: "user@contoso.com", + tenantId: "tenant1", + role: "user", + }, + recipient: { + aadObjectId: "agent-oid", + name: "Agent One", + role: "agent", + }, + conversation: { id: "conv-1" }, + channelId: "web", + serviceUrl: "https://example.com", + isAgenticRequest: () => true, + getAgenticInstanceId: () => "agent-app-1", + getAgenticTenantId: () => "tenant1", + getAgenticUser: () => "agent@contoso.com", + }, + turnState: new Map(), + }; +} + +describe("BaggageBuilderUtils", () => { + it("should populate all baggage pairs from TurnContext", () => { + const capturedPairs: Array<[string, string]> = []; + + // Subclass BaggageBuilder to capture setPairs calls + class CapturingBaggageBuilder extends BaggageBuilder { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setPairs(pairs: Record | Iterable<[string, any]> | null | undefined): this { + if (pairs) { + // 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 [k, v] of entries) { + if (v !== null && v !== undefined) { + capturedPairs.push([k, String(v)]); + } + } + } + return this; + } + } + + const builder = new CapturingBaggageBuilder(); + const ctx = makeMockTurnContext(); + const result = BaggageBuilderUtils.fromTurnContext(builder, ctx); + + expect(result).toBe(builder); + + const asObj = Object.fromEntries(capturedPairs); + + // Caller pairs + expect(asObj[OpenTelemetryConstants.USER_ID_KEY]).toBe("user-oid"); + expect(asObj[OpenTelemetryConstants.USER_NAME_KEY]).toBe("User One"); + expect(asObj[OpenTelemetryConstants.USER_EMAIL_KEY]).toBe("user@contoso.com"); + + // Agent pairs + expect(asObj[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe("agent-app-1"); + expect(asObj[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe("Agent One"); + expect(asObj[OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY]).toBe("agent"); + + // Tenant + expect(asObj[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant1"); + + // Channel + expect(asObj[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("web"); + + // Conversation + expect(asObj[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("conv-1"); + }); + + it("should throw if turnContext is missing", () => { + const builder = new BaggageBuilder(); + expect(() => + BaggageBuilderUtils.fromTurnContext(builder, undefined as unknown as TurnContextLike), + ).toThrow("turnContext is required"); + }); + + it("should call individual set helpers", () => { + const builder = new BaggageBuilder(); + const ctx = makeMockTurnContext(); + + // Each helper should return the same builder (fluent) + expect(BaggageBuilderUtils.setCallerBaggage(builder, ctx)).toBe(builder); + expect(BaggageBuilderUtils.setTargetAgentBaggage(builder, ctx)).toBe(builder); + expect(BaggageBuilderUtils.setTenantIdBaggage(builder, ctx)).toBe(builder); + expect(BaggageBuilderUtils.setChannelBaggage(builder, ctx)).toBe(builder); + expect(BaggageBuilderUtils.setConversationIdBaggage(builder, ctx)).toBe(builder); + }); +}); diff --git a/test/internal/unit/a365/hosting/baggageMiddleware.test.ts b/test/internal/unit/a365/hosting/baggageMiddleware.test.ts new file mode 100644 index 00000000..232e5ed2 --- /dev/null +++ b/test/internal/unit/a365/hosting/baggageMiddleware.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { trace, context as otelContext, propagation } from "@opentelemetry/api"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { BaggageMiddleware, OpenTelemetryConstants } from "../../../../../src/a365/index.js"; +import type { TurnContextLike } from "../../../../../src/a365/index.js"; + +let exporter: InMemorySpanExporter; +let contextManager: AsyncLocalStorageContextManager; + +beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); + + exporter = new InMemorySpanExporter(); + const processor = new SimpleSpanProcessor(exporter); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const globalProvider: any = trace.getTracerProvider(); + if (globalProvider && typeof globalProvider.addSpanProcessor === "function") { + globalProvider.addSpanProcessor(processor); + } else { + const provider = new BasicTracerProvider({ + spanProcessors: [processor], + }); + trace.setGlobalTracerProvider(provider); + } +}); + +beforeEach(() => { + exporter.reset(); +}); + +afterAll(() => { + contextManager.disable(); + otelContext.disable(); +}); + +function makeMockTurnContext(options?: { + activityType?: string; + activityName?: string; +}): TurnContextLike { + return { + activity: { + type: options?.activityType, + name: options?.activityName, + text: "Hello agent", + channelId: "web", + conversation: { id: "conv-001" }, + serviceUrl: "https://example.com", + from: { + aadObjectId: "user-oid", + name: "Test User", + agenticUserId: "user@contoso.com", + tenantId: "from-tenant", + }, + recipient: { + aadObjectId: "agent-oid", + name: "Agent One", + role: "assistant", + }, + isAgenticRequest: () => false, + getAgenticInstanceId: () => "agent-1", + getAgenticTenantId: () => "tenant-123", + getAgenticUser: () => "agent@contoso.com", + }, + turnState: new Map(), + }; +} + +describe("BaggageMiddleware", () => { + it("should propagate baggage context during turn", async () => { + const middleware = new BaggageMiddleware(); + const ctx = makeMockTurnContext(); + const capturedBaggage: Record = {}; + + await middleware.onTurn(ctx, async () => { + const bag = propagation.getBaggage(otelContext.active()); + if (bag) { + for (const [key, entry] of bag.getAllEntries()) { + capturedBaggage[key] = entry.value; + } + } + }); + + expect(capturedBaggage[OpenTelemetryConstants.USER_ID_KEY]).toBe("user-oid"); + expect(capturedBaggage[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(capturedBaggage[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("web"); + expect(capturedBaggage[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("conv-001"); + }); + + it("should skip baggage setup for async replies (ContinueConversation)", async () => { + const middleware = new BaggageMiddleware(); + const ctx = makeMockTurnContext({ + activityType: "event", + activityName: "ContinueConversation", + }); + const capturedBaggage: Record = {}; + + await middleware.onTurn(ctx, async () => { + const bag = propagation.getBaggage(otelContext.active()); + if (bag) { + for (const [key, entry] of bag.getAllEntries()) { + capturedBaggage[key] = entry.value; + } + } + }); + + // No baggage set for async replies + expect(Object.keys(capturedBaggage).length).toBe(0); + }); + + it("should call next() even when baggage setup is skipped", async () => { + const middleware = new BaggageMiddleware(); + const ctx = makeMockTurnContext({ + activityType: "event", + activityName: "ContinueConversation", + }); + let nextCalled = false; + + await middleware.onTurn(ctx, async () => { + nextCalled = true; + }); + + expect(nextCalled).toBe(true); + }); + + it("should always call next() for normal message activities", async () => { + const middleware = new BaggageMiddleware(); + const ctx = makeMockTurnContext(); + let nextCalled = false; + + await middleware.onTurn(ctx, async () => { + nextCalled = true; + }); + + expect(nextCalled).toBe(true); + }); + + it("should not treat non-ContinueConversation events as async replies", async () => { + const middleware = new BaggageMiddleware(); + const ctx = makeMockTurnContext({ + activityType: "event", + activityName: "SomeOtherEvent", + }); + const capturedBaggage: Record = {}; + + await middleware.onTurn(ctx, async () => { + const bag = propagation.getBaggage(otelContext.active()); + if (bag) { + for (const [key, entry] of bag.getAllEntries()) { + capturedBaggage[key] = entry.value; + } + } + }); + + // Should still set baggage for non-ContinueConversation events + expect(Object.keys(capturedBaggage).length).toBeGreaterThan(0); + }); +}); diff --git a/test/internal/unit/a365/hosting/observabilityHostingManager.test.ts b/test/internal/unit/a365/hosting/observabilityHostingManager.test.ts new file mode 100644 index 00000000..d60aa31f --- /dev/null +++ b/test/internal/unit/a365/hosting/observabilityHostingManager.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from "vitest"; + +import { ObservabilityHostingManager } from "../../../../../src/a365/hosting/observabilityHostingManager.js"; +import { BaggageMiddleware } from "../../../../../src/a365/hosting/baggageMiddleware.js"; +import { OutputLoggingMiddleware } from "../../../../../src/a365/hosting/outputLoggingMiddleware.js"; +import type { MiddlewareLike } from "../../../../../src/a365/hosting/types.js"; + +describe("ObservabilityHostingManager", () => { + it("should not register any middleware by default", () => { + const manager = new ObservabilityHostingManager(); + const registered: MiddlewareLike[] = []; + const adapter = { use: (...mws: MiddlewareLike[]) => registered.push(...mws) }; + + manager.configure(adapter, {}); + + expect(registered.length).toBe(0); + }); + + it("should register BaggageMiddleware when enableBaggage is true", () => { + const manager = new ObservabilityHostingManager(); + const registered: MiddlewareLike[] = []; + const adapter = { use: (...mws: MiddlewareLike[]) => registered.push(...mws) }; + + manager.configure(adapter, { enableBaggage: true }); + + expect(registered.length).toBe(1); + expect(registered[0]).toBeInstanceOf(BaggageMiddleware); + }); + + it("should register OutputLoggingMiddleware when enableOutputLogging is true", () => { + const manager = new ObservabilityHostingManager(); + const registered: MiddlewareLike[] = []; + const adapter = { use: (...mws: MiddlewareLike[]) => registered.push(...mws) }; + + manager.configure(adapter, { enableOutputLogging: true }); + + expect(registered.length).toBe(1); + expect(registered[0]).toBeInstanceOf(OutputLoggingMiddleware); + }); + + it("should register both middleware when both flags are true", () => { + const manager = new ObservabilityHostingManager(); + const registered: MiddlewareLike[] = []; + const adapter = { use: (...mws: MiddlewareLike[]) => registered.push(...mws) }; + + manager.configure(adapter, { enableBaggage: true, enableOutputLogging: true }); + + expect(registered.length).toBe(2); + expect(registered[0]).toBeInstanceOf(BaggageMiddleware); + expect(registered[1]).toBeInstanceOf(OutputLoggingMiddleware); + }); + + it("should ignore subsequent configure() calls", () => { + const manager = new ObservabilityHostingManager(); + const registered: MiddlewareLike[] = []; + const adapter = { use: (...mws: MiddlewareLike[]) => registered.push(...mws) }; + + manager.configure(adapter, { enableBaggage: true }); + expect(registered.length).toBe(1); + + // Second call should be a no-op + manager.configure(adapter, { enableBaggage: true, enableOutputLogging: true }); + expect(registered.length).toBe(1); + }); +}); diff --git a/test/internal/unit/a365/hosting/outputLoggingMiddleware.test.ts b/test/internal/unit/a365/hosting/outputLoggingMiddleware.test.ts new file mode 100644 index 00000000..5e63b2b1 --- /dev/null +++ b/test/internal/unit/a365/hosting/outputLoggingMiddleware.test.ts @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import { trace, context as otelContext } from "@opentelemetry/api"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { + OutputLoggingMiddleware, + A365_PARENT_SPAN_KEY, + A365_AUTH_TOKEN_KEY, + OpenTelemetryConstants, +} from "../../../../../src/a365/index.js"; +import type { + TurnContextLike, + ActivityLike, + SendActivitiesHandler, +} from "../../../../../src/a365/index.js"; + +let exporter: InMemorySpanExporter; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let flushProvider: any; +let contextManager: AsyncLocalStorageContextManager; + +const originalConsoleWarn = console.warn; + +beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); + + exporter = new InMemorySpanExporter(); + const processor = new SimpleSpanProcessor(exporter); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const globalProvider: any = trace.getTracerProvider(); + if (globalProvider && typeof globalProvider.addSpanProcessor === "function") { + globalProvider.addSpanProcessor(processor); + flushProvider = globalProvider; + } else { + const provider = new BasicTracerProvider({ + spanProcessors: [processor], + }); + trace.setGlobalTracerProvider(provider); + flushProvider = provider; + } + + console.warn = vi.fn(); +}); + +beforeEach(() => { + exporter.reset(); +}); + +afterAll(() => { + console.warn = originalConsoleWarn; + contextManager.disable(); + otelContext.disable(); +}); + +interface MockTurnContext extends TurnContextLike { + _sendHandlers: SendActivitiesHandler[]; + simulateSend(activities: Array<{ type?: string; text?: string }>): Promise; +} + +function makeMockTurnContext(options?: { + text?: string; + recipientName?: string; + recipientTenantId?: string; + channelId?: string; + conversationId?: string; + activityType?: string; + activityName?: string; +}): MockTurnContext { + const sendHandlers: SendActivitiesHandler[] = []; + const recipientTenantId = options?.recipientTenantId ?? "tenant-123"; + + const ctx: MockTurnContext = { + activity: { + type: options?.activityType, + name: options?.activityName, + text: options?.text ?? "Hello agent", + channelId: options?.channelId ?? "web", + conversation: { id: options?.conversationId ?? "conv-001" }, + serviceUrl: "https://example.com", + from: { + aadObjectId: "user-oid", + name: "Test User", + agenticUserId: "user@contoso.com", + tenantId: "from-tenant", + }, + recipient: { + aadObjectId: "agent-oid", + name: options?.recipientName ?? "Agent One", + role: "assistant", + }, + getAgenticTenantId: () => recipientTenantId, + getAgenticUser: () => "agent@contoso.com", + getAgenticInstanceId: () => "agent-1", + isAgenticRequest: () => false, + }, + turnState: new Map(), + _sendHandlers: sendHandlers, + onSendActivities(handler: SendActivitiesHandler) { + sendHandlers.push(handler); + }, + async simulateSend(activities: Array<{ type?: string; text?: string }>) { + const finalSend = async () => activities.map(() => ({ id: "resp-1" })); + let current = finalSend; + for (let i = sendHandlers.length - 1; i >= 0; i--) { + const handler = sendHandlers[i]; + const prev = current; + current = () => + handler( + ctx as TurnContextLike, + activities as ActivityLike[], + prev as () => Promise, + ); + } + return await current(); + }, + }; + + return ctx; +} + +describe("OutputLoggingMiddleware", () => { + it("should create OutputScope for outgoing messages", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: "Hello" }); + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + + await middleware.onTurn(ctx, async () => { + ctx.turnState.set(A365_PARENT_SPAN_KEY, { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: 1, + }); + await ctx.simulateSend([{ type: "message", text: "Hi there!" }]); + }); + + await flushProvider.forceFlush(); + const spans = exporter.getFinishedSpans(); + const outputSpan = spans.find((s) => s.name.includes("output_messages")); + + expect(outputSpan).toBeDefined(); + const messages = outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY]; + expect(messages).toBeDefined(); + expect(typeof messages).toBe("string"); + expect(JSON.parse(messages as string).messages[0].parts[0].content).toBe("Hi there!"); + }); + + it("should skip non-message activities", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: "Hello" }); + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + + await middleware.onTurn(ctx, async () => { + await ctx.simulateSend([{ type: "typing" }, { type: "event", text: "some event" }]); + }); + + await flushProvider.forceFlush(); + const outputSpan = exporter.getFinishedSpans().find((s) => s.name.includes("output_messages")); + expect(outputSpan).toBeUndefined(); + }); + + it("should pass through without tracing when agent details are missing", async () => { + const middleware = new OutputLoggingMiddleware(); + + // No recipient → no agent details → should pass through + const ctx: TurnContextLike = { + activity: { + text: "Hello", + isAgenticRequest: () => false, + getAgenticTenantId: () => undefined as unknown as string, + getAgenticUser: () => undefined as unknown as string, + }, + turnState: new Map(), + }; + + let nextCalled = false; + await middleware.onTurn(ctx, async () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + }); + + it("should pass through when tenantId is missing", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx: TurnContextLike = { + activity: { + text: "Hello", + recipient: { name: "Agent" }, + isAgenticRequest: () => false, + getAgenticInstanceId: () => "aid", + getAgenticTenantId: () => undefined as unknown as string, + getAgenticUser: () => undefined as unknown as string, + }, + turnState: new Map(), + }; + + let nextCalled = false; + await middleware.onTurn(ctx, async () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + }); + + it("should set caller details on OutputScope span", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: "Hello", channelId: "teams" }); + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + + await middleware.onTurn(ctx, async () => { + ctx.turnState.set(A365_PARENT_SPAN_KEY, { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: 1, + }); + await ctx.simulateSend([{ type: "message", text: "Reply" }]); + }); + + await flushProvider.forceFlush(); + const outputSpan = exporter.getFinishedSpans().find((s) => s.name.includes("output_messages")); + expect(outputSpan).toBeDefined(); + expect(outputSpan!.attributes[OpenTelemetryConstants.USER_ID_KEY]).toBe("user-oid"); + expect(outputSpan!.attributes[OpenTelemetryConstants.USER_NAME_KEY]).toBe("Test User"); + expect(outputSpan!.attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("teams"); + }); + + it("should link OutputScope to parent when parentSpanRef is set", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: "Hello" }); + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + + const parentSpanRef = { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: 1, + }; + + await middleware.onTurn(ctx, async () => { + ctx.turnState.set(A365_PARENT_SPAN_KEY, parentSpanRef); + await ctx.simulateSend([{ type: "message", text: "Reply" }]); + }); + + await flushProvider.forceFlush(); + const outputSpan = exporter.getFinishedSpans().find((s) => s.name.includes("output_messages")); + expect(outputSpan).toBeDefined(); + const parentCtx = outputSpan!.parentSpanContext; + expect(parentCtx?.traceId).toBe(parentSpanRef.traceId); + expect(parentCtx?.spanId).toBe(parentSpanRef.spanId); + }); + + it("should not create spans when no messages are sent", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: "Hello" }); + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + + await middleware.onTurn(ctx, async () => { + // next() without sending any messages + }); + + await flushProvider.forceFlush(); + expect( + exporter.getFinishedSpans().find((s) => s.name.includes("output_messages")), + ).toBeUndefined(); + }); + + it("should re-throw errors from sendNext after recording on OutputScope", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: "Hello" }); + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + const sendError = new Error("send pipeline failed"); + + // Override simulateSend to make the final send throw + ctx.simulateSend = async (activities) => { + // Register the handler first via onTurn, then trigger a throwing send + const finalSend = async () => { + throw sendError; + }; + let current = finalSend; + for (let i = ctx._sendHandlers.length - 1; i >= 0; i--) { + const handler = ctx._sendHandlers[i]; + const prev = current; + current = () => + handler( + ctx as TurnContextLike, + activities as ActivityLike[], + prev as () => Promise, + ); + } + return await current(); + }; + + await middleware.onTurn(ctx, async () => { + await expect(ctx.simulateSend([{ type: "message", text: "Will fail" }])).rejects.toThrow( + "send pipeline failed", + ); + }); + + await flushProvider.forceFlush(); + const outputSpan = exporter.getFinishedSpans().find((s) => s.name.includes("output_messages")); + expect(outputSpan).toBeDefined(); + // The span should have an error event recorded + const errorEvents = outputSpan!.events.filter((e) => e.name === "exception"); + expect(errorEvents.length).toBeGreaterThan(0); + }); + + it("should export A365_PARENT_SPAN_KEY and A365_AUTH_TOKEN_KEY constants", () => { + expect(A365_PARENT_SPAN_KEY).toBe("A365ParentSpanId"); + expect(A365_AUTH_TOKEN_KEY).toBe("A365AuthToken"); + }); +}); diff --git a/test/internal/unit/a365/hosting/scopeUtils.test.ts b/test/internal/unit/a365/hosting/scopeUtils.test.ts new file mode 100644 index 00000000..ab1952fb --- /dev/null +++ b/test/internal/unit/a365/hosting/scopeUtils.test.ts @@ -0,0 +1,411 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import { trace, context as otelContext, SpanKind } from "@opentelemetry/api"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { + ScopeUtils, + OpenTelemetryConstants, + OpenTelemetryScope, + InvokeAgentScope, + InferenceScope, + ExecuteToolScope, +} from "../../../../../src/a365/index.js"; +import type { TurnContextLike } from "../../../../../src/a365/index.js"; + +let contextManager: AsyncLocalStorageContextManager; + +const testAuthToken = "mock-auth-token"; + +const originalConsoleWarn = console.warn; +const originalConsoleError = console.error; + +beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); + + const exporter = new InMemorySpanExporter(); + const processor = new SimpleSpanProcessor(exporter); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const globalProvider: any = trace.getTracerProvider(); + if (globalProvider && typeof globalProvider.addSpanProcessor === "function") { + globalProvider.addSpanProcessor(processor); + } else { + const provider = new BasicTracerProvider({ + spanProcessors: [processor], + }); + trace.setGlobalTracerProvider(provider); + } + + console.warn = vi.fn(); + console.error = vi.fn(); +}); + +afterAll(() => { + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + contextManager.disable(); + otelContext.disable(); +}); + +function makeTurnContext( + text?: string, + channelName?: string, + channelLink?: string, + conversationId?: string, +): TurnContextLike { + return { + activity: { + text: text ?? "hello world", + channelId: channelName ?? "web", + channelIdSubChannel: channelLink ?? "https://example/channel", + conversation: { id: conversationId ?? "conv-001" }, + serviceUrl: "https://example.com", + from: { + aadObjectId: "user-oid", + name: "Test User", + agenticUserId: "user@contoso.com", + tenantId: "tenant-xyz", + agenticAppBlueprintId: "caller-agentBlueprintId", + agenticAppId: "callerAgent-1", + role: "user", + }, + recipient: { + aadObjectId: "agent-oid", + name: "Agent One", + role: "assistant", + }, + isAgenticRequest: () => true, + getAgenticInstanceId: () => "agent-1", + getAgenticUser: () => "agent-upn@contoso.com", + getAgenticTenantId: () => "tenant-123", + }, + turnState: new Map(), + }; +} + +function makeCtx(partial: Partial): TurnContextLike { + return partial as unknown as TurnContextLike; +} + +describe("ScopeUtils", () => { + describe("deriveAgentDetails", () => { + it("should map recipient fields to AgentDetails", () => { + const ctx = makeCtx({ + activity: { + recipient: { name: "A", aadObjectId: "auid", role: "bot" }, + isAgenticRequest: () => false, + getAgenticInstanceId: () => "aid", + getAgenticUser: () => "upn1", + getAgenticTenantId: () => "t1", + }, + }); + const result = ScopeUtils.deriveAgentDetails(ctx, testAuthToken); + expect(result).toEqual({ + agentId: undefined, + agentName: "A", + agentAUID: "auid", + agentBlueprintId: undefined, + agentEmail: "upn1", + agentDescription: "bot", + tenantId: "t1", + }); + }); + + it("should return undefined without recipient", () => { + const ctx = makeCtx({ activity: {} }); + expect(ScopeUtils.deriveAgentDetails(ctx, testAuthToken)).toBeUndefined(); + }); + }); + + describe("deriveCallerAgent", () => { + it("should map from fields to caller AgentDetails", () => { + const ctx = makeCtx({ + activity: { + from: { + agenticAppBlueprintId: "bp", + name: "Caller", + aadObjectId: "uid", + agenticUserId: "caller-upn", + role: "agent", + tenantId: "t2", + agenticAppId: "agent-caller", + }, + }, + }); + expect(ScopeUtils.deriveCallerAgent(ctx)).toEqual({ + agentBlueprintId: "bp", + agentName: "Caller", + agentAUID: "uid", + agentEmail: "caller-upn", + agentDescription: "agent", + tenantId: "t2", + agentId: "agent-caller", + }); + }); + + it("should return undefined without from", () => { + const ctx = makeCtx({ activity: {} }); + expect(ScopeUtils.deriveCallerAgent(ctx)).toBeUndefined(); + }); + }); + + describe("deriveCallerDetails", () => { + it("should map from to UserDetails", () => { + const ctx = makeCtx({ + activity: { + from: { + aadObjectId: "uid", + agenticUserId: "upn", + name: "User", + tenantId: "t3", + }, + }, + }); + expect(ScopeUtils.deriveCallerDetails(ctx)).toEqual({ + userId: "uid", + userEmail: "upn", + userName: "User", + tenantId: "t3", + }); + }); + + it("should return undefined without from", () => { + const ctx = makeCtx({ activity: {} }); + expect(ScopeUtils.deriveCallerDetails(ctx)).toBeUndefined(); + }); + }); + + describe("deriveConversationId", () => { + it("should return id when present", () => { + const ctx = makeCtx({ activity: { conversation: { id: "conv-1" } } }); + expect(ScopeUtils.deriveConversationId(ctx)).toBe("conv-1"); + }); + + it("should return undefined when missing", () => { + const ctx = makeCtx({ activity: {} }); + expect(ScopeUtils.deriveConversationId(ctx)).toBeUndefined(); + }); + }); + + describe("deriveChannelObject", () => { + it("should map channel name/description", () => { + const ctx = makeCtx({ + activity: { channelId: "teams", channelIdSubChannel: "chat" }, + }); + expect(ScopeUtils.deriveChannelObject(ctx)).toEqual({ + name: "teams", + description: "chat", + }); + }); + + it("should return undefined fields when missing", () => { + const ctx = makeCtx({ activity: {} }); + expect(ScopeUtils.deriveChannelObject(ctx)).toEqual({ + name: undefined, + description: undefined, + }); + }); + }); + + describe("populateInferenceScopeFromTurnContext", () => { + it("should build InferenceScope based on turn context", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const details = { + operationName: "inference", + model: "gpt-4o", + providerName: "openai", + } as any; + const ctx = makeTurnContext("input text", "web", "https://web", "conv-A"); + const scope = ScopeUtils.populateInferenceScopeFromTurnContext(details, ctx, testAuthToken); + + expect(scope).toBeInstanceOf(InferenceScope); + + const calls = spy.mock.calls.map((args) => [args[0], args[1]]); + expect(calls).toEqual( + expect.arrayContaining([ + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, "conv-A"], + [OpenTelemetryConstants.CHANNEL_NAME_KEY, "web"], + [OpenTelemetryConstants.CHANNEL_LINK_KEY, "https://web"], + [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, "Agent One"], + [OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, "agent-oid"], + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, "agent-1"], + [OpenTelemetryConstants.TENANT_ID_KEY, "tenant-123"], + ]), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + + it("should throw when agent details are missing (no recipient)", () => { + const details = { operationName: "inference", model: "m", providerName: "prov" } as any; + const ctx = makeCtx({ + activity: { getAgenticTenantId: () => "t1" }, + }); + expect(() => + ScopeUtils.populateInferenceScopeFromTurnContext(details, ctx, testAuthToken), + ).toThrow("Missing agent details on TurnContext (recipient)"); + }); + }); + + describe("populateInvokeAgentScopeFromTurnContext", () => { + it("should build InvokeAgentScope based on turn context", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const ctx = makeTurnContext("invoke message", "teams", "https://teams", "conv-B"); + const scope = ScopeUtils.populateInvokeAgentScopeFromTurnContext( + { agentId: "invoke-agent", providerName: "internal" } as any, + {}, + ctx, + testAuthToken, + ); + + expect(scope).toBeInstanceOf(InvokeAgentScope); + + const calls = spy.mock.calls.map((args) => [args[0], args[1]]); + expect(calls).toEqual( + expect.arrayContaining([ + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, "conv-B"], + [OpenTelemetryConstants.CHANNEL_NAME_KEY, "teams"], + [OpenTelemetryConstants.CHANNEL_LINK_KEY, "https://teams"], + [OpenTelemetryConstants.USER_ID_KEY, "user-oid"], + [OpenTelemetryConstants.USER_NAME_KEY, "Test User"], + [OpenTelemetryConstants.USER_EMAIL_KEY, "user@contoso.com"], + [OpenTelemetryConstants.TENANT_ID_KEY, "tenant-123"], + ]), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + + it("should forward spanKind", () => { + const spy = vi.spyOn(InvokeAgentScope, "start"); + const ctx = makeTurnContext("hello", "web", "https://web", "conv-span"); + const scope = ScopeUtils.populateInvokeAgentScopeFromTurnContext( + { agentId: "test-agent" } as any, + {}, + ctx, + testAuthToken, + undefined, + undefined, + SpanKind.SERVER, + ); + + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ spanKind: SpanKind.SERVER }), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + }); + + describe("populateExecuteToolScopeFromTurnContext", () => { + it("should build ExecuteToolScope based on turn context", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const details = { toolName: "search", arguments: "{}" } as any; + const ctx = makeTurnContext(undefined, "cli", "https://cli", "conv-C"); + const scope = ScopeUtils.populateExecuteToolScopeFromTurnContext(details, ctx, testAuthToken); + + expect(scope).toBeInstanceOf(ExecuteToolScope); + + const calls = spy.mock.calls.map((args) => [args[0], args[1]]); + expect(calls).toEqual( + expect.arrayContaining([ + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, "conv-C"], + [OpenTelemetryConstants.CHANNEL_NAME_KEY, "cli"], + [OpenTelemetryConstants.CHANNEL_LINK_KEY, "https://cli"], + [OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, "agent-oid"], + [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, "Agent One"], + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, "agent-1"], + [OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, "assistant"], + [OpenTelemetryConstants.TENANT_ID_KEY, "tenant-123"], + ]), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + + it("should throw when agent details are missing (no recipient)", () => { + const details = { toolName: "tool" } as any; + const ctx = makeCtx({ + activity: { getAgenticTenantId: () => "t1" }, + }); + expect(() => + ScopeUtils.populateExecuteToolScopeFromTurnContext(details, ctx, testAuthToken), + ).toThrow("Missing agent details on TurnContext (recipient)"); + }); + + it("should forward spanKind", () => { + const spy = vi.spyOn(ExecuteToolScope, "start"); + const ctx = makeTurnContext(); + const scope = ScopeUtils.populateExecuteToolScopeFromTurnContext( + { toolName: "tool" } as any, + ctx, + testAuthToken, + undefined, + undefined, + SpanKind.CLIENT, + ); + + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ spanKind: SpanKind.CLIENT }), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + }); + + describe("buildInvokeAgentDetails", () => { + it("should merge agent (recipient) into provided details", () => { + const ctx = makeCtx({ + activity: { + recipient: { name: "Rec", role: "bot" }, + conversation: { id: "c-2" }, + channelId: "web", + isAgenticRequest: () => false, + getAgenticInstanceId: () => "rec-agent", + getAgenticUser: () => undefined as unknown as string, + getAgenticTenantId: () => "tX", + }, + }); + + const result = ScopeUtils.buildInvokeAgentDetails( + { agentId: "provided" } as any, + ctx, + testAuthToken, + ); + expect(result.agentName).toBe("Rec"); + }); + + it("should keep base details when TurnContext has no overrides", () => { + const ctx = makeCtx({ activity: {} }); + const result = ScopeUtils.buildInvokeAgentDetails( + { agentId: "base-agent" } as any, + ctx, + testAuthToken, + ); + expect(result.agentId).toBe("base-agent"); + }); + }); +}); diff --git a/test/internal/unit/a365/hosting/turnContextUtils.test.ts b/test/internal/unit/a365/hosting/turnContextUtils.test.ts new file mode 100644 index 00000000..59c7a27a --- /dev/null +++ b/test/internal/unit/a365/hosting/turnContextUtils.test.ts @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { context as otelContext } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getChannelBaggagePairs, + getConversationIdAndItemLinkPairs, + resolveEmbodiedAgentIds, + OpenTelemetryConstants, +} from "../../../../../src/a365/index.js"; +import type { TurnContextLike } from "../../../../../src/a365/index.js"; + +let contextManager: AsyncLocalStorageContextManager; + +beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); +}); + +afterAll(() => { + contextManager.disable(); + otelContext.disable(); +}); + +function makeMockTurnContext(): TurnContextLike { + return { + activity: { + from: { + aadObjectId: "user-oid", + name: "User One", + agenticUserId: "user@contoso.com", + tenantId: "tenant1", + role: "user", + agenticAppBlueprintId: "caller-blueprint-001", + }, + recipient: { + aadObjectId: "agent-oid", + name: "Agent One", + role: "assistant", + }, + conversation: { id: "conv-1" }, + channelId: "web", + channelIdSubChannel: "general", + serviceUrl: "https://smba.trafficmanager.net/teams/", + text: "Hello world", + isAgenticRequest: () => true, + getAgenticInstanceId: () => "agent-instance-1", + getAgenticTenantId: () => "tenant1", + getAgenticUser: () => "agent@contoso.com", + }, + turnState: new Map(), + }; +} + +describe("TurnContextUtils", () => { + describe("getCallerBaggagePairs", () => { + it("should return caller baggage pairs from activity.from", () => { + const ctx = makeMockTurnContext(); + const pairs = getCallerBaggagePairs(ctx); + expect(Array.isArray(pairs)).toBe(true); + expect(pairs.length).toBeGreaterThan(0); + const obj = Object.fromEntries(pairs); + expect(obj[OpenTelemetryConstants.USER_ID_KEY]).toBe("user-oid"); + expect(obj[OpenTelemetryConstants.USER_NAME_KEY]).toBe("User One"); + expect(obj[OpenTelemetryConstants.USER_EMAIL_KEY]).toBe("user@contoso.com"); + expect(obj[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY]).toBe( + "caller-blueprint-001", + ); + }); + + it("should return empty array when from is undefined", () => { + const ctx: TurnContextLike = { activity: {}, turnState: new Map() }; + const pairs = getCallerBaggagePairs(ctx); + expect(pairs).toEqual([]); + }); + + it("should filter out undefined/empty values", () => { + const ctx: TurnContextLike = { + activity: { from: { name: "User", aadObjectId: "" } }, + turnState: new Map(), + }; + const pairs = getCallerBaggagePairs(ctx); + const keys = pairs.map(([k]) => k); + // aadObjectId is empty string → filtered out + expect(keys).not.toContain(OpenTelemetryConstants.USER_ID_KEY); + expect(keys).toContain(OpenTelemetryConstants.USER_NAME_KEY); + }); + }); + + describe("getTargetAgentBaggagePairs", () => { + it("should return target agent baggage pairs from activity.recipient", () => { + const ctx = makeMockTurnContext(); + const pairs = getTargetAgentBaggagePairs(ctx); + expect(Array.isArray(pairs)).toBe(true); + expect(pairs.length).toBeGreaterThan(0); + const obj = Object.fromEntries(pairs); + expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe("agent-instance-1"); + expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe("Agent One"); + expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY]).toBe("assistant"); + expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY]).toBe("agent-oid"); + }); + + it("should return empty array when recipient is undefined", () => { + const ctx: TurnContextLike = { activity: {}, turnState: new Map() }; + const pairs = getTargetAgentBaggagePairs(ctx); + expect(pairs).toEqual([]); + }); + + it("should include auth-resolved agent ID when authToken is provided", () => { + const ctx = makeMockTurnContext(); + // Create a fake JWT with appid claim + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64"); + const payload = Buffer.from(JSON.stringify({ appid: "token-agent-id" })).toString("base64"); + const fakeToken = `${header}.${payload}.sig`; + const pairs = getTargetAgentBaggagePairs(ctx, fakeToken); + const obj = Object.fromEntries(pairs); + // When isAgenticRequest returns true and token provided, blueprint from token is used + expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe("agent-instance-1"); + }); + }); + + describe("resolveEmbodiedAgentIds", () => { + it("should return agent IDs for agentic requests", () => { + const ctx = makeMockTurnContext(); + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64"); + const payload = Buffer.from(JSON.stringify({ appid: "bp-from-token" })).toString("base64"); + const token = `${header}.${payload}.sig`; + const result = resolveEmbodiedAgentIds(ctx, token); + expect(result.agentId).toBe("agent-instance-1"); + expect(result.agentBlueprintId).toBe("bp-from-token"); + }); + + it("should return undefined for non-agentic requests", () => { + const ctx: TurnContextLike = { + activity: { + isAgenticRequest: () => false, + recipient: { name: "Agent" }, + }, + turnState: new Map(), + }; + const result = resolveEmbodiedAgentIds(ctx, "some-token"); + expect(result.agentId).toBeUndefined(); + expect(result.agentBlueprintId).toBeUndefined(); + }); + + it("should handle empty token", () => { + const ctx = makeMockTurnContext(); + const result = resolveEmbodiedAgentIds(ctx, ""); + expect(result.agentId).toBe("agent-instance-1"); + // Empty token → getAgentIdFromToken returns "" → undefined + expect(result.agentBlueprintId).toBeUndefined(); + }); + + it("should use xms_par_app_azp over appid over azp from JWT", () => { + const ctx = makeMockTurnContext(); + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64"); + const payload = Buffer.from( + JSON.stringify({ xms_par_app_azp: "xms-id", appid: "appid-id", azp: "azp-id" }), + ).toString("base64"); + const token = `${header}.${payload}.sig`; + const result = resolveEmbodiedAgentIds(ctx, token); + expect(result.agentBlueprintId).toBe("xms-id"); + }); + }); + + describe("getTenantIdPair", () => { + it("should return tenant ID pair when available", () => { + const ctx = makeMockTurnContext(); + const pairs = getTenantIdPair(ctx); + expect(pairs).toEqual([[OpenTelemetryConstants.TENANT_ID_KEY, "tenant1"]]); + }); + + it("should return empty array when tenantId is not available", () => { + const ctx: TurnContextLike = { + activity: { getAgenticTenantId: () => undefined as unknown as string }, + turnState: new Map(), + }; + const pairs = getTenantIdPair(ctx); + expect(pairs).toEqual([]); + }); + }); + + describe("getChannelBaggagePairs", () => { + it("should return channel baggage pairs", () => { + const ctx = makeMockTurnContext(); + const pairs = getChannelBaggagePairs(ctx); + expect(Array.isArray(pairs)).toBe(true); + const obj = Object.fromEntries(pairs); + expect(obj[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("web"); + expect(obj[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe("general"); + }); + + it("should return empty array when context is null", () => { + const pairs = getChannelBaggagePairs(null as unknown as TurnContextLike); + expect(pairs).toEqual([]); + }); + }); + + describe("getConversationIdAndItemLinkPairs", () => { + it("should return conversation ID and item link", () => { + const ctx = makeMockTurnContext(); + const pairs = getConversationIdAndItemLinkPairs(ctx); + const obj = Object.fromEntries(pairs); + expect(obj[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("conv-1"); + expect(obj[OpenTelemetryConstants.GEN_AI_CONVERSATION_ITEM_LINK_KEY]).toBe( + "https://smba.trafficmanager.net/teams/", + ); + }); + + it("should return empty array when context is null", () => { + const pairs = getConversationIdAndItemLinkPairs(null as unknown as TurnContextLike); + expect(pairs).toEqual([]); + }); + }); +});