From cf01843185969ba23669a0f34638ac4bcb3d00b7 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 24 Feb 2026 01:27:55 -0800 Subject: [PATCH 1/4] Add InputScope, OutputScope, and MessageLoggingMiddleware for message tracing Introduces message-level tracing via InputScope and OutputScope, registered through MessageLoggingMiddleware and ObservabilityMiddlewareRegistrar. Both scopes lazily read A365_PARENT_SPAN_KEY from turnState so the agent handler can link them as children of an InvokeAgentScope. Co-Authored-By: Claude Opus 4.6 --- .../src/index.ts | 3 + .../middleware/MessageLoggingMiddleware.ts | 230 +++++++++++ .../ObservabilityMiddlewareRegistrar.ts | 39 ++ .../src/utils/ScopeUtils.ts | 5 +- .../agents-a365-observability/src/index.ts | 4 +- .../src/tracing/constants.ts | 1 + .../src/tracing/contracts.ts | 13 + .../src/tracing/scopes/InferenceScope.ts | 4 +- .../src/tracing/scopes/InputScope.ts | 130 ++++++ .../src/tracing/scopes/InvokeAgentScope.ts | 4 +- .../src/tracing/scopes/OutputScope.ts | 56 ++- tests/observability/core/input-scope.test.ts | 174 ++++++++ tests/observability/core/output-scope.test.ts | 104 +---- .../message-logging-middleware.test.ts | 388 ++++++++++++++++++ ...observability-middleware-registrar.test.ts | 40 ++ .../extension/hosting/scope-utils.test.ts | 4 +- 16 files changed, 1093 insertions(+), 106 deletions(-) create mode 100644 packages/agents-a365-observability-hosting/src/middleware/MessageLoggingMiddleware.ts create mode 100644 packages/agents-a365-observability-hosting/src/middleware/ObservabilityMiddlewareRegistrar.ts create mode 100644 packages/agents-a365-observability/src/tracing/scopes/InputScope.ts create mode 100644 tests/observability/core/input-scope.test.ts create mode 100644 tests/observability/extension/hosting/message-logging-middleware.test.ts create mode 100644 tests/observability/extension/hosting/observability-middleware-registrar.test.ts diff --git a/packages/agents-a365-observability-hosting/src/index.ts b/packages/agents-a365-observability-hosting/src/index.ts index 2484a5eb..2175304e 100644 --- a/packages/agents-a365-observability-hosting/src/index.ts +++ b/packages/agents-a365-observability-hosting/src/index.ts @@ -7,3 +7,6 @@ export * from './utils/BaggageBuilderUtils'; export * from './utils/ScopeUtils'; export * from './utils/TurnContextUtils'; export { AgenticTokenCache, AgenticTokenCacheInstance } from './caching/AgenticTokenCache'; +export { MessageLoggingMiddleware, A365_PARENT_SPAN_KEY } from './middleware/MessageLoggingMiddleware'; +export type { MessageLoggingMiddlewareOptions } from './middleware/MessageLoggingMiddleware'; +export { ObservabilityMiddlewareRegistrar } from './middleware/ObservabilityMiddlewareRegistrar'; diff --git a/packages/agents-a365-observability-hosting/src/middleware/MessageLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/MessageLoggingMiddleware.ts new file mode 100644 index 00000000..f79a5edc --- /dev/null +++ b/packages/agents-a365-observability-hosting/src/middleware/MessageLoggingMiddleware.ts @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TurnContext, Middleware, SendActivitiesHandler } from '@microsoft/agents-hosting'; +import { ActivityTypes, ActivityEventNames } from '@microsoft/agents-activity'; +import { + InputScope, + OutputScope, + BaggageBuilder, + AgentDetails, + TenantDetails, + CallerDetails, + AgentRequest, + ExecutionType, + parseExecutionType, + ParentSpanRef, +} from '@microsoft/agents-a365-observability'; +import { ScopeUtils } from '../utils/ScopeUtils'; +import { + getExecutionTypePair, + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getSourceMetadataBaggagePairs, + getConversationIdAndItemLinkPairs, +} from '../utils/TurnContextUtils'; + +/** + * TurnState key for the parent span reference ({@link ParentSpanRef}). + * Developers set this value in `turnState` to link InputScope/OutputScope + */ +export const A365_PARENT_SPAN_KEY = 'A365ParentSpanId'; + +/** + * Configuration options for MessageLoggingMiddleware. + * + * **Privacy note:** When enabled, this middleware captures user input and bot output + * message content verbatim as OpenTelemetry span attributes (`gen_ai.input.messages` + * and `gen_ai.output.messages`). This data may contain PII or other sensitive + * information and will be exported to the configured telemetry backend. Ensure your + * telemetry pipeline complies with your organization's data handling policies. + */ +export interface MessageLoggingMiddlewareOptions { + /** + * Whether to create InputScope spans for user (input) messages. + * When true, the raw `activity.text` content is recorded as a span attribute. + * Defaults to true. + */ + logUserMessages?: boolean; + + /** + * Whether to create OutputScope spans for bot (output) messages. + * When true, outgoing message text is recorded as a span attribute. + * Defaults to true. + */ + logBotMessages?: boolean; +} + +/** + * Middleware for tracing input and output messages as OpenTelemetry spans. + * + * Creates {@link InputScope} / {@link OutputScope} spans for incoming/outgoing messages. + * If the developer sets a {@link ParentSpanRef} in `context.turnState` under + * {@link A365_PARENT_SPAN_KEY}, spans are created as children of that parent. + * + * @example + * ```typescript + * const adapter = new CloudAdapter(); + * adapter.use(new MessageLoggingMiddleware()); + * ``` + */ +export class MessageLoggingMiddleware implements Middleware { + private readonly _logUserMessages: boolean; + private readonly _logBotMessages: boolean; + + constructor(options?: MessageLoggingMiddlewareOptions) { + this._logUserMessages = options?.logUserMessages ?? true; + this._logBotMessages = options?.logBotMessages ?? true; + } + + /** + * Called each time the agent processes a turn. + * Creates an InputScope span for incoming messages and hooks onSendActivities + * to create OutputScope spans for outgoing messages. If a {@link ParentSpanRef} + * is set in `turnState` under {@link A365_PARENT_SPAN_KEY}, spans are linked + * to that parent. + * @param context The context object for the turn. + * @param next The next middleware or handler to call. + */ + async onTurn(context: TurnContext, next: () => Promise): Promise { + const isAsyncReply = + context.activity?.type === ActivityTypes.Event && + context.activity?.name === ActivityEventNames.ContinueConversation; + + const agentDetails = ScopeUtils.deriveAgentDetails(context); + const tenantDetails = ScopeUtils.deriveTenantDetails(context); + + // If we can't derive required details, pass through without tracing + if (!agentDetails || !tenantDetails) { + await next(); + return; + } + + const callerDetails = ScopeUtils.deriveCallerDetails(context); + const conversationId = ScopeUtils.deriveConversationId(context); + const sourceMetadata = ScopeUtils.deriveSourceMetadataObject(context); + const executionTypePair = getExecutionTypePair(context); + const executionType = executionTypePair.length > 0 + ? parseExecutionType(executionTypePair[0][1]) + : undefined; + + // Register send activities handler for output tracing + if (this._logBotMessages) { + context.onSendActivities( + this._createSendHandler(context, agentDetails, tenantDetails, callerDetails, conversationId, sourceMetadata, executionType) + ); + } + + // For async replies, skip baggage and input tracing — just run next() + if (isAsyncReply) { + await next(); + return; + } + + // Build baggage context from turn context for propagation + const baggageScope = new BaggageBuilder() + .setPairs(getCallerBaggagePairs(context)) + .setPairs(getTargetAgentBaggagePairs(context)) + .setPairs(getTenantIdPair(context)) + .setPairs(getSourceMetadataBaggagePairs(context)) + .setPairs(getConversationIdAndItemLinkPairs(context)) + .setPairs(executionTypePair) + .build(); + + await baggageScope.run(async () => { + const shouldLogInput = this._logUserMessages && !!context.activity?.text; + + // Record start time before next() so the InputScope span reflects when the input arrived + const inputStartTime = shouldLogInput ? Date.now() : undefined; + let turnError: unknown; + + try { + await next(); + } catch (error) { + turnError = error; + } + + // Create InputScope after next() so we can read the parent span ref from turnState + if (shouldLogInput) { + const parentSpanRef: ParentSpanRef | undefined = context.turnState.get(A365_PARENT_SPAN_KEY); + const request = this._buildAgentRequest(context, executionType, sourceMetadata); + const inputScope = InputScope.start( + request, agentDetails, tenantDetails, callerDetails, conversationId, + parentSpanRef, inputStartTime + ); + if (turnError) { + inputScope.recordError( + turnError instanceof Error ? turnError : new Error(typeof turnError === 'string' ? turnError : JSON.stringify(turnError)) + ); + } + inputScope.dispose(); + } + }); + } + + /** + * Builds an AgentRequest from the TurnContext for the InputScope. + */ + private _buildAgentRequest( + context: TurnContext, + executionType?: ExecutionType, + sourceMetadata?: { name?: string; description?: string } + ): AgentRequest { + return { + content: context.activity?.text, + executionType, + sourceMetadata: (sourceMetadata?.name || sourceMetadata?.description) + ? { name: sourceMetadata?.name, description: sourceMetadata?.description } + : undefined, + }; + } + + /** + * Creates a handler for onSendActivities that wraps outgoing messages in OutputScope spans. + * Reads {@link A365_PARENT_SPAN_KEY} from turnState lazily at execution time so the + * agent handler has a chance to set it during `next()`. + */ + private _createSendHandler( + turnContext: TurnContext, + agentDetails: AgentDetails, + tenantDetails: TenantDetails, + callerDetails?: CallerDetails, + conversationId?: string, + sourceMetadata?: { name?: string; description?: string }, + executionType?: ExecutionType, + ): SendActivitiesHandler { + return async (_ctx, activities, sendNext) => { + // Collect text from message-type activities + const messages = activities + .filter((a) => a.type === 'message' && a.text) + .map((a) => a.text!); + + if (messages.length > 0) { + // Read parent span ref lazily — the agent handler sets it during next() + const parentSpanRef: ParentSpanRef | undefined = turnContext.turnState.get(A365_PARENT_SPAN_KEY); + const outputScope = OutputScope.start( + { messages }, + agentDetails, + tenantDetails, + callerDetails, + conversationId, + sourceMetadata, + executionType, + parentSpanRef, + ); + try { + return await sendNext(); + } catch (error) { + outputScope.recordError( + error instanceof Error ? error : new Error(typeof error === 'string' ? error : JSON.stringify(error)) + ); + } finally { + outputScope.dispose(); + } + } + + return await sendNext(); + }; + } +} diff --git a/packages/agents-a365-observability-hosting/src/middleware/ObservabilityMiddlewareRegistrar.ts b/packages/agents-a365-observability-hosting/src/middleware/ObservabilityMiddlewareRegistrar.ts new file mode 100644 index 00000000..c2db57f2 --- /dev/null +++ b/packages/agents-a365-observability-hosting/src/middleware/ObservabilityMiddlewareRegistrar.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Middleware } from '@microsoft/agents-hosting'; +import { MessageLoggingMiddleware, MessageLoggingMiddlewareOptions } from './MessageLoggingMiddleware'; + +/** + * Fluent builder for registering observability middleware on an adapter. + * + * @example + * ```typescript + * new ObservabilityMiddlewareRegistrar() + * .withMessageLogging() + * .apply(adapter); + * ``` + */ +export class ObservabilityMiddlewareRegistrar { + private readonly _middlewareFactories: Array<() => Middleware> = []; + + /** + * Configures message logging middleware for tracing input/output messages. + * @param options Optional configuration for message logging behavior. + * @returns This registrar instance for chaining. + */ + withMessageLogging(options?: MessageLoggingMiddlewareOptions): this { + this._middlewareFactories.push(() => new MessageLoggingMiddleware(options)); + return this; + } + + /** + * Instantiates and registers all configured middleware on the adapter. + * @param adapter The adapter to register middleware on. Must have a `use` method. + */ + apply(adapter: { use(...middlewares: Array): void }): void { + for (const createMiddleware of this._middlewareFactories) { + adapter.use(createMiddleware()); + } + } +} diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index 1f8531c3..1bbba3d5 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -15,7 +15,7 @@ import { InferenceDetails, InvokeAgentDetails, ToolCallDetails, - ExecutionType + parseExecutionType, } from '@microsoft/agents-a365-observability'; import { getExecutionTypePair, @@ -212,7 +212,7 @@ export class ScopeUtils { conversationId: ScopeUtils.deriveConversationId(turnContext), request: { ...baseRequest, - executionType: executionTypePair.length > 0 ? (executionTypePair[0][1] as ExecutionType) : baseRequest.executionType, + executionType: executionTypePair.length > 0 ? parseExecutionType(executionTypePair[0][1]) ?? baseRequest.executionType : baseRequest.executionType, sourceMetadata: mergedSourceMetadata } }; @@ -247,4 +247,5 @@ export class ScopeUtils { const scope = ExecuteToolScope.start(details, agent, tenant, conversationId, sourceMetadata, undefined, startTime, endTime); return scope; } + } diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index b86f9338..84fb1a6e 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -44,7 +44,8 @@ export { InferenceDetails, InferenceOperationType, InferenceResponse, - OutputResponse + OutputResponse, + parseExecutionType } from './tracing/contracts'; // Scopes @@ -53,6 +54,7 @@ export { ExecuteToolScope } from './tracing/scopes/ExecuteToolScope'; export { InvokeAgentScope } from './tracing/scopes/InvokeAgentScope'; export { InferenceScope } from './tracing/scopes/InferenceScope'; export { OutputScope } from './tracing/scopes/OutputScope'; +export { InputScope } from './tracing/scopes/InputScope'; export { logger, setLogger, getLogger, resetLogger, formatError } from './utils/logging'; export type { ILogger } from './utils/logging'; diff --git a/packages/agents-a365-observability/src/tracing/constants.ts b/packages/agents-a365-observability/src/tracing/constants.ts index c7e88b19..d1c00bc4 100644 --- a/packages/agents-a365-observability/src/tracing/constants.ts +++ b/packages/agents-a365-observability/src/tracing/constants.ts @@ -10,6 +10,7 @@ export class OpenTelemetryConstants { public static readonly INVOKE_AGENT_OPERATION_NAME = 'invoke_agent'; public static readonly EXECUTE_TOOL_OPERATION_NAME = 'execute_tool'; public static readonly OUTPUT_MESSAGES_OPERATION_NAME = 'output_messages'; + public static readonly INPUT_MESSAGES_OPERATION_NAME = 'input_messages'; public static readonly CHAT_OPERATION_NAME = 'chat'; // OpenTelemetry semantic conventions diff --git a/packages/agents-a365-observability/src/tracing/contracts.ts b/packages/agents-a365-observability/src/tracing/contracts.ts index bef4b494..bfc024eb 100644 --- a/packages/agents-a365-observability/src/tracing/contracts.ts +++ b/packages/agents-a365-observability/src/tracing/contracts.ts @@ -19,6 +19,19 @@ export enum ExecutionType { Unknown = 'Unknown' } +const _executionTypeValues = new Set(Object.values(ExecutionType)); + +/** + * Safely parse a string into an ExecutionType enum value. + * Returns the ExecutionType if the value is a valid member, otherwise undefined. + */ +export function parseExecutionType(value: string | undefined): ExecutionType | undefined { + if (value !== undefined && _executionTypeValues.has(value)) { + return value as ExecutionType; + } + return undefined; +} + /** * Represents different roles that can invoke an agent */ diff --git a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts index 3973cc91..57e1d3ff 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts @@ -81,7 +81,7 @@ export class InferenceScope extends OpenTelemetryScope { * @param messages Array of input messages */ public recordInputMessages(messages: string[]): void { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, messages.join(',')); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, JSON.stringify(messages)); } /** @@ -89,7 +89,7 @@ export class InferenceScope extends OpenTelemetryScope { * @param messages Array of output messages */ public recordOutputMessages(messages: string[]): void { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, messages.join(',')); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, JSON.stringify(messages)); } /** diff --git a/packages/agents-a365-observability/src/tracing/scopes/InputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InputScope.ts new file mode 100644 index 00000000..6af80059 --- /dev/null +++ b/packages/agents-a365-observability/src/tracing/scopes/InputScope.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SpanKind, TimeInput } from '@opentelemetry/api'; +import { OpenTelemetryScope } from './OpenTelemetryScope'; +import { AgentDetails, TenantDetails, CallerDetails, AgentRequest } from '../contracts'; +import { ParentContext } from '../context/trace-context-propagation'; +import { OpenTelemetryConstants } from '../constants'; + +/** + * Provides OpenTelemetry tracing scope for input message tracing with parent span linking. + */ +export class InputScope extends OpenTelemetryScope { + private _inputMessages: string[]; + private _inputMessagesDirty = false; + + /** + * Creates and starts a new scope for input message tracing. + * @param request The agent request containing the input content, execution type, and source metadata. + * @param agentDetails The details of the agent receiving the input. + * @param tenantDetails The tenant details. + * @param callerDetails Optional caller identity details (id, upn, name, tenant, client ip). + * @param conversationId Optional conversation identifier. + * @param parentContext Optional parent context for cross-async-boundary tracing. + * Accepts a ParentSpanRef (manual traceId/spanId) or an OTel Context (e.g. from extractTraceContext). + * @param startTime Optional explicit start time (ms epoch, Date, or HrTime). + * @param endTime Optional explicit end time (ms epoch, Date, or HrTime). + * @returns A new InputScope instance. + */ + public static start( + request: AgentRequest, + agentDetails: AgentDetails, + tenantDetails: TenantDetails, + callerDetails?: CallerDetails, + conversationId?: string, + parentContext?: ParentContext, + startTime?: TimeInput, + endTime?: TimeInput + ): InputScope { + return new InputScope(request, agentDetails, tenantDetails, callerDetails, conversationId, parentContext, startTime, endTime); + } + + private constructor( + request: AgentRequest, + agentDetails: AgentDetails, + tenantDetails: TenantDetails, + callerDetails?: CallerDetails, + conversationId?: string, + parentContext?: ParentContext, + startTime?: TimeInput, + endTime?: TimeInput + ) { + super( + SpanKind.CLIENT, + OpenTelemetryConstants.INPUT_MESSAGES_OPERATION_NAME, + agentDetails.agentName + ? `${OpenTelemetryConstants.INPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentName}` + : `${OpenTelemetryConstants.INPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentId}`, + agentDetails, + tenantDetails, + parentContext, + startTime, + endTime + ); + + // Initialize accumulated messages list from the request content + this._inputMessages = request.content ? [request.content] : []; + + // Set initial input messages attribute + if (this._inputMessages.length > 0) { + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, + JSON.stringify(this._inputMessages) + ); + } + + // Set execution type if provided + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_EXECUTION_TYPE_KEY, + request.executionType + ); + + // Set source metadata if provided + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_NAME_KEY, + request.sourceMetadata?.name + ); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, + request.sourceMetadata?.description + ); + + // Set conversation id + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, conversationId); + + // Set caller details if provided + if (callerDetails) { + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_ID_KEY, callerDetails.callerId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_UPN_KEY, callerDetails.callerUpn); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_NAME_KEY, callerDetails.callerName); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_TENANT_ID_KEY, callerDetails.tenantId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, callerDetails.callerClientIp); + } + } + + /** + * Records the input messages for telemetry tracking. + * Appends the provided messages to the accumulated input messages list. + * The updated attribute is flushed when the scope is disposed. + * @param messages Array of input messages to append. + */ + public recordInputMessages(messages: string[]): void { + this._inputMessages.push(...messages); + this._inputMessagesDirty = true; + } + + public override [Symbol.dispose](): void { + if (this._inputMessagesDirty) { + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, + JSON.stringify(this._inputMessages) + ); + } + super[Symbol.dispose](); + } + + public override dispose(): void { + this[Symbol.dispose](); + } +} diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts index cc161466..3e4b5692 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -125,7 +125,7 @@ export class InvokeAgentScope extends OpenTelemetryScope { * @param messages Array of input messages */ public recordInputMessages(messages: string[]): void { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, messages.join(',')); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, JSON.stringify(messages)); } /** @@ -133,6 +133,6 @@ export class InvokeAgentScope extends OpenTelemetryScope { * @param messages Array of output messages */ public recordOutputMessages(messages: string[]): void { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, messages.join(',')); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, JSON.stringify(messages)); } } diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index 9888b456..145e31b5 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts @@ -3,7 +3,7 @@ import { SpanKind, TimeInput } from '@opentelemetry/api'; import { OpenTelemetryScope } from './OpenTelemetryScope'; -import { AgentDetails, TenantDetails, OutputResponse } from '../contracts'; +import { AgentDetails, TenantDetails, CallerDetails, OutputResponse, SourceMetadata, ExecutionType } from '../contracts'; import { ParentContext } from '../context/trace-context-propagation'; import { OpenTelemetryConstants } from '../constants'; @@ -12,12 +12,17 @@ import { OpenTelemetryConstants } from '../constants'; */ export class OutputScope extends OpenTelemetryScope { private _outputMessages: string[]; + private _outputMessagesDirty = false; /** * Creates and starts a new scope for output message tracing. * @param response The response containing initial output messages. * @param agentDetails The details of the agent producing the output. * @param tenantDetails The tenant details. + * @param callerDetails Optional caller identity details (id, upn, name, tenant, client ip). + * @param conversationId Optional conversation identifier. + * @param sourceMetadata Optional source metadata; only `name` and `description` are used for tagging. + * @param executionType Optional execution type (HumanToAgent, Agent2Agent, etc.). * @param parentContext Optional parent context for cross-async-boundary tracing. * Accepts a ParentSpanRef (manual traceId/spanId) or an OTel Context (e.g. from extractTraceContext). * @param startTime Optional explicit start time (ms epoch, Date, or HrTime). @@ -28,17 +33,25 @@ export class OutputScope extends OpenTelemetryScope { response: OutputResponse, agentDetails: AgentDetails, tenantDetails: TenantDetails, + callerDetails?: CallerDetails, + conversationId?: string, + sourceMetadata?: Pick, + executionType?: ExecutionType, parentContext?: ParentContext, startTime?: TimeInput, endTime?: TimeInput ): OutputScope { - return new OutputScope(response, agentDetails, tenantDetails, parentContext, startTime, endTime); + return new OutputScope(response, agentDetails, tenantDetails, callerDetails, conversationId, sourceMetadata, executionType, parentContext, startTime, endTime); } private constructor( response: OutputResponse, agentDetails: AgentDetails, tenantDetails: TenantDetails, + callerDetails?: CallerDetails, + conversationId?: string, + sourceMetadata?: Pick, + executionType?: ExecutionType, parentContext?: ParentContext, startTime?: TimeInput, endTime?: TimeInput @@ -46,7 +59,9 @@ export class OutputScope extends OpenTelemetryScope { super( SpanKind.CLIENT, OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME, - `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentId}`, + agentDetails.agentName + ? `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentName}` + : `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentId}`, agentDetails, tenantDetails, parentContext, @@ -62,18 +77,45 @@ export class OutputScope extends OpenTelemetryScope { OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, JSON.stringify(this._outputMessages) ); + + // Set conversation, execution type, and source metadata + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, conversationId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_EXECUTION_TYPE_KEY, executionType); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_NAME_KEY, sourceMetadata?.name); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, sourceMetadata?.description); + + // Set caller details if provided + if (callerDetails) { + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_ID_KEY, callerDetails.callerId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_UPN_KEY, callerDetails.callerUpn); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_NAME_KEY, callerDetails.callerName); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_TENANT_ID_KEY, callerDetails.tenantId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, callerDetails.callerClientIp); + } } /** * Records the output messages for telemetry tracking. * Appends the provided messages to the accumulated output messages list. + * The updated attribute is flushed when the scope is disposed. * @param messages Array of output messages to append. */ public recordOutputMessages(messages: string[]): void { this._outputMessages.push(...messages); - this.setTagMaybe( - OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, - JSON.stringify(this._outputMessages) - ); + this._outputMessagesDirty = true; + } + + public override [Symbol.dispose](): void { + if (this._outputMessagesDirty) { + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, + JSON.stringify(this._outputMessages) + ); + } + super[Symbol.dispose](); + } + + public override dispose(): void { + this[Symbol.dispose](); } } diff --git a/tests/observability/core/input-scope.test.ts b/tests/observability/core/input-scope.test.ts new file mode 100644 index 00000000..bfc22787 --- /dev/null +++ b/tests/observability/core/input-scope.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { trace, context as otelContext } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; + +import { + InputScope, + AgentDetails, + TenantDetails, + CallerDetails, + AgentRequest, + OpenTelemetryConstants, + ParentSpanRef, +} from '@microsoft/agents-a365-observability'; + +describe('InputScope', () => { + const testAgentDetails: AgentDetails = { + agentId: 'test-agent-123', + agentName: 'Test Agent', + agentDescription: 'A test agent for input scope testing', + }; + + const testTenantDetails: TenantDetails = { + tenantId: '12345678-1234-5678-1234-567812345678', + }; + + let exporter: InMemorySpanExporter; + let provider: BasicTracerProvider; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let flushProvider: any; + 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); + flushProvider = globalProvider; + } else { + provider = new BasicTracerProvider({ + spanProcessors: [processor] + }); + trace.setGlobalTracerProvider(provider); + flushProvider = provider; + } + }); + + beforeEach(() => { + exporter.reset(); + }); + + afterAll(async () => { + exporter.reset(); + await provider?.shutdown?.(); + contextManager.disable(); + otelContext.disable(); + }); + + function getLastSpan() { + const spans = exporter.getFinishedSpans(); + expect(spans.length).toBeGreaterThan(0); + const span = spans[spans.length - 1]; + return { span, attributes: span.attributes }; + } + + it('should create scope with correct span attributes', async () => { + const request: AgentRequest = { content: 'Hello agent' }; + + const scope = InputScope.start(request, testAgentDetails, testTenantDetails); + expect(scope).toBeInstanceOf(InputScope); + scope.dispose(); + + await flushProvider.forceFlush(); + const { span, attributes } = getLastSpan(); + + expect(span.name).toBe('input_messages Test Agent'); + expect(attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe('input_messages'); + expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(testAgentDetails.agentId); + expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe(testAgentDetails.agentName); + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(testTenantDetails.tenantId); + expect(JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string)).toEqual(['Hello agent']); + }); + + it('should not set input messages when content is missing or empty', async () => { + for (const content of [undefined, '']) { + exporter.reset(); + const request: AgentRequest = content === undefined ? {} : { content }; + const scope = InputScope.start(request, testAgentDetails, testTenantDetails); + scope.dispose(); + + await flushProvider.forceFlush(); + const { attributes } = getLastSpan(); + expect(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY]).toBeUndefined(); + } + }); + + it('should append messages with recordInputMessages and flush on dispose', async () => { + const request: AgentRequest = { content: 'Initial' }; + const scope = InputScope.start(request, testAgentDetails, testTenantDetails); + + scope.recordInputMessages(['Appended 1']); + scope.recordInputMessages(['Appended 2', 'Appended 3']); + scope.dispose(); + + await flushProvider.forceFlush(); + const { attributes } = getLastSpan(); + const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string); + expect(parsed).toEqual(['Initial', 'Appended 1', 'Appended 2', 'Appended 3']); + }); + + it('should use parent span reference for linking', async () => { + const parentTraceId = '1234567890abcdef1234567890abcdef'; + const parentSpanId = 'abcdefabcdef1234'; + + const scope = InputScope.start( + { content: 'Test' }, testAgentDetails, testTenantDetails, + undefined, undefined, { traceId: parentTraceId, spanId: parentSpanId } as ParentSpanRef + ); + scope.dispose(); + + await flushProvider.forceFlush(); + const { span } = getLastSpan(); + expect(span.spanContext().traceId).toBe(parentTraceId); + expect(span.parentSpanContext?.spanId).toBe(parentSpanId); + }); + + it('should set caller details and conversationId on the span', async () => { + const callerDetails: CallerDetails = { + callerId: 'caller-oid-123', + callerUpn: 'caller@contoso.com', + callerName: 'Test Caller', + tenantId: 'caller-tenant-456', + callerClientIp: '10.0.0.1', + }; + + const scope = InputScope.start( + { content: 'Test' }, testAgentDetails, testTenantDetails, + callerDetails, 'conv-42' + ); + scope.dispose(); + + await flushProvider.forceFlush(); + const { attributes } = getLastSpan(); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_ID_KEY]).toBe('caller-oid-123'); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_UPN_KEY]).toBe('caller@contoso.com'); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_NAME_KEY]).toBe('Test Caller'); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_TENANT_ID_KEY]).toBe('caller-tenant-456'); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY]).toBe('10.0.0.1'); + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe('conv-42'); + }); + + it('should use agentName in span name, falling back to agentId', async () => { + const scope1 = InputScope.start({ content: 'Test' }, { agentId: 'id-1', agentName: 'My Agent' }, testTenantDetails); + scope1.dispose(); + await flushProvider.forceFlush(); + expect(getLastSpan().span.name).toBe('input_messages My Agent'); + + exporter.reset(); + const scope2 = InputScope.start({ content: 'Test' }, { agentId: 'id-only' }, testTenantDetails); + scope2.dispose(); + await flushProvider.forceFlush(); + expect(getLastSpan().span.name).toBe('input_messages id-only'); + }); +}); diff --git a/tests/observability/core/output-scope.test.ts b/tests/observability/core/output-scope.test.ts index 42d1b484..a706931a 100644 --- a/tests/observability/core/output-scope.test.ts +++ b/tests/observability/core/output-scope.test.ts @@ -72,129 +72,53 @@ describe('OutputScope', () => { return { span, attributes: span.attributes }; } - it('should create scope with output messages', async () => { + it('should create scope with correct span attributes and output messages', async () => { const response: OutputResponse = { messages: ['First message', 'Second message'] }; const scope = OutputScope.start(response, testAgentDetails, testTenantDetails); - expect(scope).toBeInstanceOf(OutputScope); scope.dispose(); await flushProvider.forceFlush(); const { span, attributes } = getLastSpan(); - // Verify span name contains operation name and agent id - expect(span.name).toContain('output_messages'); - expect(span.name).toContain(testAgentDetails.agentId); - - // Verify output messages are set as JSON array - const outputValue = attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string; - expect(outputValue).toBeDefined(); - const parsed = JSON.parse(outputValue); + expect(span.name).toBe('output_messages Test Agent'); + expect(attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe('output_messages'); + expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(testAgentDetails.agentId); + expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe(testAgentDetails.agentName); + const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); expect(parsed).toEqual(['First message', 'Second message']); }); - it('should record output messages by appending to accumulated messages', async () => { + it('should append messages with recordOutputMessages and flush on dispose', async () => { const response: OutputResponse = { messages: ['Initial'] }; const scope = OutputScope.start(response, testAgentDetails, testTenantDetails); - scope.recordOutputMessages(['Appended 1']); scope.recordOutputMessages(['Appended 2', 'Appended 3']); - scope.dispose(); await flushProvider.forceFlush(); const { attributes } = getLastSpan(); - const outputValue = attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string; - expect(outputValue).toBeDefined(); - // All messages should be present (initial + all appended) as JSON array - const parsed = JSON.parse(outputValue); + const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); expect(parsed).toEqual(['Initial', 'Appended 1', 'Appended 2', 'Appended 3']); }); - it('should use parent span reference to link span to parent context', async () => { - const response: OutputResponse = { messages: ['Test'] }; + it('should use parent span reference for linking', async () => { const parentTraceId = '1234567890abcdef1234567890abcdef'; const parentSpanId = 'abcdefabcdef1234'; - const parentSpanRef: ParentSpanRef = { - traceId: parentTraceId, - spanId: parentSpanId, - }; - - const scope = OutputScope.start(response, testAgentDetails, testTenantDetails, parentSpanRef); + const scope = OutputScope.start( + { messages: ['Test'] }, testAgentDetails, testTenantDetails, + undefined, undefined, undefined, undefined, + { traceId: parentTraceId, spanId: parentSpanId } as ParentSpanRef + ); scope.dispose(); await flushProvider.forceFlush(); const { span } = getLastSpan(); - - // Verify span inherits parent's trace_id expect(span.spanContext().traceId).toBe(parentTraceId); - - // Verify span's parent_span_id matches expect(span.parentSpanContext?.spanId).toBe(parentSpanId); }); - - it('should end the span on dispose', async () => { - const response: OutputResponse = { messages: ['Test'] }; - - const scope = OutputScope.start(response, testAgentDetails, testTenantDetails); - scope.dispose(); - - await flushProvider.forceFlush(); - - // Verify span was created and ended - const spans = exporter.getFinishedSpans(); - expect(spans.length).toBe(1); - }); - - it('should create scope with empty messages', async () => { - const response: OutputResponse = { messages: [] }; - - const scope = OutputScope.start(response, testAgentDetails, testTenantDetails); - scope.dispose(); - - await flushProvider.forceFlush(); - const { span } = getLastSpan(); - expect(span.name).toContain('output_messages'); - }); - - it('should set gen_ai.operation.name to output_messages', async () => { - const response: OutputResponse = { messages: ['Test'] }; - - const scope = OutputScope.start(response, testAgentDetails, testTenantDetails); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - expect(attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe('output_messages'); - }); - - it('should set agent details on the span', async () => { - const response: OutputResponse = { messages: ['Test'] }; - - const scope = OutputScope.start(response, testAgentDetails, testTenantDetails); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(testAgentDetails.agentId); - expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe(testAgentDetails.agentName); - }); - - it('should not throw when recordOutputMessages is called multiple times', () => { - const response: OutputResponse = { messages: ['Initial'] }; - - const scope = OutputScope.start(response, testAgentDetails, testTenantDetails); - - expect(() => { - scope.recordOutputMessages(['Message 1']); - scope.recordOutputMessages(['Message 2']); - scope.recordOutputMessages(['Message 3']); - }).not.toThrow(); - - scope.dispose(); - }); }); diff --git a/tests/observability/extension/hosting/message-logging-middleware.test.ts b/tests/observability/extension/hosting/message-logging-middleware.test.ts new file mode 100644 index 00000000..814cb802 --- /dev/null +++ b/tests/observability/extension/hosting/message-logging-middleware.test.ts @@ -0,0 +1,388 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { trace, context as otelContext, SpanStatusCode, propagation } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { RoleTypes, ActivityTypes, ActivityEventNames } from '@microsoft/agents-activity'; +import type { TurnContext, SendActivitiesHandler } from '@microsoft/agents-hosting'; + +import { MessageLoggingMiddleware, A365_PARENT_SPAN_KEY } from '../../../../packages/agents-a365-observability-hosting/src/middleware/MessageLoggingMiddleware'; +import { OpenTelemetryConstants, ParentSpanRef } from '@microsoft/agents-a365-observability'; + +function makeMockTurnContext(options?: { + text?: string; + recipientId?: string; + recipientName?: string; + recipientTenantId?: string; + channelId?: string; + conversationId?: string; + fromRole?: string; + activityType?: string; + activityName?: string; +}): TurnContext & { _sendHandlers: SendActivitiesHandler[]; turnState: Map; simulateSend: (activities: Array<{ type?: string; text?: string }>) => Promise> } { + const sendHandlers: SendActivitiesHandler[] = []; + + const ctx: any = { + activity: { + type: options?.activityType, + name: options?.activityName, + text: options?.text ?? 'Hello agent', + channelId: options?.channelId ?? 'web', + conversation: { id: options?.conversationId ?? 'conv-001' }, + from: { + role: options?.fromRole ?? RoleTypes.User, + aadObjectId: 'user-oid', + name: 'Test User', + agenticUserId: 'user@contoso.com', + tenantId: 'from-tenant', + }, + recipient: { + agenticAppId: options?.recipientId ?? 'agent-1', + name: options?.recipientName ?? 'Agent One', + aadObjectId: 'agent-oid', + agenticAppBlueprintId: 'blueprint-1', + agenticUserId: 'agent@contoso.com', + role: 'assistant', + tenantId: options?.recipientTenantId ?? 'tenant-123', + }, + }, + turnState: new Map(), + onSendActivities(handler: SendActivitiesHandler) { + sendHandlers.push(handler); + return ctx; + }, + _sendHandlers: sendHandlers, + 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, activities as any, prev as any); + } + return await current(); + }, + }; + + return ctx; +} + +describe('MessageLoggingMiddleware', () => { + let exporter: InMemorySpanExporter; + let provider: BasicTracerProvider; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let flushProvider: any; + 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); + flushProvider = globalProvider; + } else { + provider = new BasicTracerProvider({ + spanProcessors: [processor] + }); + trace.setGlobalTracerProvider(provider); + flushProvider = provider; + } + }); + + beforeEach(() => { + exporter.reset(); + }); + + afterAll(async () => { + exporter.reset(); + await provider?.shutdown?.(); + contextManager.disable(); + otelContext.disable(); + }); + + it('should create InputScope and OutputScope as sibling spans', async () => { + const middleware = new MessageLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: 'Hello' }); + let nextCalled = false; + + await middleware.onTurn(ctx, async () => { + nextCalled = true; + await ctx.simulateSend([{ type: 'message', text: 'Hi there!' }]); + }); + + expect(nextCalled).toBe(true); + + await flushProvider.forceFlush(); + const spans = exporter.getFinishedSpans(); + const inputSpan = spans.find(s => s.name.includes('input_messages')); + const outputSpan = spans.find(s => s.name.includes('output_messages')); + + expect(inputSpan).toBeDefined(); + expect(inputSpan!.attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY]).toBe(JSON.stringify(['Hello'])); + + expect(outputSpan).toBeDefined(); + expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY]).toBe(JSON.stringify(['Hi there!'])); + + // Without A365_PARENT_SPAN_KEY set, both spans are independent root spans + expect(inputSpan!.parentSpanContext).toBeUndefined(); + expect(outputSpan!.parentSpanContext).toBeUndefined(); + }); + + it('should record error on InputScope when turn throws without re-throwing', async () => { + const middleware = new MessageLoggingMiddleware(); + + // Error instance — middleware records but does not re-throw + const ctx1 = makeMockTurnContext({ text: 'Hello' }); + await middleware.onTurn(ctx1, async () => { throw new Error('Something went wrong'); }); + + await flushProvider.forceFlush(); + const inputSpan1 = exporter.getFinishedSpans().find(s => s.name.includes('input_messages')); + expect(inputSpan1!.status.code).toBe(SpanStatusCode.ERROR); + expect(inputSpan1!.status.message).toBe('Something went wrong'); + + // Non-Error value + exporter.reset(); + const ctx2 = makeMockTurnContext({ text: 'Hello' }); + await middleware.onTurn(ctx2, async () => { throw 'string error'; }); + + await flushProvider.forceFlush(); + const inputSpan2 = exporter.getFinishedSpans().find(s => s.name.includes('input_messages')); + expect(inputSpan2!.status.code).toBe(SpanStatusCode.ERROR); + expect(inputSpan2!.status.message).toBe('string error'); + }); + + it('should skip non-message activities in OutputScope', async () => { + const middleware = new MessageLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: 'Hello' }); + + await middleware.onTurn(ctx, async () => { + await ctx.simulateSend([ + { type: 'typing' }, + { type: 'event', text: 'some event data' }, + ]); + }); + + await flushProvider.forceFlush(); + expect(exporter.getFinishedSpans().find(s => s.name.includes('output_messages'))).toBeUndefined(); + }); + + it('should respect logUserMessages and logBotMessages options', async () => { + // logUserMessages: false — no InputScope + const mw1 = new MessageLoggingMiddleware({ logUserMessages: false }); + const ctx1 = makeMockTurnContext({ text: 'Hello' }); + let nextCalled = false; + await mw1.onTurn(ctx1, async () => { nextCalled = true; }); + expect(nextCalled).toBe(true); + await flushProvider.forceFlush(); + expect(exporter.getFinishedSpans().find(s => s.name.includes('input_messages'))).toBeUndefined(); + + // logBotMessages: false — no send handler registered + exporter.reset(); + const mw2 = new MessageLoggingMiddleware({ logBotMessages: false }); + const ctx2 = makeMockTurnContext({ text: 'Hello' }); + await mw2.onTurn(ctx2, async () => { + expect(ctx2._sendHandlers.length).toBe(0); + }); + }); + + it('should pass through without tracing for skip conditions', async () => { + const middleware = new MessageLoggingMiddleware(); + + // Missing agent details (no recipient) + const ctx1: any = { activity: { text: 'Hello' }, onSendActivities: jest.fn() }; + let nextCalled = false; + await middleware.onTurn(ctx1, async () => { nextCalled = true; }); + expect(nextCalled).toBe(true); + + // Missing tenant details (recipient but no tenantId) + const ctx2: any = { + activity: { text: 'Hello', recipient: { agenticAppId: 'agent-1', name: 'Agent' } }, + onSendActivities: jest.fn(), + }; + nextCalled = false; + await middleware.onTurn(ctx2, async () => { nextCalled = true; }); + expect(nextCalled).toBe(true); + + // No input text — next() called but no InputScope + const ctx3: any = { + activity: { + channelId: 'web', + conversation: { id: 'conv-001' }, + from: { role: RoleTypes.User }, + recipient: { agenticAppId: 'agent-1', name: 'Agent One', tenantId: 'tenant-123' }, + }, + turnState: new Map(), + onSendActivities: jest.fn().mockReturnThis(), + }; + nextCalled = false; + await middleware.onTurn(ctx3, async () => { nextCalled = true; }); + expect(nextCalled).toBe(true); + + // ContinueConversation event — only output tracing (no input/baggage) + const ctx4: any = { + activity: { + type: ActivityTypes.Event, + name: ActivityEventNames.ContinueConversation, + text: 'Hello', + recipient: { agenticAppId: 'agent-1', name: 'Agent One', tenantId: 'tenant-123' }, + from: { role: RoleTypes.User, aadObjectId: 'user-oid', name: 'User' }, + conversation: { id: 'conv-001' }, + }, + turnState: new Map(), + onSendActivities: jest.fn().mockReturnThis(), + }; + nextCalled = false; + await middleware.onTurn(ctx4, async () => { nextCalled = true; }); + expect(nextCalled).toBe(true); + // ContinueConversation registers output handler but skips input/baggage + expect(ctx4.onSendActivities).toHaveBeenCalledTimes(1); + + await flushProvider.forceFlush(); + expect(exporter.getFinishedSpans().length).toBe(0); + }); + + it('should set caller details and enrichment on OutputScope span', async () => { + const middleware = new MessageLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: 'Hello', channelId: 'teams' }); + + await middleware.onTurn(ctx, async () => { + 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.GEN_AI_CALLER_ID_KEY]).toBe('user-oid'); + expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_CALLER_NAME_KEY]).toBe('Test User'); + expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_EXECUTION_TYPE_KEY]).toBe('HumanToAgent'); + expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_NAME_KEY]).toBe('teams'); + }); + + it('should propagate baggage context during turn', async () => { + const middleware = new MessageLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: 'Hello' }); + let 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.GEN_AI_CALLER_ID_KEY]).toBe('user-oid'); + expect(capturedBaggage[OpenTelemetryConstants.TENANT_ID_KEY]).toBe('tenant-123'); + expect(capturedBaggage[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe('agent-1'); + }); + + it('should link InputScope and OutputScope to parent when parentSpanRef is set in turnState', async () => { + const middleware = new MessageLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: 'Hello' }); + + const parentSpanRef: ParentSpanRef = { + traceId: '0af7651916cd43dd8448eb211c80319c', + spanId: 'b7ad6b7169203331', + traceFlags: 1, + }; + + await middleware.onTurn(ctx, async () => { + // Agent handler sets parent span ref during next() — both scopes read it lazily + ctx.turnState.set(A365_PARENT_SPAN_KEY, parentSpanRef); + await ctx.simulateSend([{ type: 'message', text: 'Reply' }]); + }); + + await flushProvider.forceFlush(); + const spans = exporter.getFinishedSpans(); + const inputSpan = spans.find(s => s.name.includes('input_messages')); + const outputSpan = spans.find(s => s.name.includes('output_messages')); + + expect(inputSpan).toBeDefined(); + expect(outputSpan).toBeDefined(); + + // Both InputScope and OutputScope read parentSpanRef from turnState after next() + expect(inputSpan!.parentSpanContext?.traceId).toBe(parentSpanRef.traceId); + expect(inputSpan!.parentSpanContext?.spanId).toBe(parentSpanRef.spanId); + expect(outputSpan!.parentSpanContext?.traceId).toBe(parentSpanRef.traceId); + expect(outputSpan!.parentSpanContext?.spanId).toBe(parentSpanRef.spanId); + }); + + it('should create OutputScope for async reply (ContinueConversation) when messages are sent', async () => { + const middleware = new MessageLoggingMiddleware(); + const ctx = makeMockTurnContext({ + text: 'Hello', + activityType: ActivityTypes.Event, + activityName: ActivityEventNames.ContinueConversation, + }); + + await middleware.onTurn(ctx, async () => { + await ctx.simulateSend([{ type: 'message', text: 'Async reply' }]); + }); + + await flushProvider.forceFlush(); + const spans = exporter.getFinishedSpans(); + + // No InputScope for async replies + expect(spans.find(s => s.name.includes('input_messages'))).toBeUndefined(); + + // OutputScope is created for the sent message + const outputSpan = spans.find(s => s.name.includes('output_messages')); + expect(outputSpan).toBeDefined(); + expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY]).toBe(JSON.stringify(['Async reply'])); + }); + + it('should link async reply OutputScope to parent when parentSpanRef is set', async () => { + const middleware = new MessageLoggingMiddleware(); + const ctx = makeMockTurnContext({ + text: 'Hello', + activityType: ActivityTypes.Event, + activityName: ActivityEventNames.ContinueConversation, + }); + + const parentSpanRef: ParentSpanRef = { + traceId: '1af7651916cd43dd8448eb211c80319c', + spanId: 'c7ad6b7169203331', + traceFlags: 1, + }; + ctx.turnState.set(A365_PARENT_SPAN_KEY, parentSpanRef); + + await middleware.onTurn(ctx, async () => { + await ctx.simulateSend([{ type: 'message', text: 'Async reply' }]); + }); + + await flushProvider.forceFlush(); + const spans = exporter.getFinishedSpans(); + const outputSpan = spans.find(s => s.name.includes('output_messages')); + + expect(outputSpan).toBeDefined(); + expect(outputSpan!.parentSpanContext?.traceId).toBe(parentSpanRef.traceId); + expect(outputSpan!.parentSpanContext?.spanId).toBe(parentSpanRef.spanId); + }); + + it('should not create spans for async reply when no messages are sent', async () => { + const middleware = new MessageLoggingMiddleware(); + const ctx = makeMockTurnContext({ + text: 'Hello', + activityType: ActivityTypes.Event, + activityName: ActivityEventNames.ContinueConversation, + }); + + await middleware.onTurn(ctx, async () => { + // next() without sending any messages + }); + + await flushProvider.forceFlush(); + expect(exporter.getFinishedSpans().length).toBe(0); + }); +}); diff --git a/tests/observability/extension/hosting/observability-middleware-registrar.test.ts b/tests/observability/extension/hosting/observability-middleware-registrar.test.ts new file mode 100644 index 00000000..b4403c9a --- /dev/null +++ b/tests/observability/extension/hosting/observability-middleware-registrar.test.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from '@jest/globals'; +import { ObservabilityMiddlewareRegistrar } from '../../../../packages/agents-a365-observability-hosting/src/middleware/ObservabilityMiddlewareRegistrar'; +import { MessageLoggingMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/MessageLoggingMiddleware'; + +describe('ObservabilityMiddlewareRegistrar', () => { + it('should register middleware on adapter via chained withMessageLogging() and apply()', () => { + const registered: any[] = []; + const mockAdapter = { use(...middlewares: any[]) { registered.push(...middlewares); } }; + + const registrar = new ObservabilityMiddlewareRegistrar(); + const result = registrar.withMessageLogging(); + expect(result).toBe(registrar); // chaining returns this + + registrar.apply(mockAdapter); + + expect(registered.length).toBe(1); + expect(registered[0]).toBeInstanceOf(MessageLoggingMiddleware); + }); + + it('should register multiple middleware instances', () => { + const registered: any[] = []; + const mockAdapter = { use(...middlewares: any[]) { registered.push(...middlewares); } }; + + new ObservabilityMiddlewareRegistrar() + .withMessageLogging() + .withMessageLogging({ logUserMessages: false }) + .apply(mockAdapter); + + expect(registered.length).toBe(2); + }); + + it('should not call adapter.use when no middleware is configured', () => { + const useFn = jest.fn(); + new ObservabilityMiddlewareRegistrar().apply({ use: useFn }); + expect(useFn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/observability/extension/hosting/scope-utils.test.ts b/tests/observability/extension/hosting/scope-utils.test.ts index a5ad82f7..27cb6439 100644 --- a/tests/observability/extension/hosting/scope-utils.test.ts +++ b/tests/observability/extension/hosting/scope-utils.test.ts @@ -73,7 +73,7 @@ describe('ScopeUtils.populateFromTurnContext', () => { [OpenTelemetryConstants.GEN_AI_AGENT_UPN_KEY, 'agent-upn@contoso.com'], [OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, 'assistant'], [OpenTelemetryConstants.TENANT_ID_KEY, 'tenant-123'], - [OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, 'input text'] + [OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, JSON.stringify(['input text'])] ]) ); scope?.dispose(); @@ -139,7 +139,7 @@ describe('ScopeUtils.populateFromTurnContext', () => { [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, 'caller-agentBlueprintId'], [OpenTelemetryConstants.TENANT_ID_KEY, 'tenant-123'], [OpenTelemetryConstants.GEN_AI_EXECUTION_TYPE_KEY, ExecutionType.Agent2Agent.toString()], - [OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, 'invoke message'], + [OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, JSON.stringify(['invoke message'])], [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, 'agent-1'], [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, 'Agent One'], [OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, 'assistant'] From ccf4db595b8148b1b0c65a5d43e093156ce38533 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Wed, 25 Feb 2026 13:44:29 -0800 Subject: [PATCH 2/4] comments --- .../src/index.ts | 7 +- .../src/middleware/BaggageMiddleware.ts | 45 ++++ .../middleware/MessageLoggingMiddleware.ts | 230 ------------------ .../middleware/ObservabilityHostingManager.ts | 77 ++++++ .../ObservabilityMiddlewareRegistrar.ts | 39 --- .../src/middleware/OutputLoggingMiddleware.ts | 108 ++++++++ .../agents-a365-observability/src/index.ts | 1 - .../src/tracing/constants.ts | 1 - .../src/tracing/scopes/InputScope.ts | 130 ---------- tests/observability/core/input-scope.test.ts | 174 ------------- .../hosting/baggage-middleware.test.ts | 138 +++++++++++ .../observability-hosting-manager.test.ts | 53 ++++ ...observability-middleware-registrar.test.ts | 40 --- ...t.ts => output-logging-middleware.test.ts} | 184 +++----------- 14 files changed, 465 insertions(+), 762 deletions(-) create mode 100644 packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware.ts delete mode 100644 packages/agents-a365-observability-hosting/src/middleware/MessageLoggingMiddleware.ts create mode 100644 packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts delete mode 100644 packages/agents-a365-observability-hosting/src/middleware/ObservabilityMiddlewareRegistrar.ts create mode 100644 packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts delete mode 100644 packages/agents-a365-observability/src/tracing/scopes/InputScope.ts delete mode 100644 tests/observability/core/input-scope.test.ts create mode 100644 tests/observability/extension/hosting/baggage-middleware.test.ts create mode 100644 tests/observability/extension/hosting/observability-hosting-manager.test.ts delete mode 100644 tests/observability/extension/hosting/observability-middleware-registrar.test.ts rename tests/observability/extension/hosting/{message-logging-middleware.test.ts => output-logging-middleware.test.ts} (57%) diff --git a/packages/agents-a365-observability-hosting/src/index.ts b/packages/agents-a365-observability-hosting/src/index.ts index 2175304e..5f7113e5 100644 --- a/packages/agents-a365-observability-hosting/src/index.ts +++ b/packages/agents-a365-observability-hosting/src/index.ts @@ -7,6 +7,7 @@ export * from './utils/BaggageBuilderUtils'; export * from './utils/ScopeUtils'; export * from './utils/TurnContextUtils'; export { AgenticTokenCache, AgenticTokenCacheInstance } from './caching/AgenticTokenCache'; -export { MessageLoggingMiddleware, A365_PARENT_SPAN_KEY } from './middleware/MessageLoggingMiddleware'; -export type { MessageLoggingMiddlewareOptions } from './middleware/MessageLoggingMiddleware'; -export { ObservabilityMiddlewareRegistrar } from './middleware/ObservabilityMiddlewareRegistrar'; +export { BaggageMiddleware } from './middleware/BaggageMiddleware'; +export { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY } from './middleware/OutputLoggingMiddleware'; +export { ObservabilityHostingManager } from './middleware/ObservabilityHostingManager'; +export type { ObservabilityHostingOptions } from './middleware/ObservabilityHostingManager'; diff --git a/packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware.ts new file mode 100644 index 00000000..344f67c8 --- /dev/null +++ b/packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TurnContext, Middleware } from '@microsoft/agents-hosting'; +import { ActivityTypes, ActivityEventNames } from '@microsoft/agents-activity'; +import { BaggageBuilder } from '@microsoft/agents-a365-observability'; +import { + getExecutionTypePair, + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getSourceMetadataBaggagePairs, + getConversationIdAndItemLinkPairs, +} from '../utils/TurnContextUtils'; + +/** + * Middleware that propagates OpenTelemetry baggage context derived from TurnContext. + * Async replies (ContinueConversation) are passed through without baggage setup. + */ +export class BaggageMiddleware implements Middleware { + + async onTurn(context: TurnContext, next: () => Promise): Promise { + const isAsyncReply = + context.activity?.type === ActivityTypes.Event && + context.activity?.name === ActivityEventNames.ContinueConversation; + + if (isAsyncReply) { + await next(); + return; + } + + const baggageScope = new BaggageBuilder() + .setPairs(getCallerBaggagePairs(context)) + .setPairs(getTargetAgentBaggagePairs(context)) + .setPairs(getTenantIdPair(context)) + .setPairs(getSourceMetadataBaggagePairs(context)) + .setPairs(getConversationIdAndItemLinkPairs(context)) + .setPairs(getExecutionTypePair(context)) + .build(); + + await baggageScope.run(async () => { + await next(); + }); + } +} diff --git a/packages/agents-a365-observability-hosting/src/middleware/MessageLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/MessageLoggingMiddleware.ts deleted file mode 100644 index f79a5edc..00000000 --- a/packages/agents-a365-observability-hosting/src/middleware/MessageLoggingMiddleware.ts +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { TurnContext, Middleware, SendActivitiesHandler } from '@microsoft/agents-hosting'; -import { ActivityTypes, ActivityEventNames } from '@microsoft/agents-activity'; -import { - InputScope, - OutputScope, - BaggageBuilder, - AgentDetails, - TenantDetails, - CallerDetails, - AgentRequest, - ExecutionType, - parseExecutionType, - ParentSpanRef, -} from '@microsoft/agents-a365-observability'; -import { ScopeUtils } from '../utils/ScopeUtils'; -import { - getExecutionTypePair, - getCallerBaggagePairs, - getTargetAgentBaggagePairs, - getTenantIdPair, - getSourceMetadataBaggagePairs, - getConversationIdAndItemLinkPairs, -} from '../utils/TurnContextUtils'; - -/** - * TurnState key for the parent span reference ({@link ParentSpanRef}). - * Developers set this value in `turnState` to link InputScope/OutputScope - */ -export const A365_PARENT_SPAN_KEY = 'A365ParentSpanId'; - -/** - * Configuration options for MessageLoggingMiddleware. - * - * **Privacy note:** When enabled, this middleware captures user input and bot output - * message content verbatim as OpenTelemetry span attributes (`gen_ai.input.messages` - * and `gen_ai.output.messages`). This data may contain PII or other sensitive - * information and will be exported to the configured telemetry backend. Ensure your - * telemetry pipeline complies with your organization's data handling policies. - */ -export interface MessageLoggingMiddlewareOptions { - /** - * Whether to create InputScope spans for user (input) messages. - * When true, the raw `activity.text` content is recorded as a span attribute. - * Defaults to true. - */ - logUserMessages?: boolean; - - /** - * Whether to create OutputScope spans for bot (output) messages. - * When true, outgoing message text is recorded as a span attribute. - * Defaults to true. - */ - logBotMessages?: boolean; -} - -/** - * Middleware for tracing input and output messages as OpenTelemetry spans. - * - * Creates {@link InputScope} / {@link OutputScope} spans for incoming/outgoing messages. - * If the developer sets a {@link ParentSpanRef} in `context.turnState` under - * {@link A365_PARENT_SPAN_KEY}, spans are created as children of that parent. - * - * @example - * ```typescript - * const adapter = new CloudAdapter(); - * adapter.use(new MessageLoggingMiddleware()); - * ``` - */ -export class MessageLoggingMiddleware implements Middleware { - private readonly _logUserMessages: boolean; - private readonly _logBotMessages: boolean; - - constructor(options?: MessageLoggingMiddlewareOptions) { - this._logUserMessages = options?.logUserMessages ?? true; - this._logBotMessages = options?.logBotMessages ?? true; - } - - /** - * Called each time the agent processes a turn. - * Creates an InputScope span for incoming messages and hooks onSendActivities - * to create OutputScope spans for outgoing messages. If a {@link ParentSpanRef} - * is set in `turnState` under {@link A365_PARENT_SPAN_KEY}, spans are linked - * to that parent. - * @param context The context object for the turn. - * @param next The next middleware or handler to call. - */ - async onTurn(context: TurnContext, next: () => Promise): Promise { - const isAsyncReply = - context.activity?.type === ActivityTypes.Event && - context.activity?.name === ActivityEventNames.ContinueConversation; - - const agentDetails = ScopeUtils.deriveAgentDetails(context); - const tenantDetails = ScopeUtils.deriveTenantDetails(context); - - // If we can't derive required details, pass through without tracing - if (!agentDetails || !tenantDetails) { - await next(); - return; - } - - const callerDetails = ScopeUtils.deriveCallerDetails(context); - const conversationId = ScopeUtils.deriveConversationId(context); - const sourceMetadata = ScopeUtils.deriveSourceMetadataObject(context); - const executionTypePair = getExecutionTypePair(context); - const executionType = executionTypePair.length > 0 - ? parseExecutionType(executionTypePair[0][1]) - : undefined; - - // Register send activities handler for output tracing - if (this._logBotMessages) { - context.onSendActivities( - this._createSendHandler(context, agentDetails, tenantDetails, callerDetails, conversationId, sourceMetadata, executionType) - ); - } - - // For async replies, skip baggage and input tracing — just run next() - if (isAsyncReply) { - await next(); - return; - } - - // Build baggage context from turn context for propagation - const baggageScope = new BaggageBuilder() - .setPairs(getCallerBaggagePairs(context)) - .setPairs(getTargetAgentBaggagePairs(context)) - .setPairs(getTenantIdPair(context)) - .setPairs(getSourceMetadataBaggagePairs(context)) - .setPairs(getConversationIdAndItemLinkPairs(context)) - .setPairs(executionTypePair) - .build(); - - await baggageScope.run(async () => { - const shouldLogInput = this._logUserMessages && !!context.activity?.text; - - // Record start time before next() so the InputScope span reflects when the input arrived - const inputStartTime = shouldLogInput ? Date.now() : undefined; - let turnError: unknown; - - try { - await next(); - } catch (error) { - turnError = error; - } - - // Create InputScope after next() so we can read the parent span ref from turnState - if (shouldLogInput) { - const parentSpanRef: ParentSpanRef | undefined = context.turnState.get(A365_PARENT_SPAN_KEY); - const request = this._buildAgentRequest(context, executionType, sourceMetadata); - const inputScope = InputScope.start( - request, agentDetails, tenantDetails, callerDetails, conversationId, - parentSpanRef, inputStartTime - ); - if (turnError) { - inputScope.recordError( - turnError instanceof Error ? turnError : new Error(typeof turnError === 'string' ? turnError : JSON.stringify(turnError)) - ); - } - inputScope.dispose(); - } - }); - } - - /** - * Builds an AgentRequest from the TurnContext for the InputScope. - */ - private _buildAgentRequest( - context: TurnContext, - executionType?: ExecutionType, - sourceMetadata?: { name?: string; description?: string } - ): AgentRequest { - return { - content: context.activity?.text, - executionType, - sourceMetadata: (sourceMetadata?.name || sourceMetadata?.description) - ? { name: sourceMetadata?.name, description: sourceMetadata?.description } - : undefined, - }; - } - - /** - * Creates a handler for onSendActivities that wraps outgoing messages in OutputScope spans. - * Reads {@link A365_PARENT_SPAN_KEY} from turnState lazily at execution time so the - * agent handler has a chance to set it during `next()`. - */ - private _createSendHandler( - turnContext: TurnContext, - agentDetails: AgentDetails, - tenantDetails: TenantDetails, - callerDetails?: CallerDetails, - conversationId?: string, - sourceMetadata?: { name?: string; description?: string }, - executionType?: ExecutionType, - ): SendActivitiesHandler { - return async (_ctx, activities, sendNext) => { - // Collect text from message-type activities - const messages = activities - .filter((a) => a.type === 'message' && a.text) - .map((a) => a.text!); - - if (messages.length > 0) { - // Read parent span ref lazily — the agent handler sets it during next() - const parentSpanRef: ParentSpanRef | undefined = turnContext.turnState.get(A365_PARENT_SPAN_KEY); - const outputScope = OutputScope.start( - { messages }, - agentDetails, - tenantDetails, - callerDetails, - conversationId, - sourceMetadata, - executionType, - parentSpanRef, - ); - try { - return await sendNext(); - } catch (error) { - outputScope.recordError( - error instanceof Error ? error : new Error(typeof error === 'string' ? error : JSON.stringify(error)) - ); - } finally { - outputScope.dispose(); - } - } - - return await sendNext(); - }; - } -} diff --git a/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts b/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts new file mode 100644 index 00000000..91d7ed62 --- /dev/null +++ b/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Middleware } from '@microsoft/agents-hosting'; +import { logger } from '@microsoft/agents-a365-observability'; +import { BaggageMiddleware } from './BaggageMiddleware'; +import { OutputLoggingMiddleware } from './OutputLoggingMiddleware'; + +/** + * Configuration options for the hosting observability layer. + */ +export interface ObservabilityHostingOptions { + /** Enable baggage propagation middleware. Defaults to true. */ + enableBaggage?: boolean; + + /** Enable output logging middleware for tracing outgoing messages. Defaults to false. */ + enableOutputLogging?: boolean; +} + +/** + * Singleton manager for configuring hosting-layer observability middleware. + * + * @example + * ```typescript + * ObservabilityHostingManager.configure(adapter, { + * enableOutputLogging: true, + * }); + * ``` + */ +export class ObservabilityHostingManager { + private static _instance?: ObservabilityHostingManager; + + private constructor() {} + + /** + * Configures the singleton instance and registers middleware on the adapter. + */ + static configure( + adapter?: { use(...middlewares: Array): void }, + options?: ObservabilityHostingOptions + ): ObservabilityHostingManager { + if (ObservabilityHostingManager._instance) { + logger.warn('[ObservabilityHostingManager] Already configured. Subsequent configure() calls are ignored.'); + return ObservabilityHostingManager._instance; + } + + const instance = new ObservabilityHostingManager(); + + if (adapter) { + const enableBaggage = options?.enableBaggage !== false; + const enableOutputLogging = options?.enableOutputLogging === true; + + if (enableBaggage) { + adapter.use(new BaggageMiddleware()); + logger.info('[ObservabilityHostingManager] BaggageMiddleware registered.'); + } + if (enableOutputLogging) { + adapter.use(new OutputLoggingMiddleware()); + logger.info('[ObservabilityHostingManager] OutputLoggingMiddleware registered.'); + } + + logger.info(`[ObservabilityHostingManager] Configured. Baggage: ${enableBaggage}, OutputLogging: ${enableOutputLogging}.`); + } else { + logger.warn('[ObservabilityHostingManager] No adapter provided. No middleware registered.'); + } + + ObservabilityHostingManager._instance = instance; + return instance; + } + + /** + * Returns the current singleton instance, or null if not configured. + */ + static getInstance(): ObservabilityHostingManager | null { + return ObservabilityHostingManager._instance ?? null; + } +} diff --git a/packages/agents-a365-observability-hosting/src/middleware/ObservabilityMiddlewareRegistrar.ts b/packages/agents-a365-observability-hosting/src/middleware/ObservabilityMiddlewareRegistrar.ts deleted file mode 100644 index c2db57f2..00000000 --- a/packages/agents-a365-observability-hosting/src/middleware/ObservabilityMiddlewareRegistrar.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { Middleware } from '@microsoft/agents-hosting'; -import { MessageLoggingMiddleware, MessageLoggingMiddlewareOptions } from './MessageLoggingMiddleware'; - -/** - * Fluent builder for registering observability middleware on an adapter. - * - * @example - * ```typescript - * new ObservabilityMiddlewareRegistrar() - * .withMessageLogging() - * .apply(adapter); - * ``` - */ -export class ObservabilityMiddlewareRegistrar { - private readonly _middlewareFactories: Array<() => Middleware> = []; - - /** - * Configures message logging middleware for tracing input/output messages. - * @param options Optional configuration for message logging behavior. - * @returns This registrar instance for chaining. - */ - withMessageLogging(options?: MessageLoggingMiddlewareOptions): this { - this._middlewareFactories.push(() => new MessageLoggingMiddleware(options)); - return this; - } - - /** - * Instantiates and registers all configured middleware on the adapter. - * @param adapter The adapter to register middleware on. Must have a `use` method. - */ - apply(adapter: { use(...middlewares: Array): void }): void { - for (const createMiddleware of this._middlewareFactories) { - adapter.use(createMiddleware()); - } - } -} diff --git a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts new file mode 100644 index 00000000..124926b2 --- /dev/null +++ b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TurnContext, Middleware, SendActivitiesHandler } from '@microsoft/agents-hosting'; +import { + OutputScope, + AgentDetails, + TenantDetails, + CallerDetails, + ExecutionType, + parseExecutionType, + ParentSpanRef, + logger, +} from '@microsoft/agents-a365-observability'; +import { ScopeUtils } from '../utils/ScopeUtils'; +import { getExecutionTypePair } from '../utils/TurnContextUtils'; + +/** + * 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'; + +/** + * 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 Middleware { + + async onTurn(context: TurnContext, next: () => Promise): Promise { + const agentDetails = ScopeUtils.deriveAgentDetails(context); + const tenantDetails = ScopeUtils.deriveTenantDetails(context); + + if (!agentDetails || !tenantDetails) { + await next(); + return; + } + + const callerDetails = ScopeUtils.deriveCallerDetails(context); + const conversationId = ScopeUtils.deriveConversationId(context); + const sourceMetadata = ScopeUtils.deriveSourceMetadataObject(context); + const executionTypePair = getExecutionTypePair(context); + const executionType = executionTypePair.length > 0 + ? parseExecutionType(executionTypePair[0][1]) + : undefined; + + context.onSendActivities( + this._createSendHandler(context, agentDetails, tenantDetails, callerDetails, conversationId, sourceMetadata, executionType) + ); + + await next(); + } + + /** + * Creates a send handler that wraps outgoing messages in OutputScope spans. + * Reads parent span ref lazily so the agent handler can set it during `next()`. + */ + private _createSendHandler( + turnContext: TurnContext, + agentDetails: AgentDetails, + tenantDetails: TenantDetails, + callerDetails?: CallerDetails, + conversationId?: string, + sourceMetadata?: { name?: string; description?: string }, + executionType?: ExecutionType, + ): SendActivitiesHandler { + return async (_ctx, activities, sendNext) => { + const messages = activities + .filter((a) => a.type === 'message' && a.text) + .map((a) => a.text!); + + if (messages.length === 0) { + return await sendNext(); + } + + const parentSpanRef: ParentSpanRef | undefined = turnContext.turnState.get(A365_PARENT_SPAN_KEY); + if (!parentSpanRef) { + logger.warn( + `[OutputLoggingMiddleware] No parent span ref in turnState under '${A365_PARENT_SPAN_KEY}'. OutputScope will not be linked to a parent.` + ); + } + + const outputScope = OutputScope.start( + { messages }, + agentDetails, + tenantDetails, + callerDetails, + conversationId, + sourceMetadata, + executionType, + parentSpanRef, + ); + 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/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index 84fb1a6e..76d628f0 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -54,7 +54,6 @@ export { ExecuteToolScope } from './tracing/scopes/ExecuteToolScope'; export { InvokeAgentScope } from './tracing/scopes/InvokeAgentScope'; export { InferenceScope } from './tracing/scopes/InferenceScope'; export { OutputScope } from './tracing/scopes/OutputScope'; -export { InputScope } from './tracing/scopes/InputScope'; export { logger, setLogger, getLogger, resetLogger, formatError } from './utils/logging'; export type { ILogger } from './utils/logging'; diff --git a/packages/agents-a365-observability/src/tracing/constants.ts b/packages/agents-a365-observability/src/tracing/constants.ts index d1c00bc4..c7e88b19 100644 --- a/packages/agents-a365-observability/src/tracing/constants.ts +++ b/packages/agents-a365-observability/src/tracing/constants.ts @@ -10,7 +10,6 @@ export class OpenTelemetryConstants { public static readonly INVOKE_AGENT_OPERATION_NAME = 'invoke_agent'; public static readonly EXECUTE_TOOL_OPERATION_NAME = 'execute_tool'; public static readonly OUTPUT_MESSAGES_OPERATION_NAME = 'output_messages'; - public static readonly INPUT_MESSAGES_OPERATION_NAME = 'input_messages'; public static readonly CHAT_OPERATION_NAME = 'chat'; // OpenTelemetry semantic conventions diff --git a/packages/agents-a365-observability/src/tracing/scopes/InputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InputScope.ts deleted file mode 100644 index 6af80059..00000000 --- a/packages/agents-a365-observability/src/tracing/scopes/InputScope.ts +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { SpanKind, TimeInput } from '@opentelemetry/api'; -import { OpenTelemetryScope } from './OpenTelemetryScope'; -import { AgentDetails, TenantDetails, CallerDetails, AgentRequest } from '../contracts'; -import { ParentContext } from '../context/trace-context-propagation'; -import { OpenTelemetryConstants } from '../constants'; - -/** - * Provides OpenTelemetry tracing scope for input message tracing with parent span linking. - */ -export class InputScope extends OpenTelemetryScope { - private _inputMessages: string[]; - private _inputMessagesDirty = false; - - /** - * Creates and starts a new scope for input message tracing. - * @param request The agent request containing the input content, execution type, and source metadata. - * @param agentDetails The details of the agent receiving the input. - * @param tenantDetails The tenant details. - * @param callerDetails Optional caller identity details (id, upn, name, tenant, client ip). - * @param conversationId Optional conversation identifier. - * @param parentContext Optional parent context for cross-async-boundary tracing. - * Accepts a ParentSpanRef (manual traceId/spanId) or an OTel Context (e.g. from extractTraceContext). - * @param startTime Optional explicit start time (ms epoch, Date, or HrTime). - * @param endTime Optional explicit end time (ms epoch, Date, or HrTime). - * @returns A new InputScope instance. - */ - public static start( - request: AgentRequest, - agentDetails: AgentDetails, - tenantDetails: TenantDetails, - callerDetails?: CallerDetails, - conversationId?: string, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput - ): InputScope { - return new InputScope(request, agentDetails, tenantDetails, callerDetails, conversationId, parentContext, startTime, endTime); - } - - private constructor( - request: AgentRequest, - agentDetails: AgentDetails, - tenantDetails: TenantDetails, - callerDetails?: CallerDetails, - conversationId?: string, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput - ) { - super( - SpanKind.CLIENT, - OpenTelemetryConstants.INPUT_MESSAGES_OPERATION_NAME, - agentDetails.agentName - ? `${OpenTelemetryConstants.INPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentName}` - : `${OpenTelemetryConstants.INPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentId}`, - agentDetails, - tenantDetails, - parentContext, - startTime, - endTime - ); - - // Initialize accumulated messages list from the request content - this._inputMessages = request.content ? [request.content] : []; - - // Set initial input messages attribute - if (this._inputMessages.length > 0) { - this.setTagMaybe( - OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, - JSON.stringify(this._inputMessages) - ); - } - - // Set execution type if provided - this.setTagMaybe( - OpenTelemetryConstants.GEN_AI_EXECUTION_TYPE_KEY, - request.executionType - ); - - // Set source metadata if provided - this.setTagMaybe( - OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_NAME_KEY, - request.sourceMetadata?.name - ); - this.setTagMaybe( - OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, - request.sourceMetadata?.description - ); - - // Set conversation id - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, conversationId); - - // Set caller details if provided - if (callerDetails) { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_ID_KEY, callerDetails.callerId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_UPN_KEY, callerDetails.callerUpn); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_NAME_KEY, callerDetails.callerName); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_TENANT_ID_KEY, callerDetails.tenantId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, callerDetails.callerClientIp); - } - } - - /** - * Records the input messages for telemetry tracking. - * Appends the provided messages to the accumulated input messages list. - * The updated attribute is flushed when the scope is disposed. - * @param messages Array of input messages to append. - */ - public recordInputMessages(messages: string[]): void { - this._inputMessages.push(...messages); - this._inputMessagesDirty = true; - } - - public override [Symbol.dispose](): void { - if (this._inputMessagesDirty) { - this.setTagMaybe( - OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, - JSON.stringify(this._inputMessages) - ); - } - super[Symbol.dispose](); - } - - public override dispose(): void { - this[Symbol.dispose](); - } -} diff --git a/tests/observability/core/input-scope.test.ts b/tests/observability/core/input-scope.test.ts deleted file mode 100644 index bfc22787..00000000 --- a/tests/observability/core/input-scope.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { trace, context as otelContext } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; - -import { - InputScope, - AgentDetails, - TenantDetails, - CallerDetails, - AgentRequest, - OpenTelemetryConstants, - ParentSpanRef, -} from '@microsoft/agents-a365-observability'; - -describe('InputScope', () => { - const testAgentDetails: AgentDetails = { - agentId: 'test-agent-123', - agentName: 'Test Agent', - agentDescription: 'A test agent for input scope testing', - }; - - const testTenantDetails: TenantDetails = { - tenantId: '12345678-1234-5678-1234-567812345678', - }; - - let exporter: InMemorySpanExporter; - let provider: BasicTracerProvider; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let flushProvider: any; - 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); - flushProvider = globalProvider; - } else { - provider = new BasicTracerProvider({ - spanProcessors: [processor] - }); - trace.setGlobalTracerProvider(provider); - flushProvider = provider; - } - }); - - beforeEach(() => { - exporter.reset(); - }); - - afterAll(async () => { - exporter.reset(); - await provider?.shutdown?.(); - contextManager.disable(); - otelContext.disable(); - }); - - function getLastSpan() { - const spans = exporter.getFinishedSpans(); - expect(spans.length).toBeGreaterThan(0); - const span = spans[spans.length - 1]; - return { span, attributes: span.attributes }; - } - - it('should create scope with correct span attributes', async () => { - const request: AgentRequest = { content: 'Hello agent' }; - - const scope = InputScope.start(request, testAgentDetails, testTenantDetails); - expect(scope).toBeInstanceOf(InputScope); - scope.dispose(); - - await flushProvider.forceFlush(); - const { span, attributes } = getLastSpan(); - - expect(span.name).toBe('input_messages Test Agent'); - expect(attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe('input_messages'); - expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(testAgentDetails.agentId); - expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe(testAgentDetails.agentName); - expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(testTenantDetails.tenantId); - expect(JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string)).toEqual(['Hello agent']); - }); - - it('should not set input messages when content is missing or empty', async () => { - for (const content of [undefined, '']) { - exporter.reset(); - const request: AgentRequest = content === undefined ? {} : { content }; - const scope = InputScope.start(request, testAgentDetails, testTenantDetails); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - expect(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY]).toBeUndefined(); - } - }); - - it('should append messages with recordInputMessages and flush on dispose', async () => { - const request: AgentRequest = { content: 'Initial' }; - const scope = InputScope.start(request, testAgentDetails, testTenantDetails); - - scope.recordInputMessages(['Appended 1']); - scope.recordInputMessages(['Appended 2', 'Appended 3']); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string); - expect(parsed).toEqual(['Initial', 'Appended 1', 'Appended 2', 'Appended 3']); - }); - - it('should use parent span reference for linking', async () => { - const parentTraceId = '1234567890abcdef1234567890abcdef'; - const parentSpanId = 'abcdefabcdef1234'; - - const scope = InputScope.start( - { content: 'Test' }, testAgentDetails, testTenantDetails, - undefined, undefined, { traceId: parentTraceId, spanId: parentSpanId } as ParentSpanRef - ); - scope.dispose(); - - await flushProvider.forceFlush(); - const { span } = getLastSpan(); - expect(span.spanContext().traceId).toBe(parentTraceId); - expect(span.parentSpanContext?.spanId).toBe(parentSpanId); - }); - - it('should set caller details and conversationId on the span', async () => { - const callerDetails: CallerDetails = { - callerId: 'caller-oid-123', - callerUpn: 'caller@contoso.com', - callerName: 'Test Caller', - tenantId: 'caller-tenant-456', - callerClientIp: '10.0.0.1', - }; - - const scope = InputScope.start( - { content: 'Test' }, testAgentDetails, testTenantDetails, - callerDetails, 'conv-42' - ); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_ID_KEY]).toBe('caller-oid-123'); - expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_UPN_KEY]).toBe('caller@contoso.com'); - expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_NAME_KEY]).toBe('Test Caller'); - expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_TENANT_ID_KEY]).toBe('caller-tenant-456'); - expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY]).toBe('10.0.0.1'); - expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe('conv-42'); - }); - - it('should use agentName in span name, falling back to agentId', async () => { - const scope1 = InputScope.start({ content: 'Test' }, { agentId: 'id-1', agentName: 'My Agent' }, testTenantDetails); - scope1.dispose(); - await flushProvider.forceFlush(); - expect(getLastSpan().span.name).toBe('input_messages My Agent'); - - exporter.reset(); - const scope2 = InputScope.start({ content: 'Test' }, { agentId: 'id-only' }, testTenantDetails); - scope2.dispose(); - await flushProvider.forceFlush(); - expect(getLastSpan().span.name).toBe('input_messages id-only'); - }); -}); diff --git a/tests/observability/extension/hosting/baggage-middleware.test.ts b/tests/observability/extension/hosting/baggage-middleware.test.ts new file mode 100644 index 00000000..f2d1907b --- /dev/null +++ b/tests/observability/extension/hosting/baggage-middleware.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { trace, context as otelContext, propagation } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { RoleTypes, ActivityTypes, ActivityEventNames } from '@microsoft/agents-activity'; +import type { TurnContext } from '@microsoft/agents-hosting'; + +import { BaggageMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware'; +import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability'; + +function makeMockTurnContext(options?: { + text?: string; + recipientId?: string; + recipientTenantId?: string; + channelId?: string; + conversationId?: string; + fromRole?: string; + activityType?: string; + activityName?: string; +}): TurnContext { + const ctx: any = { + activity: { + type: options?.activityType, + name: options?.activityName, + text: options?.text ?? 'Hello agent', + channelId: options?.channelId ?? 'web', + conversation: { id: options?.conversationId ?? 'conv-001' }, + from: { + role: options?.fromRole ?? RoleTypes.User, + aadObjectId: 'user-oid', + name: 'Test User', + agenticUserId: 'user@contoso.com', + tenantId: 'from-tenant', + }, + recipient: { + agenticAppId: options?.recipientId ?? 'agent-1', + name: 'Agent One', + aadObjectId: 'agent-oid', + agenticAppBlueprintId: 'blueprint-1', + agenticUserId: 'agent@contoso.com', + role: 'assistant', + tenantId: options?.recipientTenantId ?? 'tenant-123', + }, + }, + turnState: new Map(), + }; + + return ctx; +} + +describe('BaggageMiddleware', () => { + let provider: BasicTracerProvider; + let contextManager: AsyncLocalStorageContextManager; + + 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 { + provider = new BasicTracerProvider({ + spanProcessors: [processor] + }); + trace.setGlobalTracerProvider(provider); + } + }); + + afterAll(async () => { + await provider?.shutdown?.(); + contextManager.disable(); + otelContext.disable(); + }); + + it('should propagate baggage context during turn', async () => { + const middleware = new BaggageMiddleware(); + const ctx = makeMockTurnContext(); + let 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.GEN_AI_CALLER_ID_KEY]).toBe('user-oid'); + expect(capturedBaggage[OpenTelemetryConstants.TENANT_ID_KEY]).toBe('tenant-123'); + expect(capturedBaggage[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe('agent-1'); + expect(capturedBaggage[OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_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: ActivityTypes.Event, + activityName: ActivityEventNames.ContinueConversation, + }); + let 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: ActivityTypes.Event, + activityName: ActivityEventNames.ContinueConversation, + }); + let nextCalled = false; + + await middleware.onTurn(ctx, async () => { nextCalled = true; }); + + expect(nextCalled).toBe(true); + }); +}); diff --git a/tests/observability/extension/hosting/observability-hosting-manager.test.ts b/tests/observability/extension/hosting/observability-hosting-manager.test.ts new file mode 100644 index 00000000..8a56022c --- /dev/null +++ b/tests/observability/extension/hosting/observability-hosting-manager.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { ObservabilityHostingManager } from '../../../../packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager'; +import { BaggageMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware'; +import { OutputLoggingMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware'; + +function mockAdapter() { + const registered: any[] = []; + return { use(...mw: any[]) { registered.push(...mw); }, registered }; +} + +describe('ObservabilityHostingManager', () => { + beforeEach(() => { + (ObservabilityHostingManager as any)._instance = undefined; + }); + + it('registers BaggageMiddleware by default', () => { + const adapter = mockAdapter(); + ObservabilityHostingManager.configure(adapter); + expect(adapter.registered).toHaveLength(1); + expect(adapter.registered[0]).toBeInstanceOf(BaggageMiddleware); + }); + + it('registers both middleware when enableOutputLogging is true', () => { + const adapter = mockAdapter(); + ObservabilityHostingManager.configure(adapter, { enableOutputLogging: true }); + expect(adapter.registered).toHaveLength(2); + expect(adapter.registered[0]).toBeInstanceOf(BaggageMiddleware); + expect(adapter.registered[1]).toBeInstanceOf(OutputLoggingMiddleware); + }); + + it('skips BaggageMiddleware when enableBaggage is false', () => { + const adapter = mockAdapter(); + ObservabilityHostingManager.configure(adapter, { enableBaggage: false, enableOutputLogging: true }); + expect(adapter.registered).toHaveLength(1); + expect(adapter.registered[0]).toBeInstanceOf(OutputLoggingMiddleware); + }); + + it('is a singleton — subsequent calls are no-ops', () => { + const adapter = mockAdapter(); + const first = ObservabilityHostingManager.configure(adapter, { enableOutputLogging: true }); + const second = ObservabilityHostingManager.configure(adapter, { enableOutputLogging: true }); + expect(first).toBe(second); + expect(adapter.registered).toHaveLength(2); + }); + + it('works without adapter', () => { + const manager = ObservabilityHostingManager.configure(); + expect(ObservabilityHostingManager.getInstance()).toBe(manager); + }); +}); diff --git a/tests/observability/extension/hosting/observability-middleware-registrar.test.ts b/tests/observability/extension/hosting/observability-middleware-registrar.test.ts deleted file mode 100644 index b4403c9a..00000000 --- a/tests/observability/extension/hosting/observability-middleware-registrar.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect } from '@jest/globals'; -import { ObservabilityMiddlewareRegistrar } from '../../../../packages/agents-a365-observability-hosting/src/middleware/ObservabilityMiddlewareRegistrar'; -import { MessageLoggingMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/MessageLoggingMiddleware'; - -describe('ObservabilityMiddlewareRegistrar', () => { - it('should register middleware on adapter via chained withMessageLogging() and apply()', () => { - const registered: any[] = []; - const mockAdapter = { use(...middlewares: any[]) { registered.push(...middlewares); } }; - - const registrar = new ObservabilityMiddlewareRegistrar(); - const result = registrar.withMessageLogging(); - expect(result).toBe(registrar); // chaining returns this - - registrar.apply(mockAdapter); - - expect(registered.length).toBe(1); - expect(registered[0]).toBeInstanceOf(MessageLoggingMiddleware); - }); - - it('should register multiple middleware instances', () => { - const registered: any[] = []; - const mockAdapter = { use(...middlewares: any[]) { registered.push(...middlewares); } }; - - new ObservabilityMiddlewareRegistrar() - .withMessageLogging() - .withMessageLogging({ logUserMessages: false }) - .apply(mockAdapter); - - expect(registered.length).toBe(2); - }); - - it('should not call adapter.use when no middleware is configured', () => { - const useFn = jest.fn(); - new ObservabilityMiddlewareRegistrar().apply({ use: useFn }); - expect(useFn).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/observability/extension/hosting/message-logging-middleware.test.ts b/tests/observability/extension/hosting/output-logging-middleware.test.ts similarity index 57% rename from tests/observability/extension/hosting/message-logging-middleware.test.ts rename to tests/observability/extension/hosting/output-logging-middleware.test.ts index 814cb802..be461701 100644 --- a/tests/observability/extension/hosting/message-logging-middleware.test.ts +++ b/tests/observability/extension/hosting/output-logging-middleware.test.ts @@ -3,12 +3,12 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { trace, context as otelContext, SpanStatusCode, propagation } from '@opentelemetry/api'; +import { trace, context as otelContext } from '@opentelemetry/api'; import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import { RoleTypes, ActivityTypes, ActivityEventNames } from '@microsoft/agents-activity'; import type { TurnContext, SendActivitiesHandler } from '@microsoft/agents-hosting'; -import { MessageLoggingMiddleware, A365_PARENT_SPAN_KEY } from '../../../../packages/agents-a365-observability-hosting/src/middleware/MessageLoggingMiddleware'; +import { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY } from '../../../../packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware'; import { OpenTelemetryConstants, ParentSpanRef } from '@microsoft/agents-a365-observability'; function makeMockTurnContext(options?: { @@ -69,7 +69,7 @@ function makeMockTurnContext(options?: { return ctx; } -describe('MessageLoggingMiddleware', () => { +describe('OutputLoggingMiddleware', () => { let exporter: InMemorySpanExporter; let provider: BasicTracerProvider; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -109,59 +109,25 @@ describe('MessageLoggingMiddleware', () => { otelContext.disable(); }); - it('should create InputScope and OutputScope as sibling spans', async () => { - const middleware = new MessageLoggingMiddleware(); + it('should create OutputScope for outgoing messages', async () => { + const middleware = new OutputLoggingMiddleware(); const ctx = makeMockTurnContext({ text: 'Hello' }); - let nextCalled = false; await middleware.onTurn(ctx, async () => { - nextCalled = true; + ctx.turnState.set(A365_PARENT_SPAN_KEY, { traceId: '0af7651916cd43dd8448eb211c80319c', spanId: 'b7ad6b7169203331', traceFlags: 1 }); await ctx.simulateSend([{ type: 'message', text: 'Hi there!' }]); }); - expect(nextCalled).toBe(true); - await flushProvider.forceFlush(); const spans = exporter.getFinishedSpans(); - const inputSpan = spans.find(s => s.name.includes('input_messages')); const outputSpan = spans.find(s => s.name.includes('output_messages')); - expect(inputSpan).toBeDefined(); - expect(inputSpan!.attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY]).toBe(JSON.stringify(['Hello'])); - expect(outputSpan).toBeDefined(); expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY]).toBe(JSON.stringify(['Hi there!'])); - - // Without A365_PARENT_SPAN_KEY set, both spans are independent root spans - expect(inputSpan!.parentSpanContext).toBeUndefined(); - expect(outputSpan!.parentSpanContext).toBeUndefined(); - }); - - it('should record error on InputScope when turn throws without re-throwing', async () => { - const middleware = new MessageLoggingMiddleware(); - - // Error instance — middleware records but does not re-throw - const ctx1 = makeMockTurnContext({ text: 'Hello' }); - await middleware.onTurn(ctx1, async () => { throw new Error('Something went wrong'); }); - - await flushProvider.forceFlush(); - const inputSpan1 = exporter.getFinishedSpans().find(s => s.name.includes('input_messages')); - expect(inputSpan1!.status.code).toBe(SpanStatusCode.ERROR); - expect(inputSpan1!.status.message).toBe('Something went wrong'); - - // Non-Error value - exporter.reset(); - const ctx2 = makeMockTurnContext({ text: 'Hello' }); - await middleware.onTurn(ctx2, async () => { throw 'string error'; }); - - await flushProvider.forceFlush(); - const inputSpan2 = exporter.getFinishedSpans().find(s => s.name.includes('input_messages')); - expect(inputSpan2!.status.code).toBe(SpanStatusCode.ERROR); - expect(inputSpan2!.status.message).toBe('string error'); }); it('should skip non-message activities in OutputScope', async () => { - const middleware = new MessageLoggingMiddleware(); + const middleware = new OutputLoggingMiddleware(); const ctx = makeMockTurnContext({ text: 'Hello' }); await middleware.onTurn(ctx, async () => { @@ -175,27 +141,8 @@ describe('MessageLoggingMiddleware', () => { expect(exporter.getFinishedSpans().find(s => s.name.includes('output_messages'))).toBeUndefined(); }); - it('should respect logUserMessages and logBotMessages options', async () => { - // logUserMessages: false — no InputScope - const mw1 = new MessageLoggingMiddleware({ logUserMessages: false }); - const ctx1 = makeMockTurnContext({ text: 'Hello' }); - let nextCalled = false; - await mw1.onTurn(ctx1, async () => { nextCalled = true; }); - expect(nextCalled).toBe(true); - await flushProvider.forceFlush(); - expect(exporter.getFinishedSpans().find(s => s.name.includes('input_messages'))).toBeUndefined(); - - // logBotMessages: false — no send handler registered - exporter.reset(); - const mw2 = new MessageLoggingMiddleware({ logBotMessages: false }); - const ctx2 = makeMockTurnContext({ text: 'Hello' }); - await mw2.onTurn(ctx2, async () => { - expect(ctx2._sendHandlers.length).toBe(0); - }); - }); - it('should pass through without tracing for skip conditions', async () => { - const middleware = new MessageLoggingMiddleware(); + const middleware = new OutputLoggingMiddleware(); // Missing agent details (no recipient) const ctx1: any = { activity: { text: 'Hello' }, onSendActivities: jest.fn() }; @@ -211,50 +158,14 @@ describe('MessageLoggingMiddleware', () => { nextCalled = false; await middleware.onTurn(ctx2, async () => { nextCalled = true; }); expect(nextCalled).toBe(true); - - // No input text — next() called but no InputScope - const ctx3: any = { - activity: { - channelId: 'web', - conversation: { id: 'conv-001' }, - from: { role: RoleTypes.User }, - recipient: { agenticAppId: 'agent-1', name: 'Agent One', tenantId: 'tenant-123' }, - }, - turnState: new Map(), - onSendActivities: jest.fn().mockReturnThis(), - }; - nextCalled = false; - await middleware.onTurn(ctx3, async () => { nextCalled = true; }); - expect(nextCalled).toBe(true); - - // ContinueConversation event — only output tracing (no input/baggage) - const ctx4: any = { - activity: { - type: ActivityTypes.Event, - name: ActivityEventNames.ContinueConversation, - text: 'Hello', - recipient: { agenticAppId: 'agent-1', name: 'Agent One', tenantId: 'tenant-123' }, - from: { role: RoleTypes.User, aadObjectId: 'user-oid', name: 'User' }, - conversation: { id: 'conv-001' }, - }, - turnState: new Map(), - onSendActivities: jest.fn().mockReturnThis(), - }; - nextCalled = false; - await middleware.onTurn(ctx4, async () => { nextCalled = true; }); - expect(nextCalled).toBe(true); - // ContinueConversation registers output handler but skips input/baggage - expect(ctx4.onSendActivities).toHaveBeenCalledTimes(1); - - await flushProvider.forceFlush(); - expect(exporter.getFinishedSpans().length).toBe(0); }); it('should set caller details and enrichment on OutputScope span', async () => { - const middleware = new MessageLoggingMiddleware(); + const middleware = new OutputLoggingMiddleware(); const ctx = makeMockTurnContext({ text: 'Hello', channelId: 'teams' }); 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' }]); }); @@ -268,27 +179,8 @@ describe('MessageLoggingMiddleware', () => { expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_NAME_KEY]).toBe('teams'); }); - it('should propagate baggage context during turn', async () => { - const middleware = new MessageLoggingMiddleware(); - const ctx = makeMockTurnContext({ text: 'Hello' }); - let 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.GEN_AI_CALLER_ID_KEY]).toBe('user-oid'); - expect(capturedBaggage[OpenTelemetryConstants.TENANT_ID_KEY]).toBe('tenant-123'); - expect(capturedBaggage[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe('agent-1'); - }); - - it('should link InputScope and OutputScope to parent when parentSpanRef is set in turnState', async () => { - const middleware = new MessageLoggingMiddleware(); + it('should link OutputScope to parent when parentSpanRef is set in turnState', async () => { + const middleware = new OutputLoggingMiddleware(); const ctx = makeMockTurnContext({ text: 'Hello' }); const parentSpanRef: ParentSpanRef = { @@ -298,28 +190,19 @@ describe('MessageLoggingMiddleware', () => { }; await middleware.onTurn(ctx, async () => { - // Agent handler sets parent span ref during next() — both scopes read it lazily ctx.turnState.set(A365_PARENT_SPAN_KEY, parentSpanRef); await ctx.simulateSend([{ type: 'message', text: 'Reply' }]); }); await flushProvider.forceFlush(); - const spans = exporter.getFinishedSpans(); - const inputSpan = spans.find(s => s.name.includes('input_messages')); - const outputSpan = spans.find(s => s.name.includes('output_messages')); - - expect(inputSpan).toBeDefined(); + const outputSpan = exporter.getFinishedSpans().find(s => s.name.includes('output_messages')); expect(outputSpan).toBeDefined(); - - // Both InputScope and OutputScope read parentSpanRef from turnState after next() - expect(inputSpan!.parentSpanContext?.traceId).toBe(parentSpanRef.traceId); - expect(inputSpan!.parentSpanContext?.spanId).toBe(parentSpanRef.spanId); expect(outputSpan!.parentSpanContext?.traceId).toBe(parentSpanRef.traceId); expect(outputSpan!.parentSpanContext?.spanId).toBe(parentSpanRef.spanId); }); it('should create OutputScope for async reply (ContinueConversation) when messages are sent', async () => { - const middleware = new MessageLoggingMiddleware(); + const middleware = new OutputLoggingMiddleware(); const ctx = makeMockTurnContext({ text: 'Hello', activityType: ActivityTypes.Event, @@ -331,19 +214,13 @@ describe('MessageLoggingMiddleware', () => { }); await flushProvider.forceFlush(); - const spans = exporter.getFinishedSpans(); - - // No InputScope for async replies - expect(spans.find(s => s.name.includes('input_messages'))).toBeUndefined(); - - // OutputScope is created for the sent message - const outputSpan = spans.find(s => s.name.includes('output_messages')); + const outputSpan = exporter.getFinishedSpans().find(s => s.name.includes('output_messages')); expect(outputSpan).toBeDefined(); expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY]).toBe(JSON.stringify(['Async reply'])); }); it('should link async reply OutputScope to parent when parentSpanRef is set', async () => { - const middleware = new MessageLoggingMiddleware(); + const middleware = new OutputLoggingMiddleware(); const ctx = makeMockTurnContext({ text: 'Hello', activityType: ActivityTypes.Event, @@ -362,16 +239,14 @@ describe('MessageLoggingMiddleware', () => { }); await flushProvider.forceFlush(); - const spans = exporter.getFinishedSpans(); - const outputSpan = spans.find(s => s.name.includes('output_messages')); - + const outputSpan = exporter.getFinishedSpans().find(s => s.name.includes('output_messages')); expect(outputSpan).toBeDefined(); expect(outputSpan!.parentSpanContext?.traceId).toBe(parentSpanRef.traceId); expect(outputSpan!.parentSpanContext?.spanId).toBe(parentSpanRef.spanId); }); - it('should not create spans for async reply when no messages are sent', async () => { - const middleware = new MessageLoggingMiddleware(); + it('should not create spans when no messages are sent', async () => { + const middleware = new OutputLoggingMiddleware(); const ctx = makeMockTurnContext({ text: 'Hello', activityType: ActivityTypes.Event, @@ -385,4 +260,25 @@ describe('MessageLoggingMiddleware', () => { await flushProvider.forceFlush(); expect(exporter.getFinishedSpans().length).toBe(0); }); + + it('should re-throw errors from sendNext after recording on OutputScope', async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: 'Hello' }); + const sendError = new Error('send pipeline failed'); + + // Override simulateSend to make the send pipeline throw + ctx.simulateSend = async () => { throw sendError; }; + + // Register handler manually since simulateSend is overridden + await middleware.onTurn(ctx, async () => { + ctx.turnState.set(A365_PARENT_SPAN_KEY, { traceId: '0af7651916cd43dd8448eb211c80319c', spanId: 'b7ad6b7169203331', traceFlags: 1 }); + // Simulate the handler being called with message activities + const handler = ctx._sendHandlers[0]; + if (handler) { + await expect( + handler(ctx, [{ type: 'message', text: 'Reply' }] as any, async () => { throw sendError; }) + ).rejects.toThrow('send pipeline failed'); + } + }); + }); }); From 003cb1f3da882d8842e0f94254041004144be921 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 27 Feb 2026 13:34:04 -0800 Subject: [PATCH 3/4] Address PR #210 review comments - Remove parseExecutionType (execution type removed in new schema) - Make ObservabilityHostingManager.configure() an instance method with required params - Remove static singleton pattern and getInstance() from ObservabilityHostingManager - Update CHANGELOG with unreleased changes Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 20 ++++++ .../middleware/ObservabilityHostingManager.ts | 63 +++++++------------ .../src/middleware/OutputLoggingMiddleware.ts | 12 +--- .../src/utils/ScopeUtils.ts | 6 -- .../agents-a365-observability/src/index.ts | 1 - .../src/tracing/contracts.ts | 12 ---- .../observability-hosting-manager.test.ts | 25 +++----- .../hosting/output-logging-middleware.test.ts | 1 - .../extension/hosting/scope-utils.test.ts | 1 - 9 files changed, 53 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 334eadff..f5b7b113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to the Agent365 TypeScript SDK will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **OutputScope**: New tracing scope for outgoing agent messages with caller details, conversation ID, and source metadata. +- **BaggageMiddleware**: Middleware for automatic OpenTelemetry baggage propagation from TurnContext (caller, agent, tenant, source metadata, conversation, execution type). +- **OutputLoggingMiddleware**: Middleware that creates OutputScope spans for outgoing messages with lazy parent span linking via `A365_PARENT_SPAN_KEY` in turnState. +- **ObservabilityHostingManager**: Manager for configuring hosting-layer observability middleware with `ObservabilityHostingOptions`. +- **CallerDetails** contract for caller identity attributes (id, upn, name, tenant). +- **OutputResponse** contract for output message tracing. + +### Changed +- `ObservabilityHostingManager.configure()` is now an instance method with required adapter and options parameters. +- Input/output message recording across scopes now uses JSON array format instead of comma-separated strings. + +### Removed +- `InputScope` (not supported in current schema). +- `MessageLoggingMiddleware` (replaced by split `BaggageMiddleware` + `OutputLoggingMiddleware`). +- `ObservabilityMiddlewareRegistrar` (replaced by `ObservabilityHostingManager`). +- `parseExecutionType` helper (execution type removed from new telemetry schema). + ## [1.1.0] - 2025-12-09 ### Changed diff --git a/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts b/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts index 91d7ed62..a5f05803 100644 --- a/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts +++ b/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts @@ -18,60 +18,43 @@ export interface ObservabilityHostingOptions { } /** - * Singleton manager for configuring hosting-layer observability middleware. + * Manager for configuring hosting-layer observability middleware. * * @example * ```typescript - * ObservabilityHostingManager.configure(adapter, { - * enableOutputLogging: true, - * }); + * const manager = new ObservabilityHostingManager(); + * manager.configure(adapter, { enableOutputLogging: true }); * ``` */ export class ObservabilityHostingManager { - private static _instance?: ObservabilityHostingManager; - - private constructor() {} + private _configured = false; /** - * Configures the singleton instance and registers middleware on the adapter. + * Registers observability middleware on the adapter. + * Subsequent calls are ignored. */ - static configure( - adapter?: { use(...middlewares: Array): void }, - options?: ObservabilityHostingOptions - ): ObservabilityHostingManager { - if (ObservabilityHostingManager._instance) { + configure( + adapter: { use(...middlewares: Array): void }, + options: ObservabilityHostingOptions + ): void { + if (this._configured) { logger.warn('[ObservabilityHostingManager] Already configured. Subsequent configure() calls are ignored.'); - return ObservabilityHostingManager._instance; + return; } - const instance = new ObservabilityHostingManager(); - - if (adapter) { - const enableBaggage = options?.enableBaggage !== false; - const enableOutputLogging = options?.enableOutputLogging === true; + const enableBaggage = options.enableBaggage !== false; + const enableOutputLogging = options.enableOutputLogging === true; - if (enableBaggage) { - adapter.use(new BaggageMiddleware()); - logger.info('[ObservabilityHostingManager] BaggageMiddleware registered.'); - } - if (enableOutputLogging) { - adapter.use(new OutputLoggingMiddleware()); - logger.info('[ObservabilityHostingManager] OutputLoggingMiddleware registered.'); - } - - logger.info(`[ObservabilityHostingManager] Configured. Baggage: ${enableBaggage}, OutputLogging: ${enableOutputLogging}.`); - } else { - logger.warn('[ObservabilityHostingManager] No adapter provided. No middleware registered.'); + if (enableBaggage) { + adapter.use(new BaggageMiddleware()); + logger.info('[ObservabilityHostingManager] BaggageMiddleware registered.'); + } + if (enableOutputLogging) { + adapter.use(new OutputLoggingMiddleware()); + logger.info('[ObservabilityHostingManager] OutputLoggingMiddleware registered.'); } - ObservabilityHostingManager._instance = instance; - return instance; - } - - /** - * Returns the current singleton instance, or null if not configured. - */ - static getInstance(): ObservabilityHostingManager | null { - return ObservabilityHostingManager._instance ?? null; + logger.info(`[ObservabilityHostingManager] Configured. Baggage: ${enableBaggage}, OutputLogging: ${enableOutputLogging}.`); + this._configured = true; } } diff --git a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts index 124926b2..bc2225ed 100644 --- a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts +++ b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts @@ -7,13 +7,10 @@ import { AgentDetails, TenantDetails, CallerDetails, - ExecutionType, - parseExecutionType, ParentSpanRef, logger, } from '@microsoft/agents-a365-observability'; import { ScopeUtils } from '../utils/ScopeUtils'; -import { getExecutionTypePair } from '../utils/TurnContextUtils'; /** * TurnState key for the parent span reference. @@ -42,13 +39,9 @@ export class OutputLoggingMiddleware implements Middleware { const callerDetails = ScopeUtils.deriveCallerDetails(context); const conversationId = ScopeUtils.deriveConversationId(context); const sourceMetadata = ScopeUtils.deriveSourceMetadataObject(context); - const executionTypePair = getExecutionTypePair(context); - const executionType = executionTypePair.length > 0 - ? parseExecutionType(executionTypePair[0][1]) - : undefined; context.onSendActivities( - this._createSendHandler(context, agentDetails, tenantDetails, callerDetails, conversationId, sourceMetadata, executionType) + this._createSendHandler(context, agentDetails, tenantDetails, callerDetails, conversationId, sourceMetadata) ); await next(); @@ -65,7 +58,6 @@ export class OutputLoggingMiddleware implements Middleware { callerDetails?: CallerDetails, conversationId?: string, sourceMetadata?: { name?: string; description?: string }, - executionType?: ExecutionType, ): SendActivitiesHandler { return async (_ctx, activities, sendNext) => { const messages = activities @@ -90,7 +82,7 @@ export class OutputLoggingMiddleware implements Middleware { callerDetails, conversationId, sourceMetadata, - executionType, + undefined, parentSpanRef, ); try { diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index 1bbba3d5..e1b46201 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -15,11 +15,7 @@ import { InferenceDetails, InvokeAgentDetails, ToolCallDetails, - parseExecutionType, } from '@microsoft/agents-a365-observability'; -import { - getExecutionTypePair, -} from './TurnContextUtils'; /** * Unified utilities to populate scope tags from a TurnContext. @@ -198,7 +194,6 @@ export class ScopeUtils { public static buildInvokeAgentDetails(details: InvokeAgentDetails, turnContext: TurnContext): InvokeAgentDetails { const agent = ScopeUtils.deriveAgentDetails(turnContext); const srcMetaFromContext = ScopeUtils.deriveSourceMetadataObject(turnContext); - const executionTypePair = getExecutionTypePair(turnContext); const baseRequest = details.request ?? {}; const baseSource = baseRequest.sourceMetadata ?? {}; const mergedSourceMetadata = { @@ -212,7 +207,6 @@ export class ScopeUtils { conversationId: ScopeUtils.deriveConversationId(turnContext), request: { ...baseRequest, - executionType: executionTypePair.length > 0 ? parseExecutionType(executionTypePair[0][1]) ?? baseRequest.executionType : baseRequest.executionType, sourceMetadata: mergedSourceMetadata } }; diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index 76d628f0..b2131b64 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -45,7 +45,6 @@ export { InferenceOperationType, InferenceResponse, OutputResponse, - parseExecutionType } from './tracing/contracts'; // Scopes diff --git a/packages/agents-a365-observability/src/tracing/contracts.ts b/packages/agents-a365-observability/src/tracing/contracts.ts index bfc024eb..2a327312 100644 --- a/packages/agents-a365-observability/src/tracing/contracts.ts +++ b/packages/agents-a365-observability/src/tracing/contracts.ts @@ -19,18 +19,6 @@ export enum ExecutionType { Unknown = 'Unknown' } -const _executionTypeValues = new Set(Object.values(ExecutionType)); - -/** - * Safely parse a string into an ExecutionType enum value. - * Returns the ExecutionType if the value is a valid member, otherwise undefined. - */ -export function parseExecutionType(value: string | undefined): ExecutionType | undefined { - if (value !== undefined && _executionTypeValues.has(value)) { - return value as ExecutionType; - } - return undefined; -} /** * Represents different roles that can invoke an agent diff --git a/tests/observability/extension/hosting/observability-hosting-manager.test.ts b/tests/observability/extension/hosting/observability-hosting-manager.test.ts index 8a56022c..99eae6e3 100644 --- a/tests/observability/extension/hosting/observability-hosting-manager.test.ts +++ b/tests/observability/extension/hosting/observability-hosting-manager.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, expect, beforeEach } from '@jest/globals'; +import { describe, it, expect } from '@jest/globals'; import { ObservabilityHostingManager } from '../../../../packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager'; import { BaggageMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware'; import { OutputLoggingMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware'; @@ -12,20 +12,16 @@ function mockAdapter() { } describe('ObservabilityHostingManager', () => { - beforeEach(() => { - (ObservabilityHostingManager as any)._instance = undefined; - }); - it('registers BaggageMiddleware by default', () => { const adapter = mockAdapter(); - ObservabilityHostingManager.configure(adapter); + new ObservabilityHostingManager().configure(adapter, {}); expect(adapter.registered).toHaveLength(1); expect(adapter.registered[0]).toBeInstanceOf(BaggageMiddleware); }); it('registers both middleware when enableOutputLogging is true', () => { const adapter = mockAdapter(); - ObservabilityHostingManager.configure(adapter, { enableOutputLogging: true }); + new ObservabilityHostingManager().configure(adapter, { enableOutputLogging: true }); expect(adapter.registered).toHaveLength(2); expect(adapter.registered[0]).toBeInstanceOf(BaggageMiddleware); expect(adapter.registered[1]).toBeInstanceOf(OutputLoggingMiddleware); @@ -33,21 +29,16 @@ describe('ObservabilityHostingManager', () => { it('skips BaggageMiddleware when enableBaggage is false', () => { const adapter = mockAdapter(); - ObservabilityHostingManager.configure(adapter, { enableBaggage: false, enableOutputLogging: true }); + new ObservabilityHostingManager().configure(adapter, { enableBaggage: false, enableOutputLogging: true }); expect(adapter.registered).toHaveLength(1); expect(adapter.registered[0]).toBeInstanceOf(OutputLoggingMiddleware); }); - it('is a singleton — subsequent calls are no-ops', () => { + it('subsequent configure calls on same instance are no-ops', () => { const adapter = mockAdapter(); - const first = ObservabilityHostingManager.configure(adapter, { enableOutputLogging: true }); - const second = ObservabilityHostingManager.configure(adapter, { enableOutputLogging: true }); - expect(first).toBe(second); + const manager = new ObservabilityHostingManager(); + manager.configure(adapter, { enableOutputLogging: true }); + manager.configure(adapter, { enableOutputLogging: true }); expect(adapter.registered).toHaveLength(2); }); - - it('works without adapter', () => { - const manager = ObservabilityHostingManager.configure(); - expect(ObservabilityHostingManager.getInstance()).toBe(manager); - }); }); diff --git a/tests/observability/extension/hosting/output-logging-middleware.test.ts b/tests/observability/extension/hosting/output-logging-middleware.test.ts index be461701..448384bb 100644 --- a/tests/observability/extension/hosting/output-logging-middleware.test.ts +++ b/tests/observability/extension/hosting/output-logging-middleware.test.ts @@ -175,7 +175,6 @@ describe('OutputLoggingMiddleware', () => { expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_CALLER_ID_KEY]).toBe('user-oid'); expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_CALLER_NAME_KEY]).toBe('Test User'); - expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_EXECUTION_TYPE_KEY]).toBe('HumanToAgent'); expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_NAME_KEY]).toBe('teams'); }); diff --git a/tests/observability/extension/hosting/scope-utils.test.ts b/tests/observability/extension/hosting/scope-utils.test.ts index 27cb6439..73b5e69d 100644 --- a/tests/observability/extension/hosting/scope-utils.test.ts +++ b/tests/observability/extension/hosting/scope-utils.test.ts @@ -138,7 +138,6 @@ describe('ScopeUtils.populateFromTurnContext', () => { [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, 'callerAgent-1'], [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, 'caller-agentBlueprintId'], [OpenTelemetryConstants.TENANT_ID_KEY, 'tenant-123'], - [OpenTelemetryConstants.GEN_AI_EXECUTION_TYPE_KEY, ExecutionType.Agent2Agent.toString()], [OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, JSON.stringify(['invoke message'])], [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, 'agent-1'], [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, 'Agent One'], From bfec932d5f665ec3f9d59fad0ee5faaab08afa32 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 27 Feb 2026 13:54:12 -0800 Subject: [PATCH 4/4] Update CHANGELOG to reflect net public API changes vs main Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5b7b113..ccacef0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,22 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **OutputScope**: New tracing scope for outgoing agent messages with caller details, conversation ID, and source metadata. -- **BaggageMiddleware**: Middleware for automatic OpenTelemetry baggage propagation from TurnContext (caller, agent, tenant, source metadata, conversation, execution type). -- **OutputLoggingMiddleware**: Middleware that creates OutputScope spans for outgoing messages with lazy parent span linking via `A365_PARENT_SPAN_KEY` in turnState. +- **OutputScope**: Tracing scope for outgoing agent messages with caller details, conversation ID, source metadata, and parent span linking. +- **BaggageMiddleware**: Middleware for automatic OpenTelemetry baggage propagation from TurnContext. +- **OutputLoggingMiddleware**: Middleware that creates OutputScope spans for outgoing messages with lazy parent span linking via `A365_PARENT_SPAN_KEY`. - **ObservabilityHostingManager**: Manager for configuring hosting-layer observability middleware with `ObservabilityHostingOptions`. -- **CallerDetails** contract for caller identity attributes (id, upn, name, tenant). -- **OutputResponse** contract for output message tracing. ### Changed -- `ObservabilityHostingManager.configure()` is now an instance method with required adapter and options parameters. -- Input/output message recording across scopes now uses JSON array format instead of comma-separated strings. - -### Removed -- `InputScope` (not supported in current schema). -- `MessageLoggingMiddleware` (replaced by split `BaggageMiddleware` + `OutputLoggingMiddleware`). -- `ObservabilityMiddlewareRegistrar` (replaced by `ObservabilityHostingManager`). -- `parseExecutionType` helper (execution type removed from new telemetry schema). +- `InferenceScope.recordInputMessages()` / `recordOutputMessages()` now use JSON array format instead of comma-separated strings. +- `InvokeAgentScope.recordInputMessages()` / `recordOutputMessages()` now use JSON array format instead of comma-separated strings. ## [1.1.0] - 2025-12-09