diff --git a/CHANGELOG.md b/CHANGELOG.md index 4500b9b1..529fd08b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,23 +9,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes (`@microsoft/agents-a365-observability`) +- **`InvokeAgentDetails` renamed to `InvokeAgentScopeDetails`** — Now contains only scope-level config (`endpoint`). Agent identity (`AgentDetails`) is a separate parameter. `sessionId` moved to `Request`. +- **`InvokeAgentScope.start()` — new signature.** `start(request, invokeScopeDetails, agentDetails, callerDetails?, spanDetails?)`. Tenant ID is derived from `agentDetails.tenantId` (required). `userDetails` and `callerAgentDetails` are wrapped in `CallerDetails`. Span options grouped in `SpanDetails`. +- **`InferenceScope.start()` — new signature.** `start(request, details, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). +- **`ExecuteToolScope.start()` — new signature.** `start(request, details, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). +- **`OutputScope.start()` — new signature.** `start(request, response, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). +- **`tenantDetails` parameter removed** from all scope `start()` methods. Tenant ID is now required on `AgentDetails.tenantId`; scopes throw if missing. +- **`AgentRequest` renamed to `Request`** — Unified request interface used across all scopes. Removed `executionType` field. Removed separate `InferenceRequest`, `ToolRequest`, `OutputRequest`. +- **`CallerDetails` renamed to `UserDetails`** — Represents the human caller identity. +- **`injectTraceContext()` renamed to `injectContextToHeaders()`**. +- **`extractTraceContext()` renamed to `extractContextFromHeaders()`**. - **Caller dimension constants renamed to `user.*` namespace** — Aligns with OpenTelemetry semantic conventions and .NET SDK: - `GEN_AI_CALLER_ID_KEY` (`microsoft.caller.id`) → `USER_ID_KEY` (`user.id`) - `GEN_AI_CALLER_NAME_KEY` (`microsoft.caller.name`) → `USER_NAME_KEY` (`user.name`) - `GEN_AI_CALLER_UPN_KEY` (`microsoft.caller.upn`) → `USER_EMAIL_KEY` (`user.email`) - `GEN_AI_AGENT_UPN_KEY` (`microsoft.agent.user.upn`) → `GEN_AI_AGENT_EMAIL_KEY` (`microsoft.agent.user.email`) - `GEN_AI_CALLER_AGENT_UPN_KEY` (`microsoft.a365.caller.agent.user.upn`) → `GEN_AI_CALLER_AGENT_EMAIL_KEY` (`microsoft.a365.caller.agent.user.email`) -- **`CallerDetails` properties renamed** — `callerId` → `userId`, `callerUpn` → `userEmail`, `callerName` → `userName`. +- **`UserDetails` properties renamed** — `callerId` → `userId`, `callerUpn` → `userEmail`, `callerName` → `userName`. - **`AgentDetails.agentUPN` renamed to `AgentDetails.agentEmail`**. - **`BaggageBuilder` methods renamed** — `callerId()` → `userId()`, `callerName()` → `userName()`, `callerUpn()` → `userEmail()`, `agentUpn()` → `agentEmail()`. -- **`SourceMetadata` renamed to `Channel`** — The exported interface representing invocation channel information is renamed from `SourceMetadata` to `Channel`. -- **`AgentRequest.sourceMetadata` renamed to `AgentRequest.channel`** — The optional property on `AgentRequest` is renamed from `sourceMetadata` to `channel` (type changed from `SourceMetadata` to `Channel`). +- **`SourceMetadata` renamed to `Channel`** — The exported interface representing invocation channel information is renamed from `SourceMetadata` to `Channel`. The `AgentRequest.sourceMetadata` property is renamed to `channel`. - **`BaggageBuilder.serviceName()` renamed to `BaggageBuilder.operationSource()`** — Fluent setter for the service name baggage value. - **`BaggageBuilder.sourceMetadataName()` renamed to `BaggageBuilder.channelName()`** — Fluent setter for the channel name baggage value. - **`BaggageBuilder.sourceMetadataDescription()` renamed to `BaggageBuilder.channelLink()`** — Fluent setter for the channel link baggage value. -- **`InferenceScope.start()` parameter `sourceMetadata` renamed to `channel`** — Type changed from `Pick` to `Pick`. -- **`ExecuteToolScope.start()` parameter `sourceMetadata` renamed to `channel`** — Type changed from `Pick` to `Pick`. -- **`InvokeAgentScope`** now reads `request.channel` instead of `request.sourceMetadata` for channel name/link tags. + +### Added (`@microsoft/agents-a365-observability`) + +- **`SpanDetails`** — New interface grouping `parentContext`, `startTime`, `endTime`, `spanKind` for scope construction. +- **`CallerDetails`** — New interface wrapping `userDetails` and `callerAgentDetails` for `InvokeAgentScope`. +- **`Request`** — Unified request context interface (`conversationId`, `channel`, `content`, `sessionId`) used across all scopes. +- **`OpenTelemetryScope.recordCancellation()`** — Records a cancellation event on the span with `error.type = 'TaskCanceledException'`. +- **`OpenTelemetryConstants.ERROR_TYPE_CANCELLED`** — Constant for the cancellation error type value. +- **`ObservabilityBuilder.withServiceNamespace()`** — Configures the `service.namespace` resource attribute. ### Breaking Changes (`@microsoft/agents-a365-observability-hosting`) @@ -39,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`ScopeUtils.populateInferenceScopeFromTurnContext(details, turnContext, authToken, ...)`** — New required `authToken: string` parameter. - **`ScopeUtils.populateInvokeAgentScopeFromTurnContext(details, turnContext, authToken, ...)`** — New required `authToken: string` parameter. - **`ScopeUtils.populateExecuteToolScopeFromTurnContext(details, turnContext, authToken, ...)`** — New required `authToken: string` parameter. -- **`ScopeUtils.buildInvokeAgentDetails(details, turnContext, authToken)`** — New required `authToken: string` parameter. +- **`ScopeUtils.buildInvokeAgentDetails()`** — Now accepts `AgentDetails` (was `InvokeAgentDetails`) and returns flat `AgentDetails` instead of the old `InvokeAgentDetails` wrapper. ### Added diff --git a/docs/design.md b/docs/design.md index 1bdc3355..d036fd7c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -125,12 +125,14 @@ The foundation for distributed tracing in agent applications. Built on OpenTelem | Interface | Purpose | |-----------|---------| -| `InvokeAgentDetails` | Agent endpoint, session ID, and invocation metadata | -| `AgentDetails` | Agent identification and metadata | -| `TenantDetails` | Tenant identification for multi-tenant scenarios | +| `Request` | Request payload (channel, conversationId, content, sessionId) | +| `AgentDetails` | Agent identification and metadata (includes tenantId) | +| `InvokeAgentScopeDetails` | Details for invoking agent scope | | `InferenceDetails` | Model name, tokens, provider information | | `ToolCallDetails` | Tool name, arguments, endpoint | -| `CallerDetails` | Caller identification and context | +| `CallerDetails` | Wrapper for caller identity: `userDetails` (human) and/or `callerAgentDetails` (A2A) | +| `UserDetails` | Human caller identification (userId, userEmail, userName, callerClientIp) | +| `SpanDetails` | Optional span configuration (parentContext, startTime, endTime, spanKind) | **Usage Example:** @@ -159,10 +161,10 @@ const scope = new BaggageBuilder() scope.run(() => { // Trace agent invocation using agentScope = InvokeAgentScope.start( - invokeAgentDetails, - tenantDetails, - callerAgentDetails, - callerDetails + { conversationId, channel: { name: 'Teams' } }, // Request + { endpoint: { host: 'api.example.com' } }, // InvokeAgentScopeDetails + { agentId, agentName, tenantId }, // AgentDetails + { userDetails: { userId, userName } } // CallerDetails (optional) ); // Agent logic here @@ -315,7 +317,7 @@ export class ObservabilityManager { All scope classes implement the `Disposable` interface for automatic span lifecycle management: ```typescript -using scope = InvokeAgentScope.start(details, tenantDetails); +using scope = InvokeAgentScope.start(request, scopeDetails, agentDetails); // Span is active scope.recordResponse('result'); // Span automatically ends when scope is disposed diff --git a/packages/agents-a365-observability-hosting/docs/design.md b/packages/agents-a365-observability-hosting/docs/design.md index 9bc3da18..880e758c 100644 --- a/packages/agents-a365-observability-hosting/docs/design.md +++ b/packages/agents-a365-observability-hosting/docs/design.md @@ -192,12 +192,13 @@ async function onMessage(turnContext: TurnContext, turnState: TurnState) { return baggageScope.run(async () => { // Create agent invocation scope using scope = InvokeAgentScope.start( + { conversationId: turnContext.activity.conversation?.id, sessionId: turnContext.activity.conversation?.id }, + {}, // InvokeAgentScopeDetails { agentId: turnContext.activity.recipient?.agenticAppId, agentName: turnContext.activity.recipient?.name, - sessionId: turnContext.activity.conversation?.id - }, - { tenantId: turnContext.activity.recipient?.tenantId } + tenantId: turnContext.activity.recipient?.tenantId + } ); // Agent processing... diff --git a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts index 3e6b429e..2b0a525d 100644 --- a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts +++ b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts @@ -5,9 +5,10 @@ import { TurnContext, Middleware, SendActivitiesHandler } from '@microsoft/agent import { OutputScope, AgentDetails, - TenantDetails, - CallerDetails, + UserDetails, ParentSpanRef, + Request, + SpanDetails, logger, isPerRequestExportEnabled, } from '@microsoft/agents-a365-observability'; @@ -39,19 +40,23 @@ export class OutputLoggingMiddleware implements Middleware { async onTurn(context: TurnContext, next: () => Promise): Promise { const authToken = this.resolveAuthToken(context); const agentDetails = ScopeUtils.deriveAgentDetails(context, authToken); - const tenantDetails = ScopeUtils.deriveTenantDetails(context); - if (!agentDetails || !tenantDetails) { + if (!agentDetails || !agentDetails.tenantId) { await next(); return; } - const callerDetails = ScopeUtils.deriveCallerDetails(context); + const userDetails = ScopeUtils.deriveCallerDetails(context); const conversationId = ScopeUtils.deriveConversationId(context); const channel = ScopeUtils.deriveChannelObject(context); + const request: Request = { + conversationId, + channel, + }; + context.onSendActivities( - this._createSendHandler(context, agentDetails, tenantDetails, callerDetails, conversationId, channel) + this._createSendHandler(context, agentDetails, userDetails, request) ); await next(); @@ -81,10 +86,8 @@ export class OutputLoggingMiddleware implements Middleware { private _createSendHandler( turnContext: TurnContext, agentDetails: AgentDetails, - tenantDetails: TenantDetails, - callerDetails?: CallerDetails, - conversationId?: string, - channel?: { name?: string; description?: string }, + userDetails?: UserDetails, + request?: Request, ): SendActivitiesHandler { return async (_ctx, activities, sendNext) => { const messages = activities @@ -102,14 +105,16 @@ export class OutputLoggingMiddleware implements Middleware { ); } + const spanDetails: SpanDetails | undefined = parentSpanRef + ? { parentContext: parentSpanRef } + : undefined; + const outputScope = OutputScope.start( + request ?? {}, { messages }, agentDetails, - tenantDetails, - callerDetails, - conversationId, - channel, - parentSpanRef, + userDetails, + spanDetails, ); try { return await sendNext(); diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index 09cd720b..2e16ab66 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -10,11 +10,14 @@ import { InferenceScope, ExecuteToolScope, AgentDetails, - TenantDetails, + UserDetails, CallerDetails, InferenceDetails, - InvokeAgentDetails, ToolCallDetails, + Request, + SpanDetails, + InvokeAgentScopeDetails, + TenantDetails, } from '@microsoft/agents-a365-observability'; import { resolveEmbodiedAgentIds } from './TurnContextUtils'; @@ -34,7 +37,7 @@ export class ScopeUtils { } return scope; } - + // ---------------------- // Context-derived helpers // ---------------------- @@ -71,7 +74,7 @@ export class ScopeUtils { } as AgentDetails; } - + /** * Derive caller agent details from the activity from. * @param turnContext Activity context @@ -95,9 +98,9 @@ export class ScopeUtils { /** * Derive caller identity details (id, email, name, tenant) from the activity from. * @param turnContext Activity context - * @returns Caller details when available; otherwise undefined. + * @returns User details when available; otherwise undefined. */ - public static deriveCallerDetails(turnContext: TurnContext): CallerDetails | undefined { + public static deriveCallerDetails(turnContext: TurnContext): UserDetails | undefined { const from = turnContext?.activity?.from; if (!from) return undefined; return { @@ -105,7 +108,7 @@ export class ScopeUtils { userEmail: from.agenticUserId, userName: from.name, tenantId: from.tenantId, - } as CallerDetails; + } as UserDetails; } /** @@ -131,7 +134,7 @@ export class ScopeUtils { /** * Create an `InferenceScope` using `details` and values derived from the provided `TurnContext`. - * Derives `agentDetails`, `tenantDetails`, `conversationId`, and `channel` (name/description) from context. + * Derives `conversationId` and `channel` (name/description) from context. * Also records input messages from the context if present. * @param details The inference call details (model, provider, tokens, etc.). * @param turnContext The current activity context to derive scope parameters from. @@ -148,28 +151,36 @@ export class ScopeUtils { endTime?: TimeInput ): InferenceScope { const agent = ScopeUtils.deriveAgentDetails(turnContext, authToken); - const tenant = ScopeUtils.deriveTenantDetails(turnContext); + const caller = ScopeUtils.deriveCallerDetails(turnContext); const conversationId = ScopeUtils.deriveConversationId(turnContext); const channel = ScopeUtils.deriveChannelObject(turnContext); if (!agent) { throw new Error('populateInferenceScopeFromTurnContext: Missing agent details on TurnContext (recipient)'); } - if (!tenant) { - throw new Error('populateInferenceScopeFromTurnContext: Missing tenant details on TurnContext (recipient)'); - } - const scope = InferenceScope.start(details, agent, tenant, conversationId, channel, undefined, startTime, endTime); + const hasChannel = channel.name !== undefined || channel.description !== undefined; + const request: Request = { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + }; + + const spanDetails: SpanDetails | undefined = (startTime || endTime) + ? { startTime, endTime } + : undefined; + + const scope = InferenceScope.start(request, details, agent, caller, spanDetails); this.setInputMessageTags(scope, turnContext); return scope; } /** * Create an `InvokeAgentScope` using `details` and values derived from the provided `TurnContext`. - * Populates `conversationId` and `request.channel` (name/link) in `details` from the `TurnContext`, overriding any existing values. - * Derives `tenantDetails`, `callerAgentDetails` (from caller), and `callerDetails` (from user). - * Also sets execution type and input messages from the context if present. - * @param details The invoke-agent call details to be augmented and used for the scope. + * Builds a separate `Request` with `conversationId` and `channel` from context. + * Merges agent identity from context into `details` via `buildInvokeAgentDetailsCore`. + * Derives `callerAgentDetails` (from caller) and `userDetails` (human caller). + * Also records input messages from the context if present. + * @param details The agent details to be augmented with context-derived identity. * @param turnContext The current activity context to derive scope parameters from. * @param authToken Auth token for resolving agent identity from token claims. * @param startTime Optional explicit start time (ms epoch, Date, or HrTime). @@ -178,62 +189,70 @@ export class ScopeUtils { * @returns A started `InvokeAgentScope` enriched with context-derived parameters. */ static populateInvokeAgentScopeFromTurnContext( - details: InvokeAgentDetails, + details: AgentDetails, + scopeDetails: InvokeAgentScopeDetails, turnContext: TurnContext, authToken: string, startTime?: TimeInput, endTime?: TimeInput, spanKind?: SpanKind ): InvokeAgentScope { - const tenant = ScopeUtils.deriveTenantDetails(turnContext); const callerAgent = ScopeUtils.deriveCallerAgent(turnContext); const caller = ScopeUtils.deriveCallerDetails(turnContext); - const invokeAgentDetails = ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); + const conversationId = ScopeUtils.deriveConversationId(turnContext); + const channel = ScopeUtils.deriveChannelObject(turnContext); - if (!tenant) { - throw new Error('populateInvokeAgentScopeFromTurnContext: Missing tenant details on TurnContext (recipient)'); - } + // Merge agent identity from TurnContext into details.details + const agentDetails = ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); + + // Build the request with channel and conversationId from context + const hasChannel = channel.name !== undefined || channel.description !== undefined; + const request: Request = { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + }; - const scope = InvokeAgentScope.start(invokeAgentDetails, tenant, callerAgent, caller, undefined, startTime, endTime, spanKind); + // Build caller info with both human caller and caller agent details + const callerDetails: CallerDetails = { + userDetails: caller, + callerAgentDetails: callerAgent, + }; + + const spanDetailsObj: SpanDetails | undefined = (startTime || endTime || spanKind) + ? { startTime, endTime, spanKind } + : undefined; + + const scope = InvokeAgentScope.start(request, scopeDetails, agentDetails, callerDetails, spanDetailsObj); this.setInputMessageTags(scope, turnContext); return scope; } /** - * Build InvokeAgentDetails by merging provided details with agent info, conversation id and channel from the TurnContext. - * @param details Base invoke-agent details to augment + * Build agent details by merging provided details with agent info from the TurnContext. + * @param details Base agent details to augment * @param turnContext Activity context * @param authToken Auth token for resolving agent identity from token claims. - * @returns New InvokeAgentDetails suitable for starting an InvokeAgentScope. + * @returns Merged AgentDetails with context-derived identity. */ - public static buildInvokeAgentDetails(details: InvokeAgentDetails, turnContext: TurnContext, authToken: string): InvokeAgentDetails { + public static buildInvokeAgentDetails(details: AgentDetails, turnContext: TurnContext, authToken: string): AgentDetails { return ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); } - private static buildInvokeAgentDetailsCore(details: InvokeAgentDetails, turnContext: TurnContext, authToken: string): InvokeAgentDetails { - const agent = ScopeUtils.deriveAgentDetails(turnContext, authToken); - const channelFromContext = ScopeUtils.deriveChannelObject(turnContext); - const baseRequest = details.request ?? {}; - const baseChannel = baseRequest.channel ?? {}; - const mergedChannel = { - ...baseChannel, - ...(channelFromContext.name !== undefined ? { name: channelFromContext.name } : {}), - ...(channelFromContext.description !== undefined ? { description: channelFromContext.description } : {}), - }; - return { + private static buildInvokeAgentDetailsCore(details: AgentDetails, turnContext: TurnContext, authToken: string): AgentDetails { + const derivedAgentDetails = ScopeUtils.deriveAgentDetails(turnContext, authToken); + + // Merge derived agent identity into details + const mergedAgent: AgentDetails = { ...details, - ...agent, - conversationId: ScopeUtils.deriveConversationId(turnContext), - request: { - ...baseRequest, - channel: mergedChannel - } + ...(derivedAgentDetails ?? {}), }; + + return mergedAgent; } /** * Create an `ExecuteToolScope` using `details` and values derived from the provided `TurnContext`. - * Derives `agentDetails`, `tenantDetails`, `conversationId`, and `channel` (name/link) from context. + * Derives `conversationId` and `channel` (name/link) from context. * @param details The tool call details (name, type, args, call id, etc.). * @param turnContext The current activity context to derive scope parameters from. * @param authToken Auth token for resolving agent identity from token claims. @@ -252,16 +271,25 @@ export class ScopeUtils { spanKind?: SpanKind ): ExecuteToolScope { const agent = ScopeUtils.deriveAgentDetails(turnContext, authToken); - const tenant = ScopeUtils.deriveTenantDetails(turnContext); + const caller = ScopeUtils.deriveCallerDetails(turnContext); const conversationId = ScopeUtils.deriveConversationId(turnContext); const channel = ScopeUtils.deriveChannelObject(turnContext); + if (!agent) { throw new Error('populateExecuteToolScopeFromTurnContext: Missing agent details on TurnContext (recipient)'); } - if (!tenant) { - throw new Error('populateExecuteToolScopeFromTurnContext: Missing tenant details on TurnContext (recipient)'); - } - const scope = ExecuteToolScope.start(details, agent, tenant, conversationId, channel, undefined, startTime, endTime, undefined, spanKind); + + const hasChannel = channel.name !== undefined || channel.description !== undefined; + const request: Request = { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + }; + + const spanDetailsObj: SpanDetails | undefined = (startTime || endTime || spanKind) + ? { startTime, endTime, spanKind } + : undefined; + + const scope = ExecuteToolScope.start(request, details, agent, caller, spanDetailsObj); return scope; } diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index 7a9c48ba..76bb81b6 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -129,23 +129,19 @@ abstract class OpenTelemetryScope implements Disposable { Traces agent invocation operations: ```typescript -import { InvokeAgentScope, InvokeAgentDetails, TenantDetails } from '@microsoft/agents-a365-observability'; +import { InvokeAgentScope, InvokeAgentScopeDetails, AgentDetails, Request, CallerDetails } from '@microsoft/agents-a365-observability'; -using scope = InvokeAgentScope.start( - { - agentId: 'agent-123', - agentName: 'MyAgent', - endpoint: { host: 'api.example.com', port: 443 }, - sessionId: 'session-456', - request: { - content: 'Hello', - executionType: ExecutionType.HumanToAgent - } - }, - { tenantId: 'tenant-789' }, - callerAgentDetails, // Optional caller agent - callerDetails // Optional caller user -); +const request: Request = { content: 'Hello', channel: { name: 'Teams' }, sessionId: 'session-456' }; +const scopeDetails: InvokeAgentScopeDetails = { + endpoint: { host: 'api.example.com', port: 443 } +}; +const agentDetails: AgentDetails = { agentId: 'agent-123', agentName: 'MyAgent', tenantId: 'tenant-789' }; +const callerInfo: CallerDetails = { + userDetails: { userId: 'user-1', userName: 'User' }, + callerAgentDetails: callerAgent // Optional, for A2A scenarios +}; + +using scope = InvokeAgentScope.start(request, scopeDetails, agentDetails, callerInfo); scope.recordInputMessages(['Hello']); // ... agent processing ... @@ -156,9 +152,9 @@ scope.recordOutputMessages(['Hi there!']); **Span attributes recorded:** - Server address and port - Session ID -- Execution type and channel +- Channel name and link - Input/output messages -- Caller details (ID, UPN, name, tenant, client IP) +- User details (ID, UPN, name, tenant, client IP) - Caller agent details (if agent-to-agent) #### InferenceScope ([InferenceScope.ts](../src/tracing/scopes/InferenceScope.ts)) @@ -169,15 +165,13 @@ Traces LLM/AI model inference calls: import { InferenceScope, InferenceDetails, InferenceOperationType } from '@microsoft/agents-a365-observability'; using scope = InferenceScope.start( + { conversationId: 'conv-123' }, // Request (required) { operationName: InferenceOperationType.CHAT, model: 'gpt-4', providerName: 'openai' }, - agentDetails, - tenantDetails, - conversationId, - channel + agentDetails // Must include tenantId ); scope.recordInputMessages(['User message']); @@ -197,6 +191,7 @@ Traces tool execution operations: import { ExecuteToolScope, ToolCallDetails } from '@microsoft/agents-a365-observability'; using scope = ExecuteToolScope.start( + {}, // Request (required) { toolName: 'search', arguments: JSON.stringify({ query: 'weather' }), @@ -204,10 +199,7 @@ using scope = ExecuteToolScope.start( toolType: 'mcp', endpoint: { host: 'tools.example.com' } }, - agentDetails, - tenantDetails, - conversationId, - channel + agentDetails // Must include tenantId ); // ... tool execution ... @@ -226,9 +218,9 @@ const scope = new BaggageBuilder() .tenantId('tenant-123') .agentId('agent-456') .correlationId('corr-789') - .callerId('user-abc') + .userId('user-abc') .sessionId('session-xyz') - .callerUpn('user@example.com') + .userEmail('user@example.com') .conversationId('conv-123') .build(); @@ -252,25 +244,23 @@ const scope2 = BaggageBuilder.setRequestContext( | `tenantId(value)` | `tenant_id` | | `agentId(value)` | `gen_ai.agent.id` | | `agentAuid(value)` | `gen_ai.agent.auid` | -| `agentUpn(value)` | `gen_ai.agent.upn` | +| `agentEmail(value)` | `microsoft.agent.user.email` | | `correlationId(value)` | `correlation_id` | -| `callerId(value)` | `gen_ai.caller.id` | +| `userId(value)` | `user.id` | | `sessionId(value)` | `session_id` | | `conversationId(value)` | `gen_ai.conversation.id` | -| `callerUpn(value)` | `gen_ai.caller.upn` | +| `userEmail(value)` | `user.email` | | `operationSource(value)` | `service.name` | | `channelName(value)` | `gen_ai.execution.source.name` | | `channelLink(value)` | `gen_ai.execution.source.description` | ## Data Interfaces -### InvokeAgentDetails +### InvokeAgentScopeDetails ```typescript -interface InvokeAgentDetails extends AgentDetails { - request?: AgentRequest; +interface InvokeAgentScopeDetails { endpoint?: ServiceEndpoint; - sessionId?: string; } ``` diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index 6ce6b9f0..d55f2fcf 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -1,6 +1,5 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. import { NodeSDK } from '@opentelemetry/sdk-node'; import { ConsoleSpanExporter, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; @@ -11,7 +10,7 @@ import type { TokenResolver } from './tracing/exporter/Agent365ExporterOptions'; import { Agent365ExporterOptions } from './tracing/exporter/Agent365ExporterOptions'; import { PerRequestSpanProcessor } from './tracing/PerRequestSpanProcessor'; import { resourceFromAttributes } from '@opentelemetry/resources'; -import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; +import { ATTR_SERVICE_NAME, ATTR_SERVICE_NAMESPACE } from '@opentelemetry/semantic-conventions'; import { trace } from '@opentelemetry/api'; import { ClusterCategory, IConfigurationProvider } from '@microsoft/agents-a365-runtime'; import logger, { setLogger, DefaultLogger, type ILogger } from './utils/logging'; @@ -26,6 +25,9 @@ export interface BuilderOptions { /** Custom service version for telemetry */ serviceVersion?: string; + /** Optional service namespace for the OTel resource (service.namespace attribute) */ + serviceNamespace?: string; + tokenResolver?: TokenResolver; /** Environment / cluster category (e.g., "preprod", "prod"). */ clusterCategory?: ClusterCategory; @@ -72,6 +74,16 @@ export class ObservabilityBuilder { return this; } + /** + * Configures the service namespace for telemetry (service.namespace resource attribute) + * @param serviceNamespace The service namespace + * @returns The builder instance for method chaining + */ + public withServiceNamespace(serviceNamespace: string): ObservabilityBuilder { + this.options.serviceNamespace = serviceNamespace; + return this; + } + /** * Configures the token resolver for Agent 365 exporter * @param tokenResolver Function to resolve authentication tokens @@ -190,9 +202,15 @@ export class ObservabilityBuilder { ? `${this.options.serviceName}-${this.options.serviceVersion}` : this.options.serviceName ?? 'Agent365-TypeScript'; - return resourceFromAttributes({ + const attrs: Record = { [ATTR_SERVICE_NAME]: serviceName, - }); + }; + + if (this.options.serviceNamespace) { + attrs[ATTR_SERVICE_NAMESPACE] = this.options.serviceNamespace; + } + + return resourceFromAttributes(attrs); } /** diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index 2f4fe96a..4366cfa8 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -1,6 +1,5 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. // Main SDK classes export { ObservabilityManager } from './ObservabilityManager'; @@ -23,8 +22,8 @@ export { ParentSpanRef, runWithParentSpanRef, createContextWithParentSpanRef } f export { HeadersCarrier, ParentContext, - injectTraceContext, - extractTraceContext, + injectContextToHeaders, + extractContextFromHeaders, runWithExtractedTraceContext } from './tracing/context/trace-context-propagation'; @@ -33,11 +32,12 @@ export { ExecutionType, InvocationRole, Channel, - AgentRequest, + Request, AgentDetails, TenantDetails, ToolCallDetails, - InvokeAgentDetails, + InvokeAgentScopeDetails, + UserDetails, CallerDetails, EnhancedAgentDetails, ServiceEndpoint, @@ -45,6 +45,7 @@ export { InferenceOperationType, InferenceResponse, OutputResponse, + SpanDetails, } from './tracing/contracts'; // Scopes diff --git a/packages/agents-a365-observability/src/tracing/constants.ts b/packages/agents-a365-observability/src/tracing/constants.ts index 64cd5dec..b114e5dd 100644 --- a/packages/agents-a365-observability/src/tracing/constants.ts +++ b/packages/agents-a365-observability/src/tracing/constants.ts @@ -15,6 +15,7 @@ export class OpenTelemetryConstants { // OpenTelemetry semantic conventions public static readonly ERROR_TYPE_KEY = 'error.type'; + public static readonly ERROR_TYPE_CANCELLED = 'TaskCanceledException'; public static readonly ERROR_MESSAGE_KEY = 'error.message'; public static readonly AZ_NAMESPACE_KEY = 'az.namespace'; public static readonly SERVER_ADDRESS_KEY = 'server.address'; diff --git a/packages/agents-a365-observability/src/tracing/context/trace-context-propagation.ts b/packages/agents-a365-observability/src/tracing/context/trace-context-propagation.ts index 392e48d7..6e603a7b 100644 --- a/packages/agents-a365-observability/src/tracing/context/trace-context-propagation.ts +++ b/packages/agents-a365-observability/src/tracing/context/trace-context-propagation.ts @@ -13,7 +13,7 @@ export type HeadersCarrier = Record; /** * A parent context for span creation. Accepts either: * - {@link ParentSpanRef}: explicit traceId/spanId pair (manual approach) - * - {@link Context}: an OTel Context, typically from {@link extractTraceContext} or `propagation.extract()` + * - {@link Context}: an OTel Context, typically from {@link extractContextFromHeaders} or `propagation.extract()` */ export type ParentContext = ParentSpanRef | Context; @@ -52,11 +52,11 @@ export function isParentSpanRef(value: ParentContext): value is ParentSpanRef { * @example * ```typescript * const headers: Record = {}; - * injectTraceContext(headers); + * injectContextToHeaders(headers); * await fetch('http://service-b/process', { headers }); * ``` */ -export function injectTraceContext( +export function injectContextToHeaders( headers: Record, ctx?: Context ): Record { @@ -75,11 +75,11 @@ export function injectTraceContext( * * @example * ```typescript - * const parentCtx = extractTraceContext(req.headers); - * const scope = InvokeAgentScope.start(details, tenantDetails, undefined, undefined, parentCtx); + * const parentCtx = extractContextFromHeaders(req.headers); + * const scope = InvokeAgentScope.start(request, scopeDetails, agentDetails, undefined, { parentContext: parentCtx }); * ``` */ -export function extractTraceContext( +export function extractContextFromHeaders( headers: HeadersCarrier, baseCtx?: Context ): Context { @@ -98,11 +98,11 @@ export function extractTraceContext( * @example * ```typescript * runWithExtractedTraceContext(req.headers, () => { - * const scope = InvokeAgentScope.start(details, tenantDetails); + * const scope = InvokeAgentScope.start(request, scopeDetails, agentDetails); * scope.dispose(); * }); * ``` */ export function runWithExtractedTraceContext(headers: HeadersCarrier, callback: () => T): T { - return context.with(extractTraceContext(headers), callback); + return context.with(extractContextFromHeaders(headers), callback); } diff --git a/packages/agents-a365-observability/src/tracing/contracts.ts b/packages/agents-a365-observability/src/tracing/contracts.ts index 1ff4824f..8b66dad5 100644 --- a/packages/agents-a365-observability/src/tracing/contracts.ts +++ b/packages/agents-a365-observability/src/tracing/contracts.ts @@ -1,6 +1,8 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { SpanKind, TimeInput } from '@opentelemetry/api'; +import type { ParentContext } from './context/trace-context-propagation'; /** * Represents different types of agent invocations @@ -68,20 +70,21 @@ export interface Channel { } /** - * Represents a request to an agent with telemetry context + * Represents a request with telemetry context. + * Used across all scope types for channel and conversation tracking. */ -export interface AgentRequest { +export interface Request { /** The content of the request */ content?: string; - /** The type of invocation (how the agent was called) */ - executionType?: ExecutionType; - /** Optional session identifier for grouping related requests */ sessionId?: string; /** Optional channel for the invocation */ channel?: Channel; + + /** Optional conversation identifier */ + conversationId?: string; } /** @@ -99,9 +102,6 @@ export interface AgentDetails { /** The unique identifier for the AI agent */ agentId: string; - /** The identifier for the conversation or session */ - conversationId?: string; - /** The human-readable name of the AI agent */ agentName?: string; @@ -154,9 +154,9 @@ export interface ToolCallDetails { } /** - * Details about a caller + * Details about the human user caller. */ -export interface CallerDetails { +export interface UserDetails { /** The unique identifier for the caller */ userId?: string; @@ -173,6 +173,25 @@ export interface CallerDetails { callerClientIp?: string; } +/** + * Caller details for scope creation. + * Supports human callers, agent callers, or both (A2A with a human in the chain). + * + * **Migration note:** In v1 the name `CallerDetails` referred to human caller + * identity (now {@link UserDetails}). In v2 it was repurposed as a wrapper that + * groups both human and agent caller information. + * + * @see {@link UserDetails} — human caller identity (previously `CallerDetails`) + * @see CHANGELOG.md — breaking changes section for migration guidance + */ +export interface CallerDetails { + /** Optional human caller identity */ + userDetails?: UserDetails; + + /** Optional calling agent identity for A2A (agent-to-agent) scenarios */ + callerAgentDetails?: AgentDetails; +} + /* * @deprecated Use AgentDetails. EnhancedAgentDetails is now an alias of AgentDetails. */ @@ -194,21 +213,15 @@ export interface ServiceEndpoint { } /** - * Details for invoking another agent + * Details for invoking agent scope. */ -export interface InvokeAgentDetails extends AgentDetails { - /** The request payload for the agent invocation */ - request?: AgentRequest; - +export interface InvokeAgentScopeDetails { /** The endpoint for the agent invocation */ endpoint?: ServiceEndpoint; - - /** Session ID for the invocation */ - sessionId?: string; } /** - * Details for an inference call matching C# implementation + * Details for an inference call */ export interface InferenceDetails { /** The operation name/type for the inference */ @@ -265,3 +278,25 @@ export interface OutputResponse { /** The output messages from the agent */ messages: string[]; } + +/** + * Span configuration details for scope creation. + * Groups OpenTelemetry span options into a single object so the scope + * method signature remains stable as new options are added. + */ +export interface SpanDetails { + /** Optional parent context for cross-async-boundary tracing. + * Accepts a ParentSpanRef (manual traceId/spanId) or an OTel Context + * (e.g. from extractContextFromHeaders). */ + parentContext?: ParentContext; + + /** Optional explicit start time (ms epoch, Date, or HrTime). */ + startTime?: TimeInput; + + /** Optional explicit end time (ms epoch, Date, or HrTime). */ + endTime?: TimeInput; + + /** Optional span kind override. */ + spanKind?: SpanKind; +} + diff --git a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts index 428d475f..e2337d45 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts @@ -1,10 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { SpanKind, TimeInput } from '@opentelemetry/api'; +import { SpanKind } from '@opentelemetry/api'; import { OpenTelemetryScope } from './OpenTelemetryScope'; -import { ToolCallDetails, AgentDetails, TenantDetails, Channel, CallerDetails } from '../contracts'; -import { ParentContext } from '../context/trace-context-propagation'; +import { + ToolCallDetails, + AgentDetails, + UserDetails, + Request, + SpanDetails, +} from '../contracts'; import { OpenTelemetryConstants } from '../constants'; /** @@ -13,62 +18,48 @@ import { OpenTelemetryConstants } from '../constants'; export class ExecuteToolScope extends OpenTelemetryScope { /** * Creates and starts a new scope for tool execution tracing. - * @param details The tool call details - * @param agentDetails The agent details - * @param tenantDetails The tenant details - * @param conversationId Optional conversation id to tag on the span (`gen_ai.conversation.id`). - * @param channel Optional channel; only `name` (channel name) and `description` (channel link/URL) are used for tagging. - * @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). Useful when recording a - * tool call after execution has already completed. - * @param endTime Optional explicit end time (ms epoch, Date, or HrTime). When provided, the span will - * use this timestamp when disposed instead of the current wall-clock time. - * @param callerDetails Optional caller details. - * @param spanKind Optional span kind override. Defaults to `SpanKind.INTERNAL`. - * Use `SpanKind.CLIENT` when the tool calls an external service. + * + * @param request Request payload (channel, conversationId, content, sessionId). + * @param details The tool call details (name, type, args, call id, etc.). + * @param agentDetails The agent executing the tool. Tenant ID is derived from `agentDetails.tenantId`. + * @param userDetails Optional human caller identity. + * @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanKind). * @returns A new ExecuteToolScope instance. */ public static start( + request: Request, details: ToolCallDetails, agentDetails: AgentDetails, - tenantDetails: TenantDetails, - conversationId?: string, - channel?: Pick, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, - callerDetails?: CallerDetails, - spanKind?: SpanKind + userDetails?: UserDetails, + spanDetails?: SpanDetails ): ExecuteToolScope { - return new ExecuteToolScope(details, agentDetails, tenantDetails, conversationId, channel, parentContext, startTime, endTime, callerDetails, spanKind); + return new ExecuteToolScope(request, details, agentDetails, userDetails, spanDetails); } private constructor( + request: Request, details: ToolCallDetails, agentDetails: AgentDetails, - tenantDetails: TenantDetails, - conversationId?: string, - channel?: Pick, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, - callerDetails?: CallerDetails, - spanKind?: SpanKind + userDetails?: UserDetails, + spanDetails?: SpanDetails ) { + // Validate tenantId is present (required for telemetry) + if (!agentDetails.tenantId) { + throw new Error('ExecuteToolScope: tenantId is required on agentDetails'); + } + super( - spanKind ?? SpanKind.INTERNAL, + spanDetails?.spanKind ?? SpanKind.INTERNAL, OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, `${OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME} ${details.toolName}`, agentDetails, - tenantDetails, - parentContext, - startTime, - endTime, - callerDetails + spanDetails?.parentContext, + spanDetails?.startTime, + spanDetails?.endTime, + userDetails ); - // Destructure the details object to match C# pattern + // Destructure the details object const { toolName, arguments: args, toolCallId, description, toolType, endpoint } = details; this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_NAME_KEY, toolName); @@ -76,10 +67,9 @@ export class ExecuteToolScope extends OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY, toolType); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_CALL_ID_KEY, toolCallId); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_DESCRIPTION_KEY, description); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, conversationId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, channel?.description); - + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); // Set endpoint information if provided if (endpoint) { diff --git a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts index 4a2e394c..0479fab8 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts @@ -1,17 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { SpanKind, TimeInput } from '@opentelemetry/api'; +import { SpanKind } from '@opentelemetry/api'; import { OpenTelemetryScope } from './OpenTelemetryScope'; import { OpenTelemetryConstants } from '../constants'; import { InferenceDetails, AgentDetails, - TenantDetails, - Channel, - CallerDetails + UserDetails, + Request, + SpanDetails, } from '../contracts'; -import { ParentContext } from '../context/trace-context-propagation'; /** * Provides OpenTelemetry tracing scope for generative AI inference operations. @@ -19,66 +18,57 @@ import { ParentContext } from '../context/trace-context-propagation'; export class InferenceScope extends OpenTelemetryScope { /** * Creates and starts a new scope for inference tracing. - * @param details The inference call details - * @param agentDetails The agent details - * @param tenantDetails The tenant details - * @param conversationId Optional conversation id to tag on the span (`gen_ai.conversation.id`). - * @param channel Optional channel; only `name` (channel name) and `description` (channel link/URL) are used for tagging. - * @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). - * @param callerDetails Optional caller details. + * + * @param request Request payload (channel, conversationId, content, sessionId). + * @param details The inference call details (model, provider, tokens, etc.). + * @param agentDetails The agent performing the inference. Tenant ID is derived from `agentDetails.tenantId`. + * @param userDetails Optional human caller identity. + * @param spanDetails Optional span configuration (parentContext, startTime, endTime). Note: `spanKind` is ignored; InferenceScope always uses `SpanKind.CLIENT`. * @returns A new InferenceScope instance */ public static start( + request: Request, details: InferenceDetails, agentDetails: AgentDetails, - tenantDetails: TenantDetails, - conversationId?: string, - channel?: Pick, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, - callerDetails?: CallerDetails + userDetails?: UserDetails, + spanDetails?: SpanDetails ): InferenceScope { - return new InferenceScope(details, agentDetails, tenantDetails, conversationId, channel, parentContext, startTime, endTime, callerDetails); + return new InferenceScope(request, details, agentDetails, userDetails, spanDetails); } private constructor( + request: Request, details: InferenceDetails, agentDetails: AgentDetails, - tenantDetails: TenantDetails, - conversationId?: string, - channel?: Pick, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, - callerDetails?: CallerDetails + userDetails?: UserDetails, + spanDetails?: SpanDetails ) { + // Validate tenantId is present (required for telemetry) + if (!agentDetails.tenantId) { + throw new Error('InferenceScope: tenantId is required on agentDetails'); + } + super( SpanKind.CLIENT, details.operationName.toString(), `${details.operationName} ${details.model}`, agentDetails, - tenantDetails, - parentContext, - startTime, - endTime, - callerDetails + spanDetails?.parentContext, + spanDetails?.startTime, + spanDetails?.endTime, + userDetails ); - // Set core inference information matching C# implementation - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY, details.operationName.toString()); + // Set core inference information this.setTagMaybe(OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY, details.model); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, details.providerName); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY, details.inputTokens); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY, details.outputTokens); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_RESPONSE_FINISH_REASONS_KEY, details.finishReasons); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_THOUGHT_PROCESS_KEY, details.thoughtProcess); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, conversationId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, channel?.description); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); // Set endpoint information if provided if (details.endpoint) { diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts index 1834ced4..1e913f51 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -1,15 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { SpanKind, TimeInput } from '@opentelemetry/api'; +import { SpanKind } from '@opentelemetry/api'; import { OpenTelemetryScope } from './OpenTelemetryScope'; import { - InvokeAgentDetails, - TenantDetails, + InvokeAgentScopeDetails, CallerDetails, - AgentDetails + Request, + SpanDetails, + AgentDetails, } from '../contracts'; -import { ParentContext } from '../context/trace-context-propagation'; import { OpenTelemetryConstants } from '../constants'; /** @@ -18,91 +18,84 @@ import { OpenTelemetryConstants } from '../constants'; export class InvokeAgentScope extends OpenTelemetryScope { /** * Creates and starts a new scope for agent invocation tracing. - * @param invokeAgentDetails The details of the agent invocation including endpoint, agent information, and conversation context. - * @param tenantDetails The tenant details. - * @param callerAgentDetails The details of the caller agent. - * @param callerDetails The details of the non-agentic caller. - * @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). - * @param spanKind Optional span kind override. Defaults to `SpanKind.CLIENT`. - * Use `SpanKind.SERVER` when the agent is receiving an inbound request. + * + * @param request Request payload (channel, conversationId, content, sessionId). + * @param invokeScopeDetails Scope-level details + * @param agentDetails The agent identity. Tenant ID is derived from `agentDetails.tenantId` (required). + * @param callerDetails Optional caller information. Supports three scenarios: + * - Human caller only: `{ userDetails: { userId, userName, ... } }` + * - Agent caller only: `{ callerAgentDetails: { agentId, agentName, ... } }` + * - Both (A2A with human in chain): `{ userDetails: { ... }, callerAgentDetails: { ... } }` + * @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanKind). * @returns A new InvokeAgentScope instance. */ public static start( - invokeAgentDetails: InvokeAgentDetails, - tenantDetails: TenantDetails, - callerAgentDetails?: AgentDetails, + request: Request, + invokeScopeDetails: InvokeAgentScopeDetails, + agentDetails: AgentDetails, callerDetails?: CallerDetails, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, - spanKind?: SpanKind + spanDetails?: SpanDetails, ): InvokeAgentScope { - return new InvokeAgentScope(invokeAgentDetails, tenantDetails, callerAgentDetails, callerDetails, parentContext, startTime, endTime, spanKind); + return new InvokeAgentScope(request, invokeScopeDetails, agentDetails, callerDetails, spanDetails); } private constructor( - invokeAgentDetails: InvokeAgentDetails, - tenantDetails: TenantDetails, - callerAgentDetails?: AgentDetails, + request: Request, + invokeScopeDetails: InvokeAgentScopeDetails, + agentDetails: AgentDetails, callerDetails?: CallerDetails, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, - spanKind?: SpanKind + spanDetails?: SpanDetails ) { + // Validate tenantId is present (required for telemetry) + if (!agentDetails.tenantId) { + throw new Error('InvokeAgentScope: tenantId is required on agentDetails'); + } + super( - spanKind ?? SpanKind.CLIENT, + spanDetails?.spanKind ?? SpanKind.CLIENT, OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, - invokeAgentDetails.agentName - ? `${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${invokeAgentDetails.agentName}` + agentDetails.agentName + ? `${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${agentDetails.agentName}` : OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, - invokeAgentDetails, - tenantDetails, - parentContext, - startTime, - endTime, - callerDetails + agentDetails, + spanDetails?.parentContext, + spanDetails?.startTime, + spanDetails?.endTime, + callerDetails?.userDetails ); - // Set provider name for agent invocation - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, invokeAgentDetails.providerName); + // Set provider name from agent details + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, agentDetails.providerName); - // Set session ID and endpoint information - this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, invokeAgentDetails.sessionId); + // Set session ID from request + this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request.sessionId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, invokeAgentDetails.agentBlueprintId); - - if (invokeAgentDetails.endpoint) { - this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, invokeAgentDetails.endpoint.host); + if (invokeScopeDetails.endpoint) { + this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, invokeScopeDetails.endpoint.host); // Only record port if it is different from 443 (default HTTPS port) - if (invokeAgentDetails.endpoint.port && invokeAgentDetails.endpoint.port !== 443) { - this.setTagMaybe(OpenTelemetryConstants.SERVER_PORT_KEY, invokeAgentDetails.endpoint.port); + if (invokeScopeDetails.endpoint.port && invokeScopeDetails.endpoint.port !== 443) { + this.setTagMaybe(OpenTelemetryConstants.SERVER_PORT_KEY, invokeScopeDetails.endpoint.port); } } - // Set request-related tags - const requestToUse = invokeAgentDetails.request; - if (requestToUse) { - if (requestToUse.channel) { - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, requestToUse.channel.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, requestToUse.channel.description); - } + // Set channel tags from request + if (request.channel) { + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel.name); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel.description); } - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, invokeAgentDetails.conversationId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - // Set caller agent details tags - if (callerAgentDetails) { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY, callerAgentDetails.agentName); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, callerAgentDetails.agentId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, callerAgentDetails.agentBlueprintId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_USER_ID_KEY, callerAgentDetails.agentAUID); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_EMAIL_KEY, callerAgentDetails.agentEmail); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, callerAgentDetails.platformId); + // Set caller agent details tags for A2A scenarios + const callerAgent = callerDetails?.callerAgentDetails; + if (callerAgent) { + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY, callerAgent.agentName); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, callerAgent.agentId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, callerAgent.agentBlueprintId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_USER_ID_KEY, callerAgent.agentAUID); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_EMAIL_KEY, callerAgent.agentEmail); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, callerAgent.platformId); } } @@ -119,7 +112,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, JSON.stringify(messages)); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, JSON.stringify(messages)); } /** @@ -127,6 +120,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, JSON.stringify(messages)); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, JSON.stringify(messages)); } } diff --git a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts index ad417108..070bc307 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { trace, SpanKind, Span, SpanStatusCode, Attributes, context, AttributeValue, SpanContext, TimeInput } from '@opentelemetry/api'; +import { trace, SpanKind, Span, SpanStatusCode, context, AttributeValue, SpanContext, TimeInput } from '@opentelemetry/api'; import { OpenTelemetryConstants } from '../constants'; -import { AgentDetails, TenantDetails, CallerDetails } from '../contracts'; +import { AgentDetails, UserDetails } from '../contracts'; import { createContextWithParentSpanRef } from '../context/parent-span-context'; import { ParentContext, isParentSpanRef } from '../context/trace-context-propagation'; import logger from '../../utils/logging'; @@ -27,28 +27,26 @@ export abstract class OpenTelemetryScope implements Disposable { * @param kind The kind of span (CLIENT, SERVER, INTERNAL, etc.) * @param operationName The name of the operation being traced * @param spanName The name of the span for display purposes - * @param agentDetails Optional agent details - * @param tenantDetails Optional tenant details + * @param agentDetails Optional agent details. Tenant ID is read from `agentDetails.tenantId`. * @param parentContext Optional parent context for cross-async-boundary tracing. * Accepts a {@link ParentSpanRef} (manual traceId/spanId) or an OTel {@link Context} - * (e.g. from {@link extractTraceContext} for W3C header propagation). + * (e.g. from {@link extractContextFromHeaders} for W3C header propagation). * @param startTime Optional explicit start time (ms epoch, Date, or HrTime). When provided the span * records this timestamp instead of "now", which is useful when recording an operation after it * has already completed (e.g. a tool call whose start time was captured earlier). * @param endTime Optional explicit end time (ms epoch, Date, or HrTime). When provided the span will * use this timestamp when {@link dispose} is called instead of the current wall-clock time. - * @param callerDetails Optional caller identity details (id, upn, name, client ip). + * @param userDetails Optional human caller identity details (id, upn, name, client ip). */ protected constructor( kind: SpanKind, operationName: string, spanName: string, agentDetails?: AgentDetails, - tenantDetails?: TenantDetails, parentContext?: ParentContext, startTime?: TimeInput, endTime?: TimeInput, - callerDetails?: CallerDetails + userDetails?: UserDetails ) { // Determine the context to use for span creation let currentContext = context.active(); @@ -58,12 +56,12 @@ export abstract class OpenTelemetryScope implements Disposable { currentContext = createContextWithParentSpanRef(currentContext, parentContext); logger.info(`[A365Observability] Using explicit parent span: traceId=${parentContext.traceId}, spanId=${parentContext.spanId}`); } else { - // OTel Context path (from extractTraceContext or propagation.extract) + // OTel Context path (from extractContextFromHeaders or propagation.extract) currentContext = parentContext; } } - logger.info(`[A365Observability] Starting span: ${spanName}, operation: ${operationName} for tenantId: ${tenantDetails?.tenantId || 'unknown'}, agentId: ${agentDetails?.agentId || 'unknown'}`); + logger.info(`[A365Observability] Starting span: ${spanName}, operation: ${operationName} for tenantId: ${agentDetails?.tenantId || 'unknown'}, agentId: ${agentDetails?.agentId || 'unknown'}`); // Start span with current context to establish parent-child relationship this.span = OpenTelemetryScope.tracer.startSpan(spanName, { @@ -88,24 +86,21 @@ export abstract class OpenTelemetryScope implements Disposable { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, agentDetails.agentName); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, agentDetails.agentDescription); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_PLATFORM_ID_KEY, agentDetails.platformId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, agentDetails.conversationId); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_ICON_URI_KEY, agentDetails.iconUri); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, agentDetails.agentAUID); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_EMAIL_KEY, agentDetails.agentEmail); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, agentDetails.agentBlueprintId); } - // Set tenant details if provided - if (tenantDetails) { - this.setTagMaybe(OpenTelemetryConstants.TENANT_ID_KEY, tenantDetails.tenantId); - } + // Set tenant ID from agent details + this.setTagMaybe(OpenTelemetryConstants.TENANT_ID_KEY, agentDetails?.tenantId); // Set caller details if provided - if (callerDetails) { - this.setTagMaybe(OpenTelemetryConstants.USER_ID_KEY, callerDetails.userId); - this.setTagMaybe(OpenTelemetryConstants.USER_EMAIL_KEY, callerDetails.userEmail); - this.setTagMaybe(OpenTelemetryConstants.USER_NAME_KEY, callerDetails.userName); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, callerDetails.callerClientIp); + if (userDetails) { + this.setTagMaybe(OpenTelemetryConstants.USER_ID_KEY, userDetails.userId); + this.setTagMaybe(OpenTelemetryConstants.USER_EMAIL_KEY, userDetails.userEmail); + this.setTagMaybe(OpenTelemetryConstants.USER_NAME_KEY, userDetails.userName); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, userDetails.callerClientIp); } } @@ -180,7 +175,7 @@ export abstract class OpenTelemetryScope implements Disposable { } /** - * Sets a tag on the span if telemetry is enabled + * Sets a tag on the span if the value is not null or undefined. * @param name The tag name * @param value The tag value */ @@ -223,6 +218,21 @@ export abstract class OpenTelemetryScope implements Disposable { this.customEndTime = endTime; } + /** + * Records a cancellation event on the span. + * Sets the span status to ERROR with the cancellation reason and marks the error type as 'TaskCanceledException'. + * @param reason Optional cancellation reason. Defaults to 'Task was cancelled'. + */ + public recordCancellation(reason?: string): void { + const message = reason ?? 'Task was cancelled'; + logger.info(`[A365Observability] Recording cancellation on span[${this.span.spanContext().spanId}]: ${message}`); + this.span.setStatus({ + code: SpanStatusCode.ERROR, + message + }); + this.errorType = OpenTelemetryConstants.ERROR_TYPE_CANCELLED; + } + /** * Finalizes the scope and records metrics */ @@ -240,16 +250,13 @@ export abstract class OpenTelemetryScope implements Disposable { ? OpenTelemetryScope.timeInputToMs(this.customEndTime) : Date.now(); const durationMs = Math.max(0, endMs - startMs); - const duration = durationMs / 1000; - const finalTags:Attributes = {}; if (this.errorType) { - finalTags[OpenTelemetryConstants.ERROR_TYPE_KEY] = this.errorType; this.span.setAttributes({ [OpenTelemetryConstants.ERROR_TYPE_KEY]: this.errorType }); } this.hasEnded = true; - logger.info(`[A365Observability] Ending span[${this.span.spanContext().spanId}], duration: ${duration}s`); + logger.info(`[A365Observability] Ending span[${this.span.spanContext().spanId}], duration: ${(durationMs / 1000).toFixed(3)}s`); } /** diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index 8f0036b3..34107f43 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts @@ -1,10 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { SpanKind, TimeInput } from '@opentelemetry/api'; +import { SpanKind } from '@opentelemetry/api'; import { OpenTelemetryScope } from './OpenTelemetryScope'; -import { AgentDetails, TenantDetails, CallerDetails, OutputResponse, Channel } from '../contracts'; -import { ParentContext } from '../context/trace-context-propagation'; +import { AgentDetails, UserDetails, OutputResponse, Request, SpanDetails } from '../contracts'; import { OpenTelemetryConstants } from '../constants'; /** @@ -16,43 +15,36 @@ export class OutputScope extends OpenTelemetryScope { /** * Creates and starts a new scope for output message tracing. + * + * @param request Request payload (channel, conversationId, content, sessionId). * @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, client ip). - * @param conversationId Optional conversation identifier. - * @param channel Optional channel metadata; only `name` and `description` are used for tagging. - * @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). + * @param agentDetails The agent producing the output. Tenant ID is derived from `agentDetails.tenantId`. + * @param userDetails Optional human caller identity details. + * @param spanDetails Optional span configuration (parentContext, startTime, endTime). * @returns A new OutputScope instance. */ public static start( + request: Request, response: OutputResponse, agentDetails: AgentDetails, - tenantDetails: TenantDetails, - callerDetails?: CallerDetails, - conversationId?: string, - channel?: Pick, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput + userDetails?: UserDetails, + spanDetails?: SpanDetails ): OutputScope { - return new OutputScope(response, agentDetails, tenantDetails, callerDetails, conversationId, channel, parentContext, startTime, endTime); + return new OutputScope(request, response, agentDetails, userDetails, spanDetails); } private constructor( + request: Request, response: OutputResponse, agentDetails: AgentDetails, - tenantDetails: TenantDetails, - callerDetails?: CallerDetails, - conversationId?: string, - channel?: Pick, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput + userDetails?: UserDetails, + spanDetails?: SpanDetails ) { + // Validate tenantId is present (required for telemetry) + if (!agentDetails.tenantId) { + throw new Error('OutputScope: tenantId is required on agentDetails'); + } + super( SpanKind.CLIENT, OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME, @@ -60,11 +52,10 @@ export class OutputScope extends OpenTelemetryScope { ? `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentName}` : `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentId}`, agentDetails, - tenantDetails, - parentContext, - startTime, - endTime, - callerDetails + spanDetails?.parentContext, + spanDetails?.startTime, + spanDetails?.endTime, + userDetails ); // Initialize accumulated messages list from the response @@ -77,9 +68,9 @@ export class OutputScope extends OpenTelemetryScope { ); // Set conversation and channel - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, conversationId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, channel?.description); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); } diff --git a/tests/observability/core/observabilityBuilder-options.test.ts b/tests/observability/core/observabilityBuilder-options.test.ts index 3388a9e3..68364b9a 100644 --- a/tests/observability/core/observabilityBuilder-options.test.ts +++ b/tests/observability/core/observabilityBuilder-options.test.ts @@ -1,9 +1,10 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. import { ObservabilityBuilder } from '@microsoft/agents-a365-observability/src/ObservabilityBuilder'; import { ClusterCategory } from '@microsoft/agents-a365-runtime'; +import { ATTR_SERVICE_NAMESPACE } from '@opentelemetry/semantic-conventions'; +import * as resources from '@opentelemetry/resources'; // Mock the Agent365Exporter so we can capture the constructed options without performing network calls. jest.mock('@microsoft/agents-a365-observability/src/tracing/exporter/Agent365Exporter', () => { @@ -74,5 +75,23 @@ describe('ObservabilityBuilder exporterOptions merging', () => { expect(captured.clusterCategory).toBe('prod'); expect(captured.maxQueueSize).toBe(15); expect(captured.scheduledDelayMilliseconds).toBe(5000); // default value - }); + }); +}); + +describe('ObservabilityBuilder serviceNamespace', () => { + it('includes service.namespace only when withServiceNamespace is called', () => { + const resourceSpy = jest.spyOn(resources, 'resourceFromAttributes'); + process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; + + // Without namespace + new ObservabilityBuilder().withService('svc').build(); + expect(resourceSpy.mock.calls[0][0]).not.toHaveProperty(ATTR_SERVICE_NAMESPACE); + + // With namespace + new ObservabilityBuilder().withService('svc').withServiceNamespace('ns').build(); + expect(resourceSpy.mock.calls[1][0][ATTR_SERVICE_NAMESPACE]).toBe('ns'); + + delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; + resourceSpy.mockRestore(); + }); }); diff --git a/tests/observability/core/output-scope.test.ts b/tests/observability/core/output-scope.test.ts index 3dba8e0e..4839d5ba 100644 --- a/tests/observability/core/output-scope.test.ts +++ b/tests/observability/core/output-scope.test.ts @@ -9,7 +9,6 @@ import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-ho import { OutputScope, AgentDetails, - TenantDetails, OutputResponse, OpenTelemetryConstants, ParentSpanRef, @@ -20,12 +19,11 @@ describe('OutputScope', () => { agentId: 'test-agent-123', agentName: 'Test Agent', agentDescription: 'A test agent for output scope testing', - }; - - const testTenantDetails: TenantDetails = { tenantId: '12345678-1234-5678-1234-567812345678', }; + const testRequest = { conversationId: 'test-conv-out', channel: { name: 'OutputChannel', description: 'https://output.channel' } }; + let exporter: InMemorySpanExporter; let provider: BasicTracerProvider; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -75,7 +73,10 @@ describe('OutputScope', () => { 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); + const scope = OutputScope.start( + { conversationId: 'conv-out-1', channel: { name: 'Email', description: 'https://email.link' } }, + response, testAgentDetails + ); expect(scope).toBeInstanceOf(OutputScope); scope.dispose(); @@ -86,6 +87,9 @@ describe('OutputScope', () => { 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); + expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe('conv-out-1'); + expect(attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe('Email'); + expect(attributes[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe('https://email.link'); const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); expect(parsed).toEqual(['First message', 'Second message']); }); @@ -93,7 +97,7 @@ describe('OutputScope', () => { it('should append messages with recordOutputMessages and flush on dispose', async () => { const response: OutputResponse = { messages: ['Initial'] }; - const scope = OutputScope.start(response, testAgentDetails, testTenantDetails); + const scope = OutputScope.start(testRequest, response, testAgentDetails); scope.recordOutputMessages(['Appended 1']); scope.recordOutputMessages(['Appended 2', 'Appended 3']); scope.dispose(); @@ -110,9 +114,8 @@ describe('OutputScope', () => { const parentSpanId = 'abcdefabcdef1234'; const scope = OutputScope.start( - { messages: ['Test'] }, testAgentDetails, testTenantDetails, - undefined, undefined, undefined, - { traceId: parentTraceId, spanId: parentSpanId } as ParentSpanRef + {}, { messages: ['Test'] }, testAgentDetails, + undefined, { parentContext: { traceId: parentTraceId, spanId: parentSpanId } as ParentSpanRef } ); scope.dispose(); @@ -121,4 +124,8 @@ describe('OutputScope', () => { expect(span.spanContext().traceId).toBe(parentTraceId); expect(span.parentSpanContext?.spanId).toBe(parentSpanId); }); + + it('should throw when agentDetails.tenantId is missing', () => { + expect(() => OutputScope.start({}, { messages: ['m'] }, { agentId: 'a' } as any)).toThrow('OutputScope: tenantId is required on agentDetails'); + }); }); diff --git a/tests/observability/core/parent-span-ref.test.ts b/tests/observability/core/parent-span-ref.test.ts index 95b33090..7811ce37 100644 --- a/tests/observability/core/parent-span-ref.test.ts +++ b/tests/observability/core/parent-span-ref.test.ts @@ -11,12 +11,10 @@ import { InvokeAgentScope, InferenceScope, ExecuteToolScope, - InvokeAgentDetails, InferenceDetails, InferenceOperationType, ToolCallDetails, AgentDetails, - TenantDetails, } from '@microsoft/agents-a365-observability'; describe('ParentSpanRef - Explicit Parent Span Support', () => { @@ -68,13 +66,11 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { agentId: 'test-agent', agentName: 'Test Agent', agentDescription: 'A test agent', - conversationId: 'test-conv-123' - }; - - const testTenantDetails: TenantDetails = { tenantId: 'test-tenant-456' }; + const testRequest = { conversationId: 'test-conv-psr', channel: { name: 'PSRChannel', description: 'https://psr.channel' } }; + describe('runWithParentSpanRef', () => { it('should execute callback with parent span context', () => { @@ -98,11 +94,11 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { [ 'InvokeAgentScope', (parentRef: ParentSpanRef) => { - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent', - }; - return InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, undefined, parentRef); + return InvokeAgentScope.start(testRequest, {}, { + agentId: 'test-agent', + agentName: 'Test Agent', + tenantId: 'test-tenant-456' + }, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('invokeagent') || name.toLowerCase().includes('invoke_agent'), ], @@ -114,7 +110,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { model: 'gpt-4', providerName: 'openai', }; - return InferenceScope.start(inferenceDetails, testAgentDetails, testTenantDetails, undefined, undefined, parentRef); + return InferenceScope.start(testRequest, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('chat'), ], @@ -125,7 +121,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { toolName: 'test-tool', arguments: '{"param": "value"}', }; - return ExecuteToolScope.start(toolDetails, testAgentDetails, testTenantDetails, undefined, undefined, parentRef); + return ExecuteToolScope.start(testRequest, toolDetails, testAgentDetails, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('execute_tool'), ], @@ -162,7 +158,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { const tracer = trace.getTracer('test'); const rootSpan = tracer.startSpan('root-span'); const parentSpanContext = rootSpan.spanContext(); - + const parentRef: ParentSpanRef = { traceId: parentSpanContext.traceId, spanId: parentSpanContext.spanId, @@ -174,13 +170,10 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { // Run a callback with parent context runWithParentSpanRef(parentRef, () => { // Create a scope inside - it should automatically inherit the parent - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'nested-agent', - }; - const nestedScope = InvokeAgentScope.start( - invokeAgentDetails, - testTenantDetails + testRequest, + {}, + { agentId: 'nested-agent', tenantId: 'test-tenant-456' } ); const nestedSpanContext = nestedScope.getSpanContext(); @@ -204,11 +197,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { describe('getSpanContext method', () => { it('should return the span context from a scope (and be usable as ParentSpanRef)', async () => { - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'test-agent', - }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); const spanContext = scope.getSpanContext(); expect(spanContext).toBeDefined(); @@ -226,7 +215,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { const activeParentSpan = trace.wrapSpanContext(spanContext); const baseCtx = trace.setSpan(otelContext.active(), activeParentSpan); const childScope = otelContext.with(baseCtx, () => - InferenceScope.start(inferenceDetails, testAgentDetails, testTenantDetails, undefined, undefined, parentRef) + InferenceScope.start(testRequest, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }) ); expect(childScope.getSpanContext().traceId).toBe(spanContext.traceId); @@ -238,7 +227,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { const spans = exporter.getFinishedSpans(); const parentSpan = spans.find(s => s.name.toLowerCase().includes('invoke_agent')); const childSpan = spans.find(s => s.name.toLowerCase().includes('chat')); - + expect(parentSpan).toBeDefined(); expect(childSpan).toBeDefined(); expect(childSpan!.spanContext().traceId).toBe(parentSpan!.spanContext().traceId); @@ -255,11 +244,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { }; runWithParentSpanRef(parentRef, () => { - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'sampled-agent', - }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'sampled-agent', tenantId: 'test-tenant-456' }); scope.dispose(); }); @@ -269,7 +254,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { const childSpan = spans.find(s => s.name.toLowerCase().includes('invokeagent') || s.name.toLowerCase().includes('invoke_agent') ); - + expect(childSpan).toBeDefined(); expect(childSpan!.spanContext().traceId).toBe(parentRef.traceId); expect(childSpan!.parentSpanContext?.spanId).toBe(parentRef.spanId); @@ -284,11 +269,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { }; runWithParentSpanRef(parentRef, () => { - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'unsampled-agent', - }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'unsampled-agent', tenantId: 'test-tenant-456' }); scope.dispose(); }); @@ -296,11 +277,11 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { const spans = exporter.getFinishedSpans(); // When traceFlags is NONE, the span should still be created but not recorded/exported - const childSpan = spans.find(s => + const childSpan = spans.find(s => (s.name.toLowerCase().includes('invokeagent') || s.name.toLowerCase().includes('invoke_agent')) && s.spanContext().traceId === parentRef.traceId ); - + // The span should not be exported when traceFlags is NONE expect(childSpan).toBeUndefined(); }); @@ -313,22 +294,18 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { }; runWithParentSpanRef(parentRef, () => { - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'default-sampled-agent', - }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'default-sampled-agent', tenantId: 'test-tenant-456' }); scope.dispose(); }); await flushProvider.forceFlush(); const spans = exporter.getFinishedSpans(); - const childSpan = spans.find(s => + const childSpan = spans.find(s => (s.name.toLowerCase().includes('invokeagent') || s.name.toLowerCase().includes('invoke_agent')) && s.spanContext().traceId === parentRef.traceId ); - + // Should be recorded when traceFlags defaults to SAMPLED expect(childSpan).toBeDefined(); expect(childSpan!.spanContext().traceFlags).toBe(TraceFlags.SAMPLED); @@ -348,11 +325,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { const baseCtx = trace.setSpan(otelContext.active(), rootSpan); await otelContext.with(baseCtx, async () => { runWithParentSpanRef(parentRef, () => { - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'inherited-flags-agent', - }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'inherited-flags-agent', tenantId: 'test-tenant-456' }); scope.dispose(); }); }); @@ -362,11 +335,11 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { await flushProvider.forceFlush(); const spans = exporter.getFinishedSpans(); - const childSpan = spans.find(s => + const childSpan = spans.find(s => (s.name.toLowerCase().includes('invokeagent') || s.name.toLowerCase().includes('invoke_agent')) && s.spanContext().traceId === parentRef.traceId ); - + // Should be recorded with traceFlags inherited from active span expect(childSpan).toBeDefined(); expect(childSpan!.spanContext().traceFlags).toBe(parentSpanContext.traceFlags); diff --git a/tests/observability/core/scopes.test.ts b/tests/observability/core/scopes.test.ts index 6b8fd347..156a40c3 100644 --- a/tests/observability/core/scopes.test.ts +++ b/tests/observability/core/scopes.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { describe, it, expect, beforeAll, afterAll, afterEach, jest } from '@jest/globals'; import { trace, SpanKind } from '@opentelemetry/api'; @@ -6,12 +9,11 @@ import { InvokeAgentScope, InferenceScope, AgentDetails, - TenantDetails, - InvokeAgentDetails, + InvokeAgentScopeDetails, ToolCallDetails, InferenceDetails, InferenceOperationType, - CallerDetails, + UserDetails, OpenTelemetryConstants, OpenTelemetryScope, } from '@microsoft/agents-a365-observability'; @@ -35,96 +37,111 @@ describe('Scopes', () => { agentId: 'test-agent', agentName: 'Test Agent', agentDescription: 'A test agent', - conversationId: 'test-conv-123' - }; - - const testTenantDetails: TenantDetails = { tenantId: 'test-tenant-456' }; + const testRequest = { conversationId: 'test-conv-req', channel: { name: 'TestChannel', description: 'https://test.channel' } }; + describe('InvokeAgentScope', () => { it('should create scope with agent details', () => { - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent', - agentDescription: 'A test agent' - }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); + + const scope = InvokeAgentScope.start( + { conversationId: 'conv-req-1', channel: { name: 'Teams', description: 'https://teams.link' } }, + {}, + { + agentId: 'test-agent', + agentName: 'Test Agent', + agentDescription: 'A test agent', + tenantId: 'test-tenant-456' + } + ); expect(scope).toBeInstanceOf(InvokeAgentScope); + const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: 'conv-req-1' }), + expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: 'Teams' }), + expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_LINK_KEY, val: 'https://teams.link' }) + ])); scope?.dispose(); + spy.mockRestore(); }); it('should create scope with agent ID only', () => { - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'simple-agent' - }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'simple-agent', tenantId: 'test-tenant-456' }); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); }); it('should create scope with additional details', () => { - const invokeAgentDetails: InvokeAgentDetails = { + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', agentName: 'Test Agent', agentDescription: 'A test agent', - conversationId: 'conv-123', - iconUri: 'https://example.com/icon.png' - }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + iconUri: 'https://example.com/icon.png', + tenantId: 'test-tenant-456' + }); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); }); it('should create scope with platformId', () => { - const invokeAgentDetails: InvokeAgentDetails = { + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', agentName: 'Test Agent', - platformId: 'platform-xyz-123' - }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + platformId: 'platform-xyz-123', + tenantId: 'test-tenant-456' + }); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); }); it('should create scope with caller details', () => { - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent' - }; - - const callerDetails: CallerDetails = { + const callerDetails: UserDetails = { userId: 'user-123', userName: 'Test User', userEmail: 'test.user@contoso.com', tenantId: 'test-tenant' }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, callerDetails); + + const scope = InvokeAgentScope.start(testRequest, {}, { + agentId: 'test-agent', + agentName: 'Test Agent', + tenantId: 'test-tenant-456' + }, { userDetails: callerDetails }); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); }); + it('should set sessionId from request', () => { + const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); + const scope = InvokeAgentScope.start( + { conversationId: 'conv-1', sessionId: 'session-abc-123' }, + {}, + { agentId: 'test-agent', tenantId: 'test-tenant-456' } + ); + const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.SESSION_ID_KEY, val: 'session-abc-123' }) + ])); + scope?.dispose(); + spy.mockRestore(); + }); + it('should record response', () => { - const invokeAgentDetails: InvokeAgentDetails = { agentId: 'test-agent' }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); expect(() => scope?.recordResponse('Test response')).not.toThrow(); scope?.dispose(); }); it('should record input and output messages', () => { - const invokeAgentDetails: InvokeAgentDetails = { agentId: 'test-agent' }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); expect(() => scope?.recordInputMessages(['Input message 1', 'Input message 2'])).not.toThrow(); expect(() => scope?.recordOutputMessages(['Output message 1', 'Output message 2'])).not.toThrow(); @@ -132,23 +149,53 @@ describe('Scopes', () => { }); it('should record error', () => { - const invokeAgentDetails: InvokeAgentDetails = { agentId: 'test-agent' }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); const error = new Error('Test error'); expect(() => scope?.recordError(error)).not.toThrow(); scope?.dispose(); }); + it('should set conversationId from request', () => { + const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); + const scope = InvokeAgentScope.start( + { conversationId: 'explicit-conv-id' }, + {}, + { agentId: 'test-agent', tenantId: 'test-tenant-456' } + ); + const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: 'explicit-conv-id' }) + ])); + scope?.dispose(); + spy.mockRestore(); + }); + + it('should set channel tags from request.channel', () => { + const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); + const scope = InvokeAgentScope.start( + { channel: { name: 'Teams', description: 'https://teams.link' } }, + {}, + { agentId: 'test-agent', tenantId: 'test-tenant-456' } + ); + const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: 'Teams' }), + expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_LINK_KEY, val: 'https://teams.link' }) + ])); + scope?.dispose(); + spy.mockRestore(); + }); + it('should propagate platformId in span attributes', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const invokeAgentDetails: InvokeAgentDetails = { + + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', agentName: 'Test Agent', - platformId: 'test-platform-123' - }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + platformId: 'test-platform-123', + tenantId: 'test-tenant-456' + }); expect(scope).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -162,19 +209,18 @@ describe('Scopes', () => { it('should propagate caller agent platformId in span attributes', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent' - }; const callerAgentDetails: AgentDetails = { agentId: 'caller-agent', agentName: 'Caller Agent', agentDescription: 'desc', - conversationId: 'conv', platformId: 'caller-platform-xyz' } as any; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, callerAgentDetails, undefined); + const scope = InvokeAgentScope.start(testRequest, {}, { + agentId: 'test-agent', + agentName: 'Test Agent', + tenantId: 'test-tenant-456' + }, { callerAgentDetails }); expect(scope).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -188,11 +234,12 @@ describe('Scopes', () => { it('should set caller and caller-agent IP tags', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const invokeAgentDetails: InvokeAgentDetails = { + const agentDets = { agentId: 'test-agent', - agentName: 'Test Agent' + agentName: 'Test Agent', + tenantId: 'test-tenant-456' }; - const callerDetails: CallerDetails = { + const callerDetails: UserDetails = { userId: 'user-123', tenantId: 'test-tenant', callerClientIp: '10.0.0.5' @@ -201,13 +248,12 @@ describe('Scopes', () => { agentId: 'caller-agent', agentName: 'Caller Agent', agentDescription: 'desc', - conversationId: 'conv', agentClientIP: '192.168.1.100' } as any; - const scope1 = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, callerDetails); + const scope1 = InvokeAgentScope.start(testRequest, {}, agentDets, { userDetails: callerDetails }); expect(scope1).toBeInstanceOf(InvokeAgentScope); - const scope2 = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, callerAgentDetails, undefined); + const scope2 = InvokeAgentScope.start(testRequest, {}, agentDets, { callerAgentDetails }); expect(scope2).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -219,25 +265,75 @@ describe('Scopes', () => { scope2?.dispose(); spy.mockRestore(); }); + + it('should throw when agentDetails.tenantId is missing', () => { + expect(() => InvokeAgentScope.start(testRequest, {}, { agentId: 'a' } as any)).toThrow('InvokeAgentScope: tenantId is required on agentDetails'); + }); + + it('should set both userDetails and callerAgentDetails tags when both are provided', () => { + const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); + const scope = InvokeAgentScope.start(testRequest, {}, { + agentId: 'test-agent', + agentName: 'Test Agent', + tenantId: 'test-tenant-456' + }, { + userDetails: { userId: 'user-1', userName: 'User One' }, + callerAgentDetails: { agentId: 'caller-agent-1', agentName: 'Caller Agent' } as any + }); + + const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.USER_ID_KEY, val: 'user-1' }), + expect.objectContaining({ key: OpenTelemetryConstants.USER_NAME_KEY, val: 'User One' }), + expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, val: 'caller-agent-1' }), + expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY, val: 'Caller Agent' }), + ])); + + scope?.dispose(); + spy.mockRestore(); + }); + + it('should set endpoint tags from typed InvokeAgentScopeDetails', () => { + const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); + const details: InvokeAgentScopeDetails = { endpoint: { host: 'agent-api.contoso.com', port: 8443 } }; + const scope = InvokeAgentScope.start(testRequest, details, { agentId: 'typed-agent', tenantId: 'test-tenant-456' }); + const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.SERVER_ADDRESS_KEY, val: 'agent-api.contoso.com' }), + expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY, val: 8443 }) + ])); + scope?.dispose(); + spy.mockRestore(); + }); + + it('should omit endpoint tags when InvokeAgentScopeDetails is empty', () => { + const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); + const keys = new Set(spy.mock.calls.map(args => args[0])); + expect(keys).not.toContain(OpenTelemetryConstants.SERVER_ADDRESS_KEY); + expect(keys).not.toContain(OpenTelemetryConstants.SERVER_PORT_KEY); + scope?.dispose(); + spy.mockRestore(); + }); }); describe('ExecuteToolScope', () => { it('should create scope with tool details', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const callerDetails: CallerDetails = { + const callerDetails: UserDetails = { userId: 'caller-tool-1', userEmail: 'tool.user@contoso.com', userName: 'Tool User', tenantId: 'tool-tenant', callerClientIp: '10.0.0.10' }; - const scope = ExecuteToolScope.start({ + const scope = ExecuteToolScope.start(testRequest, { toolName: 'test-tool', arguments: '{"param": "value"}', toolCallId: 'call-123', description: 'A test tool', toolType: 'test' - }, testAgentDetails, testTenantDetails, undefined, undefined, undefined, undefined, undefined, callerDetails); + }, testAgentDetails, callerDetails); expect(scope).toBeInstanceOf(ExecuteToolScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -258,15 +354,29 @@ describe('Scopes', () => { }); it('should record response', () => { - const scope = ExecuteToolScope.start({ toolName: 'test-tool' }, testAgentDetails, testTenantDetails); + const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); + const scope = ExecuteToolScope.start( + { conversationId: 'conv-tool-resp', channel: { name: 'Web', description: 'https://web.link' } }, + { toolName: 'test-tool' }, testAgentDetails + ); expect(() => scope?.recordResponse('Tool result')).not.toThrow(); + const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: 'conv-tool-resp' }), + expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: 'Web' }), + expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_LINK_KEY, val: 'https://web.link' }) + ])); scope?.dispose(); + spy.mockRestore(); }); - + it('should set conversationId and channel tags when provided', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = (ExecuteToolScope as unknown as any).start({ toolName: 'test-tool' }, testAgentDetails, testTenantDetails, 'conv-tool-123', { name: 'ChannelTool', description: 'https://channel/tool' }); + const scope = ExecuteToolScope.start( + { conversationId: 'conv-tool-123', channel: { name: 'ChannelTool', description: 'https://channel/tool' } }, + { toolName: 'test-tool' }, testAgentDetails + ); expect(scope).toBeInstanceOf(ExecuteToolScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -285,8 +395,8 @@ describe('Scopes', () => { it('should record non-443 port as a number on ExecuteToolScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = ExecuteToolScope.start( - { toolName: 'test-tool', endpoint: { host: 'tools.example.com', port: 8080 } }, - testAgentDetails, testTenantDetails + testRequest, { toolName: 'test-tool', endpoint: { host: 'tools.example.com', port: 8080 } }, + testAgentDetails ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).toEqual(expect.arrayContaining([ @@ -299,8 +409,8 @@ describe('Scopes', () => { it('should omit port 443 on ExecuteToolScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = ExecuteToolScope.start( - { toolName: 'test-tool', endpoint: { host: 'tools.example.com', port: 443 } }, - testAgentDetails, testTenantDetails + testRequest, { toolName: 'test-tool', endpoint: { host: 'tools.example.com', port: 443 } }, + testAgentDetails ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).not.toEqual(expect.arrayContaining([ @@ -313,8 +423,8 @@ describe('Scopes', () => { it('should record non-443 port as a number on InferenceScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InferenceScope.start( - { operationName: InferenceOperationType.CHAT, model: 'gpt-4', endpoint: { host: 'api.openai.com', port: 8443 } }, - testAgentDetails, testTenantDetails + testRequest, { operationName: InferenceOperationType.CHAT, model: 'gpt-4', endpoint: { host: 'api.openai.com', port: 8443 } }, + testAgentDetails ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).toEqual(expect.arrayContaining([ @@ -327,8 +437,8 @@ describe('Scopes', () => { it('should omit port 443 on InferenceScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InferenceScope.start( - { operationName: InferenceOperationType.CHAT, model: 'gpt-4', endpoint: { host: 'api.openai.com', port: 443 } }, - testAgentDetails, testTenantDetails + testRequest, { operationName: InferenceOperationType.CHAT, model: 'gpt-4', endpoint: { host: 'api.openai.com', port: 443 } }, + testAgentDetails ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).not.toEqual(expect.arrayContaining([ @@ -341,11 +451,13 @@ describe('Scopes', () => { it('should record non-443 port as a number on InvokeAgentScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( - { agentId: 'test-agent', endpoint: { host: 'agent.example.com', port: 9090 } }, - testTenantDetails + testRequest, + { endpoint: { host: 'agent.example.com', port: 9090 } }, + { agentId: 'test-agent', tenantId: 'test-tenant-456' } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.SERVER_ADDRESS_KEY, val: 'agent.example.com' }), expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY, val: 9090 }) ])); scope?.dispose(); @@ -355,8 +467,9 @@ describe('Scopes', () => { it('should omit port 443 on InvokeAgentScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( - { agentId: 'test-agent', endpoint: { host: 'agent.example.com', port: 443 } }, - testTenantDetails + testRequest, + { endpoint: { host: 'agent.example.com', port: 443 } }, + { agentId: 'test-agent', tenantId: 'test-tenant-456' } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).not.toEqual(expect.arrayContaining([ @@ -370,7 +483,7 @@ describe('Scopes', () => { describe('InferenceScope', () => { it('should create scope with inference details', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const callerDetails: CallerDetails = { + const callerDetails: UserDetails = { userId: 'caller-inf-1', userEmail: 'inf.user@contoso.com', userName: 'Inf User', @@ -386,7 +499,7 @@ describe('Scopes', () => { finishReasons: ['stop'] }; - const scope = InferenceScope.start(inferenceDetails, testAgentDetails, testTenantDetails, undefined, undefined, undefined, undefined, undefined, callerDetails); + const scope = InferenceScope.start(testRequest, inferenceDetails, testAgentDetails, callerDetails); expect(scope).toBeInstanceOf(InferenceScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -407,15 +520,26 @@ describe('Scopes', () => { }); it('should create scope with minimal details', () => { + const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const inferenceDetails: InferenceDetails = { operationName: InferenceOperationType.TEXT_COMPLETION, model: 'gpt-3.5-turbo' }; - - const scope = InferenceScope.start(inferenceDetails, testAgentDetails, testTenantDetails); + + const scope = InferenceScope.start( + { conversationId: 'conv-inf-min', channel: { name: 'Slack', description: 'https://slack.link' } }, + inferenceDetails, testAgentDetails + ); expect(scope).toBeInstanceOf(InferenceScope); + const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: 'conv-inf-min' }), + expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: 'Slack' }), + expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_LINK_KEY, val: 'https://slack.link' }) + ])); scope?.dispose(); + spy.mockRestore(); }); it('should record granular telemetry', () => { @@ -423,8 +547,8 @@ describe('Scopes', () => { operationName: InferenceOperationType.CHAT, model: 'gpt-4' }; - - const scope = InferenceScope.start(inferenceDetails, testAgentDetails, testTenantDetails); + + const scope = InferenceScope.start(testRequest, inferenceDetails, testAgentDetails); expect(() => scope?.recordInputMessages(['Input message'])).not.toThrow(); expect(() => scope?.recordOutputMessages(['Generated response'])).not.toThrow(); @@ -441,7 +565,10 @@ describe('Scopes', () => { model: 'gpt-4' }; - const scope = (InferenceScope as unknown as any).start(inferenceDetails, testAgentDetails, testTenantDetails, 'conv-inf-123', { name: 'ChannelInf', description: 'https://channel/inf' }); + const scope = InferenceScope.start( + { conversationId: 'conv-inf-123', channel: { name: 'ChannelInf', description: 'https://channel/inf' } }, + inferenceDetails, testAgentDetails + ); expect(scope).toBeInstanceOf(InferenceScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -458,8 +585,7 @@ describe('Scopes', () => { describe('Dispose pattern', () => { it('should support manual dispose', () => { - const invokeAgentDetails: InvokeAgentDetails = { agentId: 'test-agent' }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); scope?.recordResponse('Manual dispose test'); expect(() => scope?.dispose()).not.toThrow(); @@ -467,9 +593,9 @@ describe('Scopes', () => { it('should support automatic disposal pattern', () => { const toolDetails: ToolCallDetails = { toolName: 'test-tool' }; - + expect(() => { - const scope = ExecuteToolScope.start(toolDetails, testAgentDetails, testTenantDetails); + const scope = ExecuteToolScope.start(testRequest, toolDetails, testAgentDetails); try { scope?.recordResponse('Automatic disposal test'); } finally { @@ -525,9 +651,8 @@ describe('Scopes', () => { const customEnd = 1700000005000; // 5 seconds later const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, testTenantDetails, - undefined, undefined, undefined, - customStart, customEnd + testRequest, { toolName: 'my-tool' }, testAgentDetails, + undefined, { startTime: customStart, endTime: customEnd } ); scope.dispose(); @@ -541,9 +666,8 @@ describe('Scopes', () => { const laterEnd = 1700000048000; // 8 seconds later const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, testTenantDetails, - undefined, undefined, undefined, - customStart + testRequest, { toolName: 'my-tool' }, testAgentDetails, + undefined, { startTime: customStart } ); scope.setEndTime(laterEnd); scope.dispose(); @@ -558,9 +682,8 @@ describe('Scopes', () => { const customEnd = new Date('2023-11-14T22:13:25.000Z'); // 5 seconds later const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, testTenantDetails, - undefined, undefined, undefined, - customStart, customEnd + testRequest, { toolName: 'my-tool' }, testAgentDetails, + undefined, { startTime: customStart, endTime: customEnd } ); scope.dispose(); @@ -575,9 +698,8 @@ describe('Scopes', () => { const customEnd: [number, number] = [1700000005, 500000000]; // 5.5 seconds later const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, testTenantDetails, - undefined, undefined, undefined, - customStart, customEnd + testRequest, { toolName: 'my-tool' }, testAgentDetails, + undefined, { startTime: customStart, endTime: customEnd } ); scope.dispose(); @@ -588,7 +710,7 @@ describe('Scopes', () => { it('should use wall-clock time when no custom times are provided', () => { const before = Date.now(); - const scope = ExecuteToolScope.start({ toolName: 'my-tool' }, testAgentDetails, testTenantDetails); + const scope = ExecuteToolScope.start(testRequest, { toolName: 'my-tool' }, testAgentDetails); scope.dispose(); const after = Date.now(); @@ -605,8 +727,11 @@ describe('Scopes', () => { ['SERVER', SpanKind.SERVER, SpanKind.SERVER], ])('InvokeAgentScope spanKind: %s', (_label, input, expected) => { const scope = InvokeAgentScope.start( - { agentId: 'test-agent' }, testTenantDetails, - undefined, undefined, undefined, undefined, undefined, input + testRequest, + {}, + { agentId: 'test-agent', tenantId: 'test-tenant-456' }, + undefined, + input !== undefined ? { spanKind: input } : undefined ); scope.dispose(); expect(getFinishedSpan().kind).toBe(expected); @@ -617,12 +742,38 @@ describe('Scopes', () => { ['CLIENT', SpanKind.CLIENT, SpanKind.CLIENT], ])('ExecuteToolScope spanKind: %s', (_label, input, expected) => { const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, testTenantDetails, - undefined, undefined, undefined, undefined, undefined, undefined, input + testRequest, { toolName: 'my-tool' }, testAgentDetails, + undefined, input !== undefined ? { spanKind: input } : undefined ); scope.dispose(); expect(getFinishedSpan().kind).toBe(expected); }); + + it('recordCancellation should set error status and error.type attribute with default reason', () => { + const scope = ExecuteToolScope.start( + testRequest, { toolName: 'my-tool' }, testAgentDetails + ); + scope.recordCancellation(); + scope.dispose(); + + const span = getFinishedSpan(); + expect(span.status.code).toBe(2); // SpanStatusCode.ERROR + expect(span.status.message).toBe('Task was cancelled'); + expect(span.attributes[OpenTelemetryConstants.ERROR_TYPE_KEY]).toBe('TaskCanceledException'); + }); + + it('recordCancellation should use custom reason', () => { + const scope = ExecuteToolScope.start( + testRequest, { toolName: 'my-tool' }, testAgentDetails + ); + scope.recordCancellation('User aborted'); + scope.dispose(); + + const span = getFinishedSpan(); + expect(span.status.code).toBe(2); // SpanStatusCode.ERROR + expect(span.status.message).toBe('User aborted'); + expect(span.attributes[OpenTelemetryConstants.ERROR_TYPE_KEY]).toBe('TaskCanceledException'); + }); }); }); diff --git a/tests/observability/core/trace-context-propagation.test.ts b/tests/observability/core/trace-context-propagation.test.ts index 8c8d2779..beb6c1b1 100644 --- a/tests/observability/core/trace-context-propagation.test.ts +++ b/tests/observability/core/trace-context-propagation.test.ts @@ -8,11 +8,10 @@ import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-ho import { W3CTraceContextPropagator } from '@opentelemetry/core'; import { ParentSpanRef, - injectTraceContext, - extractTraceContext, + injectContextToHeaders, + extractContextFromHeaders, runWithExtractedTraceContext, InvokeAgentScope, - TenantDetails, } from '@microsoft/agents-a365-observability'; describe('Trace Context Propagation', () => { @@ -22,8 +21,6 @@ describe('Trace Context Propagation', () => { let exporter: InMemorySpanExporter; let contextManager: AsyncLocalStorageContextManager; - const testTenantDetails: TenantDetails = { tenantId: 'test-tenant' }; - beforeAll(() => { contextManager = new AsyncLocalStorageContextManager(); contextManager.enable(); @@ -54,7 +51,7 @@ describe('Trace Context Propagation', () => { otelContext.disable(); }); - describe('injectTraceContext', () => { + describe('injectContextToHeaders', () => { it('should inject traceparent header from active span', () => { const tracer = trace.getTracer('test'); const span = tracer.startSpan('sender'); @@ -63,7 +60,7 @@ describe('Trace Context Propagation', () => { otelContext.with(ctx, () => { const headers: Record = {}; - const result = injectTraceContext(headers); + const result = injectContextToHeaders(headers); expect(result).toBe(headers); const traceparent = headers['traceparent']; @@ -83,17 +80,17 @@ describe('Trace Context Propagation', () => { it('should be a no-op when no active span exists', () => { const headers: Record = {}; - injectTraceContext(headers); + injectContextToHeaders(headers); expect(headers['traceparent']).toBeUndefined(); }); }); - describe('extractTraceContext', () => { + describe('extractContextFromHeaders', () => { it('should extract valid traceparent into Context with correct traceId/spanId', () => { const traceId = '0af7651916cd43dd8448eb211c80319c'; const spanId = 'b7ad6b7169203331'; - const extracted = extractTraceContext({ traceparent: `00-${traceId}-${spanId}-01` }); + const extracted = extractContextFromHeaders({ traceparent: `00-${traceId}-${spanId}-01` }); const span = trace.getSpan(extracted); expect(span).toBeDefined(); @@ -103,8 +100,8 @@ describe('Trace Context Propagation', () => { }); it('should return base context for missing or malformed headers', () => { - expect(trace.getSpan(extractTraceContext({}))).toBeUndefined(); - expect(trace.getSpan(extractTraceContext({ traceparent: 'invalid' }))).toBeUndefined(); + expect(trace.getSpan(extractContextFromHeaders({}))).toBeUndefined(); + expect(trace.getSpan(extractContextFromHeaders({ traceparent: 'invalid' }))).toBeUndefined(); }); }); @@ -115,7 +112,7 @@ describe('Trace Context Propagation', () => { const senderCtx = trace.setSpan(otelContext.active(), senderSpan); const headers: Record = {}; - otelContext.with(senderCtx, () => injectTraceContext(headers)); + otelContext.with(senderCtx, () => injectContextToHeaders(headers)); const result = runWithExtractedTraceContext(headers, () => { const child = tracer.startSpan('receiver'); @@ -139,9 +136,15 @@ describe('Trace Context Propagation', () => { it('should create scope as child of extracted trace context', async () => { const traceId = '0af7651916cd43dd8448eb211c80319c'; const spanId = 'b7ad6b7169203331'; - const extractedCtx = extractTraceContext({ traceparent: `00-${traceId}-${spanId}-01` }); - - const scope = InvokeAgentScope.start({ agentId: 'ctx-agent' }, testTenantDetails, undefined, undefined, extractedCtx); + const extractedCtx = extractContextFromHeaders({ traceparent: `00-${traceId}-${spanId}-01` }); + + const scope = InvokeAgentScope.start( + {}, + {}, + { agentId: 'ctx-agent', tenantId: 'test-tenant' }, + undefined, + { parentContext: extractedCtx } + ); expect(scope.getSpanContext().traceId).toBe(traceId); scope.dispose(); @@ -161,7 +164,13 @@ describe('Trace Context Propagation', () => { isRemote: true, }; - const scope = InvokeAgentScope.start({ agentId: 'remote-agent' }, testTenantDetails, undefined, undefined, parentRef); + const scope = InvokeAgentScope.start( + {}, + {}, + { agentId: 'remote-agent', tenantId: 'test-tenant' }, + undefined, + { parentContext: parentRef } + ); scope.dispose(); await flushProvider.forceFlush(); diff --git a/tests/observability/extension/hosting/scope-utils.test.ts b/tests/observability/extension/hosting/scope-utils.test.ts index f5590ad7..d9e62d09 100644 --- a/tests/observability/extension/hosting/scope-utils.test.ts +++ b/tests/observability/extension/hosting/scope-utils.test.ts @@ -16,7 +16,7 @@ jest.mock('@microsoft/agents-a365-runtime', () => { }); import { ScopeUtils } from '../../../../packages/agents-a365-observability-hosting/src/utils/ScopeUtils'; -import { InferenceScope, InvokeAgentScope, ExecuteToolScope, OpenTelemetryConstants, ExecutionType, OpenTelemetryScope, InvokeAgentDetails } from '@microsoft/agents-a365-observability'; +import { InferenceScope, InvokeAgentScope, ExecuteToolScope, OpenTelemetryConstants, OpenTelemetryScope } from '@microsoft/agents-a365-observability'; import { SpanKind } from '@opentelemetry/api'; import { RoleTypes } from '@microsoft/agents-activity'; import type { TurnContext } from '@microsoft/agents-hosting'; @@ -110,7 +110,7 @@ describe('ScopeUtils.populateFromTurnContext', () => { const details: any = { operationName: 'inference', model: 'm', providerName: 'prov' }; const ctx = makeCtx({ activity: { recipient: { agenticAppId: 'aid' }, isAgenticRequest: () => false, getAgenticInstanceId: () => 'aid', getAgenticUser: () => undefined, getAgenticTenantId: () => undefined } as any }); // agent ok, no tenantId expect(() => ScopeUtils.populateInferenceScopeFromTurnContext(details, ctx, testAuthToken)) - .toThrow('populateInferenceScopeFromTurnContext: Missing tenant details on TurnContext (recipient)'); + .toThrow('InferenceScope: tenantId is required on agentDetails'); }); test('populateExecuteToolScopeFromTurnContext throws when agent details are missing', () => { @@ -124,22 +124,20 @@ describe('ScopeUtils.populateFromTurnContext', () => { const details: any = { toolName: 'tool' }; const ctx = makeCtx({ activity: { recipient: { agenticAppId: 'aid' }, isAgenticRequest: () => false, getAgenticInstanceId: () => 'aid', getAgenticUser: () => undefined, getAgenticTenantId: () => undefined } as any }); // agent ok, no tenantId expect(() => ScopeUtils.populateExecuteToolScopeFromTurnContext(details, ctx, testAuthToken)) - .toThrow('populateExecuteToolScopeFromTurnContext: Missing tenant details on TurnContext (recipient)'); + .toThrow('ExecuteToolScope: tenantId is required on agentDetails'); }); test('populateInvokeAgentScopeFromTurnContext throws when tenant details are missing', () => { - const details: InvokeAgentDetails = { agentId: 'aid' } as any; const ctx = makeCtx({ activity: { recipient: { agenticAppId: 'aid' }, isAgenticRequest: () => false, getAgenticInstanceId: () => 'aid', getAgenticUser: () => undefined, getAgenticTenantId: () => undefined } as any }); // no tenantId - expect(() => ScopeUtils.populateInvokeAgentScopeFromTurnContext(details, ctx, testAuthToken)) - .toThrow('populateInvokeAgentScopeFromTurnContext: Missing tenant details on TurnContext (recipient)'); + expect(() => ScopeUtils.populateInvokeAgentScopeFromTurnContext({ agentId: 'aid' } as any, {}, ctx, testAuthToken)) + .toThrow('InvokeAgentScope: tenantId is required on agentDetails'); }); }); test('build InvokeAgentScope based on turn context', () => { - const details = { operationName: 'invoke', model: 'n/a', providerName: 'internal' } as any; const ctx = makeTurnContext('invoke message', 'teams', 'https://teams', 'conv-B'); ctx.activity.from!.role = RoleTypes.AgenticUser; - const scope = ScopeUtils.populateInvokeAgentScopeFromTurnContext(details, ctx, testAuthToken) as InvokeAgentScope; + const scope = ScopeUtils.populateInvokeAgentScopeFromTurnContext({ agentId: 'invoke-agent', providerName: 'internal' } as any, {}, ctx, testAuthToken) as InvokeAgentScope; expect(scope).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => [args[0], args[1]]); const expected = [ @@ -237,7 +235,7 @@ test('deriveCallerAgent returns undefined without from', () => { expect(ScopeUtils.deriveCallerAgent(ctx)).toBeUndefined(); }); -test('deriveCallerDetails maps from to CallerDetails', () => { +test('deriveCallerDetails maps from to UserDetails', () => { const ctx = makeCtx({ activity: { from: { aadObjectId: 'uid', agenticUserId: 'upn', name: 'User', tenantId: 't3' } } as any }); expect(ScopeUtils.deriveCallerDetails(ctx)).toEqual({ userId: 'uid', @@ -272,11 +270,7 @@ test('deriveChannelObject returns undefined fields when none', () => { expect(ScopeUtils.deriveChannelObject(ctx)).toEqual({ name: undefined, description: undefined }); }); -test('buildInvokeAgentDetails merges agent (recipient), conversationId, channel', () => { - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'provided', - request: { content: 'hi', executionType: ExecutionType.HumanToAgent, channel: { id: 'orig-id' } }, - }; +test('buildInvokeAgentDetails merges agent (recipient) into details', () => { const ctx = makeCtx({ activity: { recipient: { name: 'Rec', role: 'bot' }, @@ -290,22 +284,15 @@ test('buildInvokeAgentDetails merges agent (recipient), conversationId, channel' } as any }); - const result = ScopeUtils.buildInvokeAgentDetails(invokeAgentDetails, ctx, testAuthToken); - expect(result.agentId).toBeUndefined(); - expect(result.conversationId).toBe('c-2'); - expect(result.request?.channel).toEqual({ id: 'orig-id', name: 'web', description: 'inbox' }); + const result = ScopeUtils.buildInvokeAgentDetails({ agentId: 'provided' } as any, ctx, testAuthToken); + // Agent identity is merged into result + expect(result.agentName).toBe('Rec'); }); -test('buildInvokeAgentDetails keeps base request when TurnContext has no overrides', () => { - const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'base-agent', - request: { content: 'hi', executionType: ExecutionType.HumanToAgent, channel: { description: 'keep', name: 'keep-name' }}, - }; +test('buildInvokeAgentDetails keeps base details when TurnContext has no overrides', () => { const ctx = makeCtx({ activity: {} as any }); - const result = ScopeUtils.buildInvokeAgentDetails(invokeAgentDetails, ctx, testAuthToken); + const result = ScopeUtils.buildInvokeAgentDetails({ agentId: 'base-agent' } as any, ctx, testAuthToken); expect(result.agentId).toBe('base-agent'); - expect(result.conversationId).toBeUndefined(); - expect(result.request?.channel).toEqual({ description: 'keep', name: 'keep-name' }); }); describe('ScopeUtils spanKind forwarding', () => { @@ -313,12 +300,13 @@ describe('ScopeUtils spanKind forwarding', () => { const spy = jest.spyOn(InvokeAgentScope, 'start'); const ctx = makeTurnContext('hello', 'web', 'https://web', 'conv-span'); const scope = ScopeUtils.populateInvokeAgentScopeFromTurnContext( - { agentId: 'test-agent' }, ctx, testAuthToken, + { agentId: 'test-agent' } as any, {}, ctx, testAuthToken, undefined, undefined, SpanKind.SERVER ); expect(spy).toHaveBeenCalledWith( - expect.anything(), expect.anything(), expect.anything(), expect.anything(), - undefined, undefined, undefined, SpanKind.SERVER + expect.anything(), expect.anything(), expect.anything(), + expect.anything(), + expect.objectContaining({ spanKind: SpanKind.SERVER }) ); scope?.dispose(); spy.mockRestore(); @@ -333,7 +321,7 @@ describe('ScopeUtils spanKind forwarding', () => { ); expect(spy).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.anything(), expect.anything(), - expect.anything(), undefined, undefined, undefined, undefined, SpanKind.CLIENT + expect.objectContaining({ spanKind: SpanKind.CLIENT }) ); scope?.dispose(); spy.mockRestore();