From ff636469d13f0bf6dcca7c1303e00d6763a0f2c7 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Wed, 18 Mar 2026 18:25:36 -0700 Subject: [PATCH 01/16] feat: align InvokeAgentDetails with .NET/Python SDKs BREAKING CHANGE: InvokeAgentDetails uses composition (details: AgentDetails) instead of inheritance (extends AgentDetails). - InvokeAgentScope.start() adds request and conversationId as separate params - Removed providerName and agentBlueprintId from InvokeAgentDetails - Added OpenTelemetryScope.setStartTime() and recordCancellation() - Added OpenTelemetryConstants.ERROR_TYPE_CANCELLED constant --- CHANGELOG.md | 9 + .../src/utils/ScopeUtils.ts | 41 +++-- .../src/tracing/constants.ts | 1 + .../context/trace-context-propagation.ts | 2 +- .../src/tracing/contracts.ts | 13 +- .../src/tracing/scopes/InvokeAgentScope.ts | 45 +++-- .../src/tracing/scopes/OpenTelemetryScope.ts | 38 +++- .../core/parent-span-ref.test.ts | 20 +- tests/observability/core/scopes.test.ts | 172 ++++++++++++++---- .../core/trace-context-propagation.test.ts | 4 +- .../extension/hosting/scope-utils.test.ts | 29 ++- 11 files changed, 264 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ef94f7..9af473fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes (`@microsoft/agents-a365-observability`) +- **`InvokeAgentDetails` — inheritance → composition.** `InvokeAgentDetails` no longer extends `AgentDetails`. Agent identity is now accessed via `details.details` (e.g., `invokeAgentDetails.details.agentId` instead of `invokeAgentDetails.agentId`). This aligns with the .NET (`Details`) and Python (`details`) SDKs. +- **`InvokeAgentDetails` — removed fields.** `request`, `providerName`, and `agentBlueprintId` have been removed from the interface. Use `details.providerName` and `details.agentBlueprintId` from the nested `AgentDetails` instead. Pass `request` as a separate parameter to `InvokeAgentScope.start()`. +- **`InvokeAgentScope.start()` — new signature.** Parameters `request` (position 3) and `conversationId` (position 6) are now explicit parameters, matching the .NET SDK. The `channel` parameter has been removed; channel data is derived from `request.channel`. - **`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`). - **`BaggageBuilder.serviceName()` renamed to `BaggageBuilder.operationSource()`** — Fluent setter for the service name baggage value. @@ -18,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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`) + +- **`OpenTelemetryScope.setStartTime()`** — Adjusts the internal duration baseline after construction. +- **`OpenTelemetryScope.recordCancellation()`** — Records a cancellation event on the span with `error.type = 'TaskCanceledException'` (aligned with Python SDK). +- **`OpenTelemetryConstants.ERROR_TYPE_CANCELLED`** — Constant for the cancellation error type value. + ### Breaking Changes (`@microsoft/agents-a365-observability-hosting`) - **`ScopeUtils.deriveSourceMetadataObject()` renamed to `ScopeUtils.deriveChannelObject()`**. diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index 7d05868a..504c33e4 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -15,6 +15,7 @@ import { InferenceDetails, InvokeAgentDetails, ToolCallDetails, + AgentRequest, } from '@microsoft/agents-a365-observability'; import { resolveEmbodiedAgentIds } from './TurnContextUtils'; @@ -188,23 +189,35 @@ export class ScopeUtils { const tenant = ScopeUtils.deriveTenantDetails(turnContext); const callerAgent = ScopeUtils.deriveCallerAgent(turnContext); const caller = ScopeUtils.deriveCallerDetails(turnContext); + const conversationId = ScopeUtils.deriveConversationId(turnContext); + const channel = ScopeUtils.deriveChannelObject(turnContext); + + // Merge agent identity from TurnContext into details.details const invokeAgentDetails = ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); + // Build the request with channel info from context + const request: AgentRequest = { + channel: { + ...(channel.name !== undefined ? { name: channel.name } : {}), + ...(channel.description !== undefined ? { description: channel.description } : {}), + }, + }; + if (!tenant) { throw new Error('populateInvokeAgentScopeFromTurnContext: Missing tenant details on TurnContext (recipient)'); } - const scope = InvokeAgentScope.start(invokeAgentDetails, tenant, callerAgent, caller, undefined, startTime, endTime, spanKind); + const scope = InvokeAgentScope.start(invokeAgentDetails, tenant, request, callerAgent, caller, conversationId, undefined, startTime, endTime, spanKind); this.setInputMessageTags(scope, turnContext); return scope; } /** - * Build InvokeAgentDetails by merging provided details with agent info, conversation id and channel from the TurnContext. + * Build InvokeAgentDetails by merging provided details with agent info from the TurnContext. * @param details Base invoke-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 New InvokeAgentDetails with merged `details.details` (agent identity). */ public static buildInvokeAgentDetails(details: InvokeAgentDetails, turnContext: TurnContext, authToken: string): InvokeAgentDetails { return ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); @@ -212,22 +225,18 @@ export class ScopeUtils { 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 } : {}), + const conversationId = ScopeUtils.deriveConversationId(turnContext); + + // Merge derived agent identity into details.details + const mergedAgent: AgentDetails = { + ...details.details, + ...(agent ?? {}), + conversationId: conversationId ?? details.details?.conversationId, }; + return { ...details, - ...agent, - conversationId: ScopeUtils.deriveConversationId(turnContext), - request: { - ...baseRequest, - channel: mergedChannel - } + details: mergedAgent, }; } diff --git a/packages/agents-a365-observability/src/tracing/constants.ts b/packages/agents-a365-observability/src/tracing/constants.ts index d6fd4168..93daf62e 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..8e025ef0 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 @@ -76,7 +76,7 @@ export function injectTraceContext( * @example * ```typescript * const parentCtx = extractTraceContext(req.headers); - * const scope = InvokeAgentScope.start(details, tenantDetails, undefined, undefined, parentCtx); + * const scope = InvokeAgentScope.start(details, tenantDetails, undefined, undefined, undefined, undefined, parentCtx); * ``` */ export function extractTraceContext( diff --git a/packages/agents-a365-observability/src/tracing/contracts.ts b/packages/agents-a365-observability/src/tracing/contracts.ts index 5cab41b4..1e31b050 100644 --- a/packages/agents-a365-observability/src/tracing/contracts.ts +++ b/packages/agents-a365-observability/src/tracing/contracts.ts @@ -1,6 +1,5 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. /** * Represents different types of agent invocations @@ -197,11 +196,11 @@ export interface ServiceEndpoint { } /** - * Details for invoking another agent + * Details for invoking another agent. */ -export interface InvokeAgentDetails extends AgentDetails { - /** The request payload for the agent invocation */ - request?: AgentRequest; +export interface InvokeAgentDetails { + /** The agent identity details (composition – matches .NET `Details` / Python `details`). */ + details: AgentDetails; /** The endpoint for the agent invocation */ endpoint?: ServiceEndpoint; diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts index c5537e19..3b8de25f 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -7,7 +7,8 @@ import { InvokeAgentDetails, TenantDetails, CallerDetails, - AgentDetails + AgentDetails, + AgentRequest } from '../contracts'; import { ParentContext } from '../context/trace-context-propagation'; import { OpenTelemetryConstants } from '../constants'; @@ -18,10 +19,12 @@ 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 invokeAgentDetails The details of the agent invocation (agent identity via `.details`, endpoint, sessionId). * @param tenantDetails The tenant details. + * @param request Optional request payload (content, executionType, channel). * @param callerAgentDetails The details of the caller agent. * @param callerDetails The details of the non-agentic caller. + * @param conversationId Optional conversation id to tag on the span (`gen_ai.conversation.id`). * @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). @@ -33,33 +36,39 @@ export class InvokeAgentScope extends OpenTelemetryScope { public static start( invokeAgentDetails: InvokeAgentDetails, tenantDetails: TenantDetails, + request?: AgentRequest, callerAgentDetails?: AgentDetails, callerDetails?: CallerDetails, + conversationId?: string, parentContext?: ParentContext, startTime?: TimeInput, endTime?: TimeInput, spanKind?: SpanKind ): InvokeAgentScope { - return new InvokeAgentScope(invokeAgentDetails, tenantDetails, callerAgentDetails, callerDetails, parentContext, startTime, endTime, spanKind); + return new InvokeAgentScope(invokeAgentDetails, tenantDetails, request, callerAgentDetails, callerDetails, conversationId, parentContext, startTime, endTime, spanKind); } private constructor( invokeAgentDetails: InvokeAgentDetails, tenantDetails: TenantDetails, + request?: AgentRequest, callerAgentDetails?: AgentDetails, callerDetails?: CallerDetails, + conversationId?: string, parentContext?: ParentContext, startTime?: TimeInput, endTime?: TimeInput, spanKind?: SpanKind ) { + const agent = invokeAgentDetails.details; + super( spanKind ?? SpanKind.CLIENT, OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, - invokeAgentDetails.agentName - ? `${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${invokeAgentDetails.agentName}` + agent.agentName + ? `${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${agent.agentName}` : OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, - invokeAgentDetails, + agent, tenantDetails, parentContext, startTime, @@ -67,13 +76,13 @@ export class InvokeAgentScope extends OpenTelemetryScope { callerDetails ); - // 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, agent.providerName); // Set session ID and endpoint information this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, invokeAgentDetails.sessionId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, invokeAgentDetails.agentBlueprintId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, agent.agentBlueprintId); if (invokeAgentDetails.endpoint) { this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, invokeAgentDetails.endpoint.host); @@ -84,16 +93,14 @@ export class InvokeAgentScope extends OpenTelemetryScope { } } - // 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 (aligned with .NET/Python: channel lives inside 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); + // Use explicit conversationId param, falling back to agent.conversationId + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, conversationId ?? agent.conversationId); // Set caller agent details tags if (callerAgentDetails) { @@ -119,7 +126,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 +134,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 6e514cd0..dcb15104 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts @@ -1,7 +1,7 @@ // 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 { createContextWithParentSpanRef } from '../context/parent-span-context'; @@ -180,11 +180,12 @@ 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. + * @internal Intended for use by scope subclasses and SDK internals only. * @param name The tag name * @param value The tag value */ - protected setTagMaybe(name: string, value: T | null | undefined): void { + public setTagMaybe(name: string, value: T | null | undefined): void { if (value != null) { this.span.setAttributes({ [name]: value as string | number | boolean | string[] | number[] }); } @@ -213,6 +214,17 @@ export abstract class OpenTelemetryScope implements Disposable { return Date.now(); } + /** + * Sets a custom start time for the scope. + * Note: this does not change the span's recorded start time (which is fixed at creation), + * but it adjusts the start time used for internal duration calculation at end method to allow + * for more accurate duration reporting when the actual operation start time is known. + * @param startTime The start time as milliseconds since epoch, a Date, or an HrTime tuple. + */ + public setStartTime(startTime: TimeInput): void { + this.customStartTime = startTime; + } + /** * Sets a custom end time for the scope. * When set, {@link dispose} will pass this value to `span.end()` instead of using the current wall-clock time. @@ -223,6 +235,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.span.setAttributes({ [OpenTelemetryConstants.ERROR_TYPE_KEY]: OpenTelemetryConstants.ERROR_TYPE_CANCELLED }); + } + /** * Finalizes the scope and records metrics */ @@ -240,16 +267,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/tests/observability/core/parent-span-ref.test.ts b/tests/observability/core/parent-span-ref.test.ts index 95b33090..484850dd 100644 --- a/tests/observability/core/parent-span-ref.test.ts +++ b/tests/observability/core/parent-span-ref.test.ts @@ -99,10 +99,12 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { 'InvokeAgentScope', (parentRef: ParentSpanRef) => { const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent', + details: { + agentId: 'test-agent', + agentName: 'Test Agent', + } }; - return InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, undefined, parentRef); + return InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, undefined, undefined, undefined, parentRef); }, (name: string) => name.toLowerCase().includes('invokeagent') || name.toLowerCase().includes('invoke_agent'), ], @@ -175,7 +177,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { runWithParentSpanRef(parentRef, () => { // Create a scope inside - it should automatically inherit the parent const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'nested-agent', + details: { agentId: 'nested-agent' }, }; const nestedScope = InvokeAgentScope.start( @@ -205,7 +207,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', + details: { agentId: 'test-agent' }, }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); @@ -256,7 +258,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { runWithParentSpanRef(parentRef, () => { const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'sampled-agent', + details: { agentId: 'sampled-agent' }, }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); @@ -285,7 +287,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { runWithParentSpanRef(parentRef, () => { const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'unsampled-agent', + details: { agentId: 'unsampled-agent' }, }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); @@ -314,7 +316,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { runWithParentSpanRef(parentRef, () => { const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'default-sampled-agent', + details: { agentId: 'default-sampled-agent' }, }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); @@ -349,7 +351,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { await otelContext.with(baseCtx, async () => { runWithParentSpanRef(parentRef, () => { const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'inherited-flags-agent', + details: { agentId: 'inherited-flags-agent' }, }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); diff --git a/tests/observability/core/scopes.test.ts b/tests/observability/core/scopes.test.ts index 8de343bd..3dd7cd18 100644 --- a/tests/observability/core/scopes.test.ts +++ b/tests/observability/core/scopes.test.ts @@ -45,9 +45,11 @@ describe('Scopes', () => { describe('InvokeAgentScope', () => { it('should create scope with agent details', () => { const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent', - agentDescription: 'A test agent' + details: { + agentId: 'test-agent', + agentName: 'Test Agent', + agentDescription: 'A test agent' + } }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); @@ -58,7 +60,7 @@ describe('Scopes', () => { it('should create scope with agent ID only', () => { const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'simple-agent' + details: { agentId: 'simple-agent' } }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); @@ -69,11 +71,13 @@ describe('Scopes', () => { it('should create scope with additional details', () => { const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent', - agentDescription: 'A test agent', - conversationId: 'conv-123', - iconUri: 'https://example.com/icon.png' + details: { + 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); @@ -84,9 +88,11 @@ describe('Scopes', () => { it('should create scope with platformId', () => { const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent', - platformId: 'platform-xyz-123' + details: { + agentId: 'test-agent', + agentName: 'Test Agent', + platformId: 'platform-xyz-123' + } }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); @@ -97,8 +103,10 @@ describe('Scopes', () => { it('should create scope with caller details', () => { const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent' + details: { + agentId: 'test-agent', + agentName: 'Test Agent' + } }; const callerDetails: CallerDetails = { @@ -108,14 +116,14 @@ describe('Scopes', () => { tenantId: 'test-tenant' }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, callerDetails); + const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, undefined, callerDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); }); it('should record response', () => { - const invokeAgentDetails: InvokeAgentDetails = { agentId: 'test-agent' }; + const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent' } }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); expect(() => scope?.recordResponse('Test response')).not.toThrow(); @@ -123,7 +131,7 @@ describe('Scopes', () => { }); it('should record input and output messages', () => { - const invokeAgentDetails: InvokeAgentDetails = { agentId: 'test-agent' }; + const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent' } }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); expect(() => scope?.recordInputMessages(['Input message 1', 'Input message 2'])).not.toThrow(); @@ -132,7 +140,7 @@ describe('Scopes', () => { }); it('should record error', () => { - const invokeAgentDetails: InvokeAgentDetails = { agentId: 'test-agent' }; + const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent' } }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); const error = new Error('Test error'); @@ -140,12 +148,60 @@ describe('Scopes', () => { scope?.dispose(); }); + it('should set conversationId from explicit param', () => { + const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); + const scope = InvokeAgentScope.start( + { details: { agentId: 'test-agent', conversationId: 'from-details' } }, + testTenantDetails, + undefined, undefined, undefined, + 'explicit-conv-id' + ); + 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 fall back to agent.conversationId when conversationId param is omitted', () => { + const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); + const scope = InvokeAgentScope.start( + { details: { agentId: 'test-agent', conversationId: 'from-details' } }, + testTenantDetails + ); + 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: 'from-details' }) + ])); + 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( + { details: { agentId: 'test-agent' } }, + testTenantDetails, + { channel: { name: 'Teams', description: 'https://teams.link' } } + ); + 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 = { - agentId: 'test-agent', - agentName: 'Test Agent', - platformId: 'test-platform-123' + details: { + agentId: 'test-agent', + agentName: 'Test Agent', + platformId: 'test-platform-123' + } }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); @@ -163,8 +219,10 @@ 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' + details: { + agentId: 'test-agent', + agentName: 'Test Agent' + } }; const callerAgentDetails: AgentDetails = { agentId: 'caller-agent', @@ -174,7 +232,7 @@ describe('Scopes', () => { platformId: 'caller-platform-xyz' } as any; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, callerAgentDetails, undefined); + const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, callerAgentDetails, undefined); expect(scope).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -189,8 +247,10 @@ describe('Scopes', () => { it('should set caller and caller-agent IP tags', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent' + details: { + agentId: 'test-agent', + agentName: 'Test Agent' + } }; const callerDetails: CallerDetails = { callerId: 'user-123', @@ -205,9 +265,9 @@ describe('Scopes', () => { agentClientIP: '192.168.1.100' } as any; - const scope1 = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, callerDetails); + const scope1 = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, undefined, callerDetails); expect(scope1).toBeInstanceOf(InvokeAgentScope); - const scope2 = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, callerAgentDetails, undefined); + const scope2 = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, callerAgentDetails, undefined); expect(scope2).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -339,7 +399,7 @@ 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 } }, + { details: { agentId: 'test-agent' }, endpoint: { host: 'agent.example.com', port: 9090 } }, testTenantDetails ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -353,7 +413,7 @@ 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 } }, + { details: { agentId: 'test-agent' }, endpoint: { host: 'agent.example.com', port: 443 } }, testTenantDetails ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -454,7 +514,7 @@ describe('Scopes', () => { describe('Dispose pattern', () => { it('should support manual dispose', () => { - const invokeAgentDetails: InvokeAgentDetails = { agentId: 'test-agent' }; + const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent' } }; const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); scope?.recordResponse('Manual dispose test'); @@ -601,8 +661,8 @@ 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 + { details: { agentId: 'test-agent' } }, testTenantDetails, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, input ); scope.dispose(); expect(getFinishedSpan().kind).toBe(expected); @@ -619,6 +679,52 @@ describe('Scopes', () => { scope.dispose(); expect(getFinishedSpan().kind).toBe(expected); }); + + it('setStartTime should adjust duration baseline when called after construction', () => { + const laterStart = 1700000002000; // 2 seconds after original start + const customEnd = 1700000010000; // 10 seconds epoch + + const scope = ExecuteToolScope.start( + { toolName: 'my-tool' }, testAgentDetails, testTenantDetails, + undefined, undefined, undefined, + 1700000000000, customEnd + ); + scope.setStartTime(laterStart); + // setStartTime adjusts the internal baseline, not the OTel span startTime + scope.dispose(); + + const span = getFinishedSpan(); + // The OTel span startTime remains the constructor-provided value + expect(hrtimeToMs(span.startTime as [number, number])).toBeCloseTo(1700000000000, -1); + // The span endTime should reflect the custom end + expect(hrtimeToMs(span.endTime as [number, number])).toBeCloseTo(customEnd, -1); + }); + + it('recordCancellation should set error status and error.type attribute with default reason', () => { + const scope = ExecuteToolScope.start( + { toolName: 'my-tool' }, testAgentDetails, testTenantDetails + ); + 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( + { toolName: 'my-tool' }, testAgentDetails, testTenantDetails + ); + 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..aee6bcaa 100644 --- a/tests/observability/core/trace-context-propagation.test.ts +++ b/tests/observability/core/trace-context-propagation.test.ts @@ -141,7 +141,7 @@ describe('Trace Context Propagation', () => { const spanId = 'b7ad6b7169203331'; const extractedCtx = extractTraceContext({ traceparent: `00-${traceId}-${spanId}-01` }); - const scope = InvokeAgentScope.start({ agentId: 'ctx-agent' }, testTenantDetails, undefined, undefined, extractedCtx); + const scope = InvokeAgentScope.start({ details: { agentId: 'ctx-agent' } }, testTenantDetails, undefined, undefined, undefined, undefined, extractedCtx); expect(scope.getSpanContext().traceId).toBe(traceId); scope.dispose(); @@ -161,7 +161,7 @@ describe('Trace Context Propagation', () => { isRemote: true, }; - const scope = InvokeAgentScope.start({ agentId: 'remote-agent' }, testTenantDetails, undefined, undefined, parentRef); + const scope = InvokeAgentScope.start({ details: { agentId: 'remote-agent' } }, testTenantDetails, undefined, undefined, undefined, undefined, 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 96ccf6bb..97b1a2de 100644 --- a/tests/observability/extension/hosting/scope-utils.test.ts +++ b/tests/observability/extension/hosting/scope-utils.test.ts @@ -128,7 +128,7 @@ describe('ScopeUtils.populateFromTurnContext', () => { }); test('populateInvokeAgentScopeFromTurnContext throws when tenant details are missing', () => { - const details: InvokeAgentDetails = { agentId: 'aid' } as any; + const details: InvokeAgentDetails = { details: { 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)'); @@ -136,7 +136,7 @@ describe('ScopeUtils.populateFromTurnContext', () => { }); test('build InvokeAgentScope based on turn context', () => { - const details = { operationName: 'invoke', model: 'n/a', providerName: 'internal' } as any; + const details: InvokeAgentDetails = { details: { agentId: 'invoke-agent', providerName: 'internal' } }; 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; @@ -271,10 +271,9 @@ test('deriveChannelObject returns undefined fields when none', () => { expect(ScopeUtils.deriveChannelObject(ctx)).toEqual({ name: undefined, description: undefined }); }); -test('buildInvokeAgentDetails merges agent (recipient), conversationId, channel', () => { +test('buildInvokeAgentDetails merges agent (recipient) and conversationId into details', () => { const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'provided', - request: { content: 'hi', executionType: ExecutionType.HumanToAgent, channel: { id: 'orig-id' } }, + details: { agentId: 'provided' }, }; const ctx = makeCtx({ activity: { @@ -290,21 +289,19 @@ test('buildInvokeAgentDetails merges agent (recipient), conversationId, channel' }); 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' }); + // Agent identity is merged into result.details + expect(result.details.agentName).toBe('Rec'); + expect(result.details.conversationId).toBe('c-2'); }); -test('buildInvokeAgentDetails keeps base request when TurnContext has no overrides', () => { +test('buildInvokeAgentDetails keeps base details when TurnContext has no overrides', () => { const invokeAgentDetails: InvokeAgentDetails = { - agentId: 'base-agent', - request: { content: 'hi', executionType: ExecutionType.HumanToAgent, channel: { description: 'keep', name: 'keep-name' }}, + details: { agentId: 'base-agent' }, }; const ctx = makeCtx({ activity: {} as any }); const result = ScopeUtils.buildInvokeAgentDetails(invokeAgentDetails, ctx, testAuthToken); - expect(result.agentId).toBe('base-agent'); - expect(result.conversationId).toBeUndefined(); - expect(result.request?.channel).toEqual({ description: 'keep', name: 'keep-name' }); + expect(result.details.agentId).toBe('base-agent'); + expect(result.details.conversationId).toBeUndefined(); }); describe('ScopeUtils spanKind forwarding', () => { @@ -312,12 +309,12 @@ 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, + { details: { agentId: 'test-agent' } }, 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(), undefined, undefined, undefined, SpanKind.SERVER ); scope?.dispose(); spy.mockRestore(); From c3eab6b6d9eb4141ac3c2bc462e239a96199dede Mon Sep 17 00:00:00 2001 From: jsl517 Date: Thu, 19 Mar 2026 18:46:17 -0700 Subject: [PATCH 02/16] fix: address Copilot PR review comments and fix all test failures - Revert setTagMaybe from public back to protected (Copilot comment #1) - Only build request object when channel/conversationId data exists in ScopeUtils populate* methods, avoiding always-truthy empty channel objects (Copilot comment #2) - Update all 5 failing test suites to match new scope API signatures: scopes.test.ts, output-scope.test.ts, parent-span-ref.test.ts, trace-context-propagation.test.ts, scope-utils.test.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/utils/ScopeUtils.ts | 85 +++++--- .../src/tracing/scopes/OpenTelemetryScope.ts | 18 +- tests/observability/core/output-scope.test.ts | 13 +- .../core/parent-span-ref.test.ts | 59 +++--- tests/observability/core/scopes.test.ts | 198 ++++++++---------- .../core/trace-context-propagation.test.ts | 41 ++-- .../extension/hosting/scope-utils.test.ts | 12 +- 7 files changed, 215 insertions(+), 211 deletions(-) diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index 504c33e4..ba7344a7 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -10,12 +10,15 @@ import { InferenceScope, ExecuteToolScope, AgentDetails, - TenantDetails, CallerDetails, InferenceDetails, InvokeAgentDetails, + InvokeAgentCallerDetails, ToolCallDetails, AgentRequest, + InferenceRequest, + ToolRequest, + SpanDetails, } from '@microsoft/agents-a365-observability'; import { resolveEmbodiedAgentIds } from './TurnContextUtils'; @@ -35,7 +38,7 @@ export class ScopeUtils { } return scope; } - + // ---------------------- // Context-derived helpers // ---------------------- @@ -44,7 +47,7 @@ export class ScopeUtils { * @param turnContext Activity context * @returns Tenant details if a recipient tenant id is present; otherwise undefined. */ - public static deriveTenantDetails(turnContext: TurnContext): TenantDetails | undefined { + public static deriveTenantDetails(turnContext: TurnContext): { tenantId: string } | undefined { const tenantId = turnContext?.activity?.getAgenticTenantId?.(); return tenantId ? { tenantId } : undefined; } @@ -72,7 +75,7 @@ export class ScopeUtils { } as AgentDetails; } - + /** * Derive caller agent details from the activity from. * @param turnContext Activity context @@ -132,7 +135,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. @@ -149,18 +152,27 @@ 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: InferenceRequest | undefined = (conversationId || hasChannel) + ? { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + } + : undefined; + + const spanDetails: SpanDetails | undefined = (startTime || endTime) + ? { startTime, endTime } + : undefined; + + const scope = InferenceScope.start(request, details, agent, caller, spanDetails); this.setInputMessageTags(scope, turnContext); return scope; } @@ -168,7 +180,7 @@ export class ScopeUtils { /** * 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). + * Derives `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. * @param turnContext The current activity context to derive scope parameters from. @@ -186,7 +198,6 @@ export class ScopeUtils { endTime?: TimeInput, spanKind?: SpanKind ): InvokeAgentScope { - const tenant = ScopeUtils.deriveTenantDetails(turnContext); const callerAgent = ScopeUtils.deriveCallerAgent(turnContext); const caller = ScopeUtils.deriveCallerDetails(turnContext); const conversationId = ScopeUtils.deriveConversationId(turnContext); @@ -195,19 +206,26 @@ export class ScopeUtils { // Merge agent identity from TurnContext into details.details const invokeAgentDetails = ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); - // Build the request with channel info from context - const request: AgentRequest = { - channel: { - ...(channel.name !== undefined ? { name: channel.name } : {}), - ...(channel.description !== undefined ? { description: channel.description } : {}), - }, + // Build the request only when there is concrete channel or conversationId info + const hasChannel = channel.name !== undefined || channel.description !== undefined; + const request: AgentRequest | undefined = (conversationId || hasChannel) + ? { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + } + : undefined; + + // Build caller info with both human caller and caller agent details + const callerInfo: InvokeAgentCallerDetails = { + callerDetails: caller, + callerAgentDetails: callerAgent, }; - if (!tenant) { - throw new Error('populateInvokeAgentScopeFromTurnContext: Missing tenant details on TurnContext (recipient)'); - } + const spanDetailsObj: SpanDetails | undefined = (startTime || endTime || spanKind) + ? { startTime, endTime, spanKind } + : undefined; - const scope = InvokeAgentScope.start(invokeAgentDetails, tenant, request, callerAgent, caller, conversationId, undefined, startTime, endTime, spanKind); + const scope = InvokeAgentScope.start(request, invokeAgentDetails, callerInfo, spanDetailsObj); this.setInputMessageTags(scope, turnContext); return scope; } @@ -242,7 +260,7 @@ export class ScopeUtils { /** * 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. @@ -261,16 +279,27 @@ 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: ToolRequest | undefined = (conversationId || hasChannel) + ? { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + } + : undefined; + + 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/src/tracing/scopes/OpenTelemetryScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts index dcb15104..24017905 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts @@ -31,7 +31,7 @@ export abstract class OpenTelemetryScope implements Disposable { * @param tenantDetails Optional tenant details * @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). @@ -58,7 +58,7 @@ 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; } } @@ -181,11 +181,10 @@ export abstract class OpenTelemetryScope implements Disposable { /** * Sets a tag on the span if the value is not null or undefined. - * @internal Intended for use by scope subclasses and SDK internals only. * @param name The tag name * @param value The tag value */ - public setTagMaybe(name: string, value: T | null | undefined): void { + protected setTagMaybe(name: string, value: T | null | undefined): void { if (value != null) { this.span.setAttributes({ [name]: value as string | number | boolean | string[] | number[] }); } @@ -214,17 +213,6 @@ export abstract class OpenTelemetryScope implements Disposable { return Date.now(); } - /** - * Sets a custom start time for the scope. - * Note: this does not change the span's recorded start time (which is fixed at creation), - * but it adjusts the start time used for internal duration calculation at end method to allow - * for more accurate duration reporting when the actual operation start time is known. - * @param startTime The start time as milliseconds since epoch, a Date, or an HrTime tuple. - */ - public setStartTime(startTime: TimeInput): void { - this.customStartTime = startTime; - } - /** * Sets a custom end time for the scope. * When set, {@link dispose} will pass this value to `span.end()` instead of using the current wall-clock time. diff --git a/tests/observability/core/output-scope.test.ts b/tests/observability/core/output-scope.test.ts index 3dba8e0e..608b20e8 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,9 +19,6 @@ 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', }; @@ -75,7 +71,7 @@ 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(undefined, response, testAgentDetails); expect(scope).toBeInstanceOf(OutputScope); scope.dispose(); @@ -93,7 +89,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(undefined, response, testAgentDetails); scope.recordOutputMessages(['Appended 1']); scope.recordOutputMessages(['Appended 2', 'Appended 3']); scope.dispose(); @@ -110,9 +106,8 @@ describe('OutputScope', () => { const parentSpanId = 'abcdefabcdef1234'; const scope = OutputScope.start( - { messages: ['Test'] }, testAgentDetails, testTenantDetails, - undefined, undefined, undefined, - { traceId: parentTraceId, spanId: parentSpanId } as ParentSpanRef + undefined, { messages: ['Test'] }, testAgentDetails, + undefined, { parentContext: { traceId: parentTraceId, spanId: parentSpanId } as ParentSpanRef } ); scope.dispose(); diff --git a/tests/observability/core/parent-span-ref.test.ts b/tests/observability/core/parent-span-ref.test.ts index 484850dd..2ebd932e 100644 --- a/tests/observability/core/parent-span-ref.test.ts +++ b/tests/observability/core/parent-span-ref.test.ts @@ -16,7 +16,6 @@ import { InferenceOperationType, ToolCallDetails, AgentDetails, - TenantDetails, } from '@microsoft/agents-a365-observability'; describe('ParentSpanRef - Explicit Parent Span Support', () => { @@ -68,10 +67,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { agentId: 'test-agent', agentName: 'Test Agent', agentDescription: 'A test agent', - conversationId: 'test-conv-123' - }; - - const testTenantDetails: TenantDetails = { + conversationId: 'test-conv-123', tenantId: 'test-tenant-456' }; @@ -102,9 +98,10 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { details: { agentId: 'test-agent', agentName: 'Test Agent', + tenantId: 'test-tenant-456' } }; - return InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, undefined, undefined, undefined, parentRef); + return InvokeAgentScope.start(undefined, invokeAgentDetails, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('invokeagent') || name.toLowerCase().includes('invoke_agent'), ], @@ -116,7 +113,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { model: 'gpt-4', providerName: 'openai', }; - return InferenceScope.start(inferenceDetails, testAgentDetails, testTenantDetails, undefined, undefined, parentRef); + return InferenceScope.start(undefined, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('chat'), ], @@ -127,7 +124,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(undefined, toolDetails, testAgentDetails, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('execute_tool'), ], @@ -164,7 +161,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, @@ -177,12 +174,12 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { runWithParentSpanRef(parentRef, () => { // Create a scope inside - it should automatically inherit the parent const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'nested-agent' }, + details: { agentId: 'nested-agent', tenantId: 'test-tenant-456' }, }; const nestedScope = InvokeAgentScope.start( - invokeAgentDetails, - testTenantDetails + undefined, + invokeAgentDetails ); const nestedSpanContext = nestedScope.getSpanContext(); @@ -207,10 +204,10 @@ 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 = { - details: { agentId: 'test-agent' }, + details: { agentId: 'test-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); const spanContext = scope.getSpanContext(); expect(spanContext).toBeDefined(); @@ -228,7 +225,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(undefined, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }) ); expect(childScope.getSpanContext().traceId).toBe(spanContext.traceId); @@ -240,7 +237,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); @@ -258,10 +255,10 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { runWithParentSpanRef(parentRef, () => { const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'sampled-agent' }, + details: { agentId: 'sampled-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); scope.dispose(); }); @@ -271,7 +268,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); @@ -287,10 +284,10 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { runWithParentSpanRef(parentRef, () => { const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'unsampled-agent' }, + details: { agentId: 'unsampled-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); scope.dispose(); }); @@ -298,11 +295,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(); }); @@ -316,21 +313,21 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { runWithParentSpanRef(parentRef, () => { const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'default-sampled-agent' }, + details: { agentId: 'default-sampled-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); 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); @@ -351,10 +348,10 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { await otelContext.with(baseCtx, async () => { runWithParentSpanRef(parentRef, () => { const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'inherited-flags-agent' }, + details: { agentId: 'inherited-flags-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); scope.dispose(); }); }); @@ -364,11 +361,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 3dd7cd18..d5945b1f 100644 --- a/tests/observability/core/scopes.test.ts +++ b/tests/observability/core/scopes.test.ts @@ -6,7 +6,6 @@ import { InvokeAgentScope, InferenceScope, AgentDetails, - TenantDetails, InvokeAgentDetails, ToolCallDetails, InferenceDetails, @@ -35,10 +34,7 @@ describe('Scopes', () => { agentId: 'test-agent', agentName: 'Test Agent', agentDescription: 'A test agent', - conversationId: 'test-conv-123' - }; - - const testTenantDetails: TenantDetails = { + conversationId: 'test-conv-123', tenantId: 'test-tenant-456' }; @@ -48,11 +44,12 @@ describe('Scopes', () => { details: { agentId: 'test-agent', agentName: 'Test Agent', - agentDescription: 'A test agent' + agentDescription: 'A test agent', + tenantId: 'test-tenant-456' } }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -60,10 +57,10 @@ describe('Scopes', () => { it('should create scope with agent ID only', () => { const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'simple-agent' } + details: { agentId: 'simple-agent', tenantId: 'test-tenant-456' } }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -76,11 +73,12 @@ describe('Scopes', () => { agentName: 'Test Agent', agentDescription: 'A test agent', conversationId: 'conv-123', - iconUri: 'https://example.com/icon.png' + iconUri: 'https://example.com/icon.png', + tenantId: 'test-tenant-456' } }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -91,11 +89,12 @@ describe('Scopes', () => { details: { agentId: 'test-agent', agentName: 'Test Agent', - platformId: 'platform-xyz-123' + platformId: 'platform-xyz-123', + tenantId: 'test-tenant-456' } }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -105,34 +104,35 @@ describe('Scopes', () => { const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', - agentName: 'Test Agent' + agentName: 'Test Agent', + tenantId: 'test-tenant-456' } }; - + const callerDetails: CallerDetails = { callerId: 'user-123', callerName: 'Test User', callerUpn: 'test.user@contoso.com', tenantId: 'test-tenant' }; - - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, undefined, callerDetails); + + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails, { callerDetails }); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); }); it('should record response', () => { - const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent' } }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); expect(() => scope?.recordResponse('Test response')).not.toThrow(); scope?.dispose(); }); it('should record input and output messages', () => { - const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent' } }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); expect(() => scope?.recordInputMessages(['Input message 1', 'Input message 2'])).not.toThrow(); expect(() => scope?.recordOutputMessages(['Output message 1', 'Output message 2'])).not.toThrow(); @@ -140,8 +140,8 @@ describe('Scopes', () => { }); it('should record error', () => { - const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent' } }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); const error = new Error('Test error'); expect(() => scope?.recordError(error)).not.toThrow(); @@ -151,10 +151,8 @@ describe('Scopes', () => { it('should set conversationId from explicit param', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( - { details: { agentId: 'test-agent', conversationId: 'from-details' } }, - testTenantDetails, - undefined, undefined, undefined, - 'explicit-conv-id' + { conversationId: 'explicit-conv-id' }, + { details: { agentId: 'test-agent', conversationId: 'from-details', tenantId: 'test-tenant-456' } } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).toEqual(expect.arrayContaining([ @@ -167,8 +165,8 @@ describe('Scopes', () => { it('should fall back to agent.conversationId when conversationId param is omitted', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( - { details: { agentId: 'test-agent', conversationId: 'from-details' } }, - testTenantDetails + undefined, + { details: { agentId: 'test-agent', conversationId: 'from-details', tenantId: 'test-tenant-456' } } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).toEqual(expect.arrayContaining([ @@ -181,9 +179,8 @@ describe('Scopes', () => { it('should set channel tags from request.channel', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( - { details: { agentId: 'test-agent' } }, - testTenantDetails, - { channel: { name: 'Teams', description: 'https://teams.link' } } + { channel: { name: 'Teams', description: 'https://teams.link' } }, + { details: { 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([ @@ -200,11 +197,12 @@ describe('Scopes', () => { details: { agentId: 'test-agent', agentName: 'Test Agent', - platformId: 'test-platform-123' + platformId: 'test-platform-123', + tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -221,7 +219,8 @@ describe('Scopes', () => { const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', - agentName: 'Test Agent' + agentName: 'Test Agent', + tenantId: 'test-tenant-456' } }; const callerAgentDetails: AgentDetails = { @@ -232,7 +231,7 @@ describe('Scopes', () => { platformId: 'caller-platform-xyz' } as any; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, callerAgentDetails, undefined); + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails, { callerAgentDetails }); expect(scope).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -249,7 +248,8 @@ describe('Scopes', () => { const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', - agentName: 'Test Agent' + agentName: 'Test Agent', + tenantId: 'test-tenant-456' } }; const callerDetails: CallerDetails = { @@ -265,9 +265,9 @@ describe('Scopes', () => { agentClientIP: '192.168.1.100' } as any; - const scope1 = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, undefined, callerDetails); + const scope1 = InvokeAgentScope.start(undefined, invokeAgentDetails, { callerDetails }); expect(scope1).toBeInstanceOf(InvokeAgentScope); - const scope2 = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails, undefined, callerAgentDetails, undefined); + const scope2 = InvokeAgentScope.start(undefined, invokeAgentDetails, { callerAgentDetails }); expect(scope2).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -291,13 +291,13 @@ describe('Scopes', () => { tenantId: 'tool-tenant', callerClientIp: '10.0.0.10' }; - const scope = ExecuteToolScope.start({ + const scope = ExecuteToolScope.start(undefined, { 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] })); @@ -316,15 +316,18 @@ describe('Scopes', () => { }); it('should record response', () => { - const scope = ExecuteToolScope.start({ toolName: 'test-tool' }, testAgentDetails, testTenantDetails); + const scope = ExecuteToolScope.start(undefined, { toolName: 'test-tool' }, testAgentDetails); expect(() => scope?.recordResponse('Tool result')).not.toThrow(); scope?.dispose(); }); - + 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] })); @@ -343,8 +346,9 @@ 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( + undefined, { toolName: 'test-tool', endpoint: { host: 'tools.example.com', port: 8080 } }, - testAgentDetails, testTenantDetails + testAgentDetails ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).toEqual(expect.arrayContaining([ @@ -357,8 +361,9 @@ describe('Scopes', () => { it('should omit port 443 on ExecuteToolScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = ExecuteToolScope.start( + undefined, { toolName: 'test-tool', endpoint: { host: 'tools.example.com', port: 443 } }, - testAgentDetails, testTenantDetails + testAgentDetails ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).not.toEqual(expect.arrayContaining([ @@ -371,8 +376,9 @@ 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( + undefined, { operationName: InferenceOperationType.CHAT, model: 'gpt-4', endpoint: { host: 'api.openai.com', port: 8443 } }, - testAgentDetails, testTenantDetails + testAgentDetails ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).toEqual(expect.arrayContaining([ @@ -385,8 +391,9 @@ describe('Scopes', () => { it('should omit port 443 on InferenceScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InferenceScope.start( + undefined, { operationName: InferenceOperationType.CHAT, model: 'gpt-4', endpoint: { host: 'api.openai.com', port: 443 } }, - testAgentDetails, testTenantDetails + testAgentDetails ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).not.toEqual(expect.arrayContaining([ @@ -399,8 +406,8 @@ 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( - { details: { agentId: 'test-agent' }, endpoint: { host: 'agent.example.com', port: 9090 } }, - testTenantDetails + undefined, + { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' }, endpoint: { host: 'agent.example.com', port: 9090 } } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).toEqual(expect.arrayContaining([ @@ -413,8 +420,8 @@ describe('Scopes', () => { it('should omit port 443 on InvokeAgentScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( - { details: { agentId: 'test-agent' }, endpoint: { host: 'agent.example.com', port: 443 } }, - testTenantDetails + undefined, + { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' }, endpoint: { host: 'agent.example.com', port: 443 } } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).not.toEqual(expect.arrayContaining([ @@ -444,7 +451,7 @@ describe('Scopes', () => { finishReasons: ['stop'] }; - const scope = InferenceScope.start(inferenceDetails, testAgentDetails, testTenantDetails, undefined, undefined, undefined, undefined, undefined, callerDetails); + const scope = InferenceScope.start(undefined, inferenceDetails, testAgentDetails, callerDetails); expect(scope).toBeInstanceOf(InferenceScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -467,8 +474,8 @@ describe('Scopes', () => { operationName: InferenceOperationType.TEXT_COMPLETION, model: 'gpt-3.5-turbo' }; - - const scope = InferenceScope.start(inferenceDetails, testAgentDetails, testTenantDetails); + + const scope = InferenceScope.start(undefined, inferenceDetails, testAgentDetails); expect(scope).toBeInstanceOf(InferenceScope); scope?.dispose(); @@ -479,8 +486,8 @@ describe('Scopes', () => { operationName: InferenceOperationType.CHAT, model: 'gpt-4' }; - - const scope = InferenceScope.start(inferenceDetails, testAgentDetails, testTenantDetails); + + const scope = InferenceScope.start(undefined, inferenceDetails, testAgentDetails); expect(() => scope?.recordInputMessages(['Input message'])).not.toThrow(); expect(() => scope?.recordOutputMessages(['Generated response'])).not.toThrow(); @@ -497,7 +504,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] })); @@ -514,8 +524,8 @@ describe('Scopes', () => { describe('Dispose pattern', () => { it('should support manual dispose', () => { - const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent' } }; - const scope = InvokeAgentScope.start(invokeAgentDetails, testTenantDetails); + const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; + const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); scope?.recordResponse('Manual dispose test'); expect(() => scope?.dispose()).not.toThrow(); @@ -523,9 +533,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(undefined, toolDetails, testAgentDetails); try { scope?.recordResponse('Automatic disposal test'); } finally { @@ -581,9 +591,8 @@ describe('Scopes', () => { const customEnd = 1700000005000; // 5 seconds later const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, testTenantDetails, - undefined, undefined, undefined, - customStart, customEnd + undefined, { toolName: 'my-tool' }, testAgentDetails, + undefined, { startTime: customStart, endTime: customEnd } ); scope.dispose(); @@ -597,9 +606,8 @@ describe('Scopes', () => { const laterEnd = 1700000048000; // 8 seconds later const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, testTenantDetails, - undefined, undefined, undefined, - customStart + undefined, { toolName: 'my-tool' }, testAgentDetails, + undefined, { startTime: customStart } ); scope.setEndTime(laterEnd); scope.dispose(); @@ -614,9 +622,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 + undefined, { toolName: 'my-tool' }, testAgentDetails, + undefined, { startTime: customStart, endTime: customEnd } ); scope.dispose(); @@ -631,9 +638,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 + undefined, { toolName: 'my-tool' }, testAgentDetails, + undefined, { startTime: customStart, endTime: customEnd } ); scope.dispose(); @@ -644,7 +650,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(undefined, { toolName: 'my-tool' }, testAgentDetails); scope.dispose(); const after = Date.now(); @@ -661,8 +667,10 @@ describe('Scopes', () => { ['SERVER', SpanKind.SERVER, SpanKind.SERVER], ])('InvokeAgentScope spanKind: %s', (_label, input, expected) => { const scope = InvokeAgentScope.start( - { details: { agentId: 'test-agent' } }, testTenantDetails, - undefined, undefined, undefined, undefined, undefined, undefined, undefined, input + undefined, + { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }, + undefined, + input !== undefined ? { spanKind: input } : undefined ); scope.dispose(); expect(getFinishedSpan().kind).toBe(expected); @@ -673,36 +681,16 @@ 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 + undefined, { toolName: 'my-tool' }, testAgentDetails, + undefined, input !== undefined ? { spanKind: input } : undefined ); scope.dispose(); expect(getFinishedSpan().kind).toBe(expected); }); - it('setStartTime should adjust duration baseline when called after construction', () => { - const laterStart = 1700000002000; // 2 seconds after original start - const customEnd = 1700000010000; // 10 seconds epoch - - const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, testTenantDetails, - undefined, undefined, undefined, - 1700000000000, customEnd - ); - scope.setStartTime(laterStart); - // setStartTime adjusts the internal baseline, not the OTel span startTime - scope.dispose(); - - const span = getFinishedSpan(); - // The OTel span startTime remains the constructor-provided value - expect(hrtimeToMs(span.startTime as [number, number])).toBeCloseTo(1700000000000, -1); - // The span endTime should reflect the custom end - expect(hrtimeToMs(span.endTime as [number, number])).toBeCloseTo(customEnd, -1); - }); - it('recordCancellation should set error status and error.type attribute with default reason', () => { const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, testTenantDetails + undefined, { toolName: 'my-tool' }, testAgentDetails ); scope.recordCancellation(); scope.dispose(); @@ -715,7 +703,7 @@ describe('Scopes', () => { it('recordCancellation should use custom reason', () => { const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, testTenantDetails + undefined, { toolName: 'my-tool' }, testAgentDetails ); scope.recordCancellation('User aborted'); scope.dispose(); diff --git a/tests/observability/core/trace-context-propagation.test.ts b/tests/observability/core/trace-context-propagation.test.ts index aee6bcaa..ab3a5619 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,14 @@ 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({ details: { agentId: 'ctx-agent' } }, testTenantDetails, undefined, undefined, undefined, undefined, extractedCtx); + const extractedCtx = extractContextFromHeaders({ traceparent: `00-${traceId}-${spanId}-01` }); + + const scope = InvokeAgentScope.start( + undefined, + { details: { agentId: 'ctx-agent', tenantId: 'test-tenant' } }, + undefined, + { parentContext: extractedCtx } + ); expect(scope.getSpanContext().traceId).toBe(traceId); scope.dispose(); @@ -161,7 +163,12 @@ describe('Trace Context Propagation', () => { isRemote: true, }; - const scope = InvokeAgentScope.start({ details: { agentId: 'remote-agent' } }, testTenantDetails, undefined, undefined, undefined, undefined, parentRef); + const scope = InvokeAgentScope.start( + undefined, + { details: { 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 97b1a2de..61a9b8d3 100644 --- a/tests/observability/extension/hosting/scope-utils.test.ts +++ b/tests/observability/extension/hosting/scope-utils.test.ts @@ -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,14 +124,14 @@ 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 = { details: { 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)'); + .toThrow('InvokeAgentScope: tenantId is required on invokeAgentDetails.details'); }); }); @@ -313,8 +313,8 @@ describe('ScopeUtils spanKind forwarding', () => { undefined, undefined, SpanKind.SERVER ); expect(spy).toHaveBeenCalledWith( - expect.anything(), expect.anything(), expect.anything(), expect.anything(), - expect.anything(), expect.anything(), undefined, undefined, undefined, SpanKind.SERVER + expect.anything(), expect.anything(), expect.anything(), + expect.objectContaining({ spanKind: SpanKind.SERVER }) ); scope?.dispose(); spy.mockRestore(); @@ -329,7 +329,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(); From b0d099dc48c84c07c37ec97db54d15b899a788b5 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Thu, 19 Mar 2026 19:40:46 -0700 Subject: [PATCH 03/16] feat: add source files for scope API alignment Include all modified source files that were missing from the previous commit: new scope signatures, contracts, exports, CHANGELOG, and hosting middleware updates. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 23 ++-- .../src/middleware/OutputLoggingMiddleware.ts | 27 +++-- .../src/ObservabilityBuilder.ts | 25 +++- .../agents-a365-observability/src/index.ts | 9 +- .../context/trace-context-propagation.ts | 16 +-- .../src/tracing/contracts.ts | 77 +++++++++++- .../src/tracing/scopes/ExecuteToolScope.ts | 74 +++++------- .../src/tracing/scopes/InferenceScope.ts | 67 +++++------ .../src/tracing/scopes/InvokeAgentScope.ts | 113 +++++++++--------- .../src/tracing/scopes/OutputScope.ts | 55 ++++----- 10 files changed, 290 insertions(+), 196 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9af473fd..31b5ab99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,23 +9,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes (`@microsoft/agents-a365-observability`) -- **`InvokeAgentDetails` — inheritance → composition.** `InvokeAgentDetails` no longer extends `AgentDetails`. Agent identity is now accessed via `details.details` (e.g., `invokeAgentDetails.details.agentId` instead of `invokeAgentDetails.agentId`). This aligns with the .NET (`Details`) and Python (`details`) SDKs. -- **`InvokeAgentDetails` — removed fields.** `request`, `providerName`, and `agentBlueprintId` have been removed from the interface. Use `details.providerName` and `details.agentBlueprintId` from the nested `AgentDetails` instead. Pass `request` as a separate parameter to `InvokeAgentScope.start()`. -- **`InvokeAgentScope.start()` — new signature.** Parameters `request` (position 3) and `conversationId` (position 6) are now explicit parameters, matching the .NET SDK. The `channel` parameter has been removed; channel data is derived from `request.channel`. +- **`InvokeAgentDetails` — inheritance → composition.** No longer extends `AgentDetails`. Agent identity is accessed via `invokeAgentDetails.details.agentId`. Removed `request`, `providerName`, `agentBlueprintId` fields. +- **`InvokeAgentScope.start()` — new signature.** `start(request?, invokeAgentDetails, callerInfo?, spanDetails?)`. Tenant ID is derived from `invokeAgentDetails.details.tenantId` (required). `callerDetails` and `callerAgentDetails` are wrapped in `InvokeAgentCallerDetails`. Span options (`parentContext`, `startTime`, `endTime`, `spanKind`) are grouped in `SpanDetails`. +- **`InferenceScope.start()` — new signature.** `start(request?, details, agentDetails, callerDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `channel` and `conversationId` moved into `InferenceRequest`. +- **`ExecuteToolScope.start()` — new signature.** `start(request?, details, agentDetails, callerDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `channel` and `conversationId` moved into `ToolRequest`. +- **`OutputScope.start()` — new signature.** `start(request?, response, agentDetails, callerDetails?, 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. +- **`injectTraceContext()` renamed to `injectContextToHeaders()`**. +- **`extractTraceContext()` renamed to `extractContextFromHeaders()`**. +- **`OpenTelemetryScope.setStartTime()` removed.** Use `SpanDetails.startTime` on scope construction instead. +- **`AgentRequest.conversationId`** — New optional field for passing conversation ID via the request object. - **`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`). - **`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`) -- **`OpenTelemetryScope.setStartTime()`** — Adjusts the internal duration baseline after construction. +- **`SpanDetails`** — New interface grouping `parentContext`, `startTime`, `endTime`, `spanKind` for scope construction. +- **`InvokeAgentCallerDetails`** — New interface wrapping `callerDetails` and `callerAgentDetails` for `InvokeAgentScope`. +- **`InferenceRequest`** — New interface for inference scope request context (`conversationId`, `channel`). +- **`ToolRequest`** — New interface for tool scope request context (`conversationId`, `channel`). +- **`OutputRequest`** — New interface for output scope request context (`conversationId`, `channel`). - **`OpenTelemetryScope.recordCancellation()`** — Records a cancellation event on the span with `error.type = 'TaskCanceledException'` (aligned with Python SDK). - **`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`) diff --git a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts index 3e6b429e..c4c2ac2c 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, ParentSpanRef, + OutputRequest, + SpanDetails, logger, isPerRequestExportEnabled, } from '@microsoft/agents-a365-observability'; @@ -39,9 +40,8 @@ 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) { await next(); return; } @@ -50,8 +50,13 @@ export class OutputLoggingMiddleware implements Middleware { const conversationId = ScopeUtils.deriveConversationId(context); const channel = ScopeUtils.deriveChannelObject(context); + const request: OutputRequest = { + conversationId, + channel, + }; + context.onSendActivities( - this._createSendHandler(context, agentDetails, tenantDetails, callerDetails, conversationId, channel) + this._createSendHandler(context, agentDetails, callerDetails, 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 }, + request?: OutputRequest, ): 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, + spanDetails, ); try { return await sendNext(); diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index 6ce6b9f0..d1691eda 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -11,7 +11,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 +26,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 +75,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 +203,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..86f0bd59 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -23,8 +23,8 @@ export { ParentSpanRef, runWithParentSpanRef, createContextWithParentSpanRef } f export { HeadersCarrier, ParentContext, - injectTraceContext, - extractTraceContext, + injectContextToHeaders, + extractContextFromHeaders, runWithExtractedTraceContext } from './tracing/context/trace-context-propagation'; @@ -45,6 +45,11 @@ export { InferenceOperationType, InferenceResponse, OutputResponse, + OutputRequest, + SpanDetails, + InferenceRequest, + ToolRequest, + InvokeAgentCallerDetails, } from './tracing/contracts'; // Scopes 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 8e025ef0..cc989bb5 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, undefined, undefined, parentCtx); + * const parentCtx = extractContextFromHeaders(req.headers); + * const scope = InvokeAgentScope.start(request, invokeAgentDetails, 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(undefined, invokeAgentDetails); * 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 1e31b050..357ecb05 100644 --- a/packages/agents-a365-observability/src/tracing/contracts.ts +++ b/packages/agents-a365-observability/src/tracing/contracts.ts @@ -1,6 +1,9 @@ // 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 */ @@ -81,6 +84,31 @@ export interface AgentRequest { /** Optional channel for the invocation */ channel?: Channel; + + /** Optional conversation identifier */ + conversationId?: string; +} + +/** + * Represents a request context for inference operations + */ +export interface InferenceRequest { + /** Optional conversation identifier */ + conversationId?: string; + + /** Optional channel; only `name` (channel name) and `description` (channel link/URL) are used for tagging. */ + channel?: Pick; +} + +/** + * Represents a request context for tool execution operations + */ +export interface ToolRequest { + /** Optional conversation identifier */ + conversationId?: string; + + /** Optional channel; only `name` (channel name) and `description` (channel link/URL) are used for tagging. */ + channel?: Pick; } /** @@ -175,6 +203,18 @@ export interface CallerDetails { callerClientIp?: string; } +/** + * Caller details for agent invocation scopes. + * Supports human callers, agent callers, or both (A2A with a human in the chain). + */ +export interface InvokeAgentCallerDetails { + /** Optional human/non-agentic caller identity */ + callerDetails?: CallerDetails; + + /** Optional calling agent identity for A2A (agent-to-agent) scenarios */ + callerAgentDetails?: AgentDetails; +} + /* * @deprecated Use AgentDetails. EnhancedAgentDetails is now an alias of AgentDetails. */ @@ -199,7 +239,7 @@ export interface ServiceEndpoint { * Details for invoking another agent. */ export interface InvokeAgentDetails { - /** The agent identity details (composition – matches .NET `Details` / Python `details`). */ + /** The agent identity details. */ details: AgentDetails; /** The endpoint for the agent invocation */ @@ -210,7 +250,7 @@ export interface InvokeAgentDetails { } /** - * Details for an inference call matching C# implementation + * Details for an inference call */ export interface InferenceDetails { /** The operation name/type for the inference */ @@ -259,6 +299,17 @@ export interface InferenceResponse { } +/** + * Represents a request context for output message operations + */ +export interface OutputRequest { + /** Optional conversation identifier */ + conversationId?: string; + + /** Optional channel; only `name` (channel name) and `description` (channel link/URL) are used for tagging. */ + channel?: Pick; +} + /** * Represents a response containing output messages from an agent. * Used with OutputScope for output message tracing. @@ -267,3 +318,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..c2bfa669 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, + CallerDetails, + ToolRequest, + SpanDetails, +} from '../contracts'; import { OpenTelemetryConstants } from '../constants'; /** @@ -13,62 +18,50 @@ 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 Optional request context (conversationId, channel). + * @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 callerDetails Optional caller identity. + * @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanKind). * @returns A new ExecuteToolScope instance. */ public static start( + request: ToolRequest | undefined, details: ToolCallDetails, agentDetails: AgentDetails, - tenantDetails: TenantDetails, - conversationId?: string, - channel?: Pick, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, callerDetails?: CallerDetails, - spanKind?: SpanKind + spanDetails?: SpanDetails ): ExecuteToolScope { - return new ExecuteToolScope(details, agentDetails, tenantDetails, conversationId, channel, parentContext, startTime, endTime, callerDetails, spanKind); + return new ExecuteToolScope(request, details, agentDetails, callerDetails, spanDetails); } private constructor( + request: ToolRequest | undefined, details: ToolCallDetails, agentDetails: AgentDetails, - tenantDetails: TenantDetails, - conversationId?: string, - channel?: Pick, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, callerDetails?: CallerDetails, - spanKind?: SpanKind + spanDetails?: SpanDetails ) { + // Derive tenant details from agentDetails.tenantId (required for telemetry) + if (!agentDetails.tenantId) { + throw new Error('ExecuteToolScope: tenantId is required on agentDetails'); + } + const tenantDetails = { tenantId: agentDetails.tenantId }; + 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, + spanDetails?.parentContext, + spanDetails?.startTime, + spanDetails?.endTime, callerDetails ); - // 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 +69,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..4a32b49b 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 + CallerDetails, + InferenceRequest, + SpanDetails, } from '../contracts'; -import { ParentContext } from '../context/trace-context-propagation'; /** * Provides OpenTelemetry tracing scope for generative AI inference operations. @@ -19,56 +18,50 @@ 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 Optional request context (conversationId, channel). + * @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 callerDetails Optional caller identity. + * @param spanDetails Optional span configuration (parentContext, startTime, endTime). * @returns A new InferenceScope instance */ public static start( + request: InferenceRequest | undefined, details: InferenceDetails, agentDetails: AgentDetails, - tenantDetails: TenantDetails, - conversationId?: string, - channel?: Pick, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, - callerDetails?: CallerDetails + callerDetails?: CallerDetails, + spanDetails?: SpanDetails ): InferenceScope { - return new InferenceScope(details, agentDetails, tenantDetails, conversationId, channel, parentContext, startTime, endTime, callerDetails); + return new InferenceScope(request, details, agentDetails, callerDetails, spanDetails); } private constructor( + request: InferenceRequest | undefined, details: InferenceDetails, agentDetails: AgentDetails, - tenantDetails: TenantDetails, - conversationId?: string, - channel?: Pick, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, - callerDetails?: CallerDetails + callerDetails?: CallerDetails, + spanDetails?: SpanDetails ) { + // Derive tenant details from agentDetails.tenantId (required for telemetry) + if (!agentDetails.tenantId) { + throw new Error('InferenceScope: tenantId is required on agentDetails'); + } + const tenantDetails = { tenantId: agentDetails.tenantId }; + super( SpanKind.CLIENT, details.operationName.toString(), `${details.operationName} ${details.model}`, agentDetails, tenantDetails, - parentContext, - startTime, - endTime, + spanDetails?.parentContext, + spanDetails?.startTime, + spanDetails?.endTime, callerDetails ); - // Set core inference information matching C# implementation + // Set core inference information this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY, details.operationName.toString()); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY, details.model); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, details.providerName); @@ -76,9 +69,9 @@ export class InferenceScope extends OpenTelemetryScope { 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 3b8de25f..5a2ca2a4 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -1,16 +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, CallerDetails, - AgentDetails, - AgentRequest + InvokeAgentCallerDetails, + AgentRequest, + SpanDetails, } from '../contracts'; -import { ParentContext } from '../context/trace-context-propagation'; import { OpenTelemetryConstants } from '../constants'; /** @@ -19,67 +18,72 @@ 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 (agent identity via `.details`, endpoint, sessionId). - * @param tenantDetails The tenant details. - * @param request Optional request payload (content, executionType, channel). - * @param callerAgentDetails The details of the caller agent. - * @param callerDetails The details of the non-agentic caller. - * @param conversationId Optional conversation id to tag on the span (`gen_ai.conversation.id`). - * @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 Optional request payload (content, executionType, channel, conversationId). + * @param invokeAgentDetails The details of the agent invocation (agent identity via `.details`, endpoint). + * Tenant ID is derived from `invokeAgentDetails.details.tenantId`. + * @param callerInfo Optional caller information. Supports three scenarios: + * - Human caller only: `{ callerDetails: { callerId, callerName, ... } }` + * - Agent caller only: `{ callerAgentDetails: { agentId, agentName, ... } }` — + * `microsoft.caller.*` attributes are derived from the agent details. + * - Both (A2A with human in chain): `{ callerDetails: { ... }, callerAgentDetails: { ... } }` + * @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanKind). * @returns A new InvokeAgentScope instance. */ public static start( + request: AgentRequest | undefined, invokeAgentDetails: InvokeAgentDetails, - tenantDetails: TenantDetails, - request?: AgentRequest, - callerAgentDetails?: AgentDetails, - callerDetails?: CallerDetails, - conversationId?: string, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, - spanKind?: SpanKind + callerInfo?: InvokeAgentCallerDetails, + spanDetails?: SpanDetails ): InvokeAgentScope { - return new InvokeAgentScope(invokeAgentDetails, tenantDetails, request, callerAgentDetails, callerDetails, conversationId, parentContext, startTime, endTime, spanKind); + return new InvokeAgentScope(request, invokeAgentDetails, callerInfo, spanDetails); } private constructor( + request: AgentRequest | undefined, invokeAgentDetails: InvokeAgentDetails, - tenantDetails: TenantDetails, - request?: AgentRequest, - callerAgentDetails?: AgentDetails, - callerDetails?: CallerDetails, - conversationId?: string, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, - spanKind?: SpanKind + callerInfo?: InvokeAgentCallerDetails, + spanDetails?: SpanDetails ) { const agent = invokeAgentDetails.details; + // Derive tenant details from agent.tenantId (required for telemetry) + if (!agent.tenantId) { + throw new Error('InvokeAgentScope: tenantId is required on invokeAgentDetails.details'); + } + const tenantDetails = { tenantId: agent.tenantId }; + + // Resolve CallerDetails for the base class (microsoft.caller.* attributes). + // When only callerAgentDetails is provided (agent-only caller), derive CallerDetails from it. + let baseCallerDetails: CallerDetails | undefined = callerInfo?.callerDetails; + if (!baseCallerDetails && callerInfo?.callerAgentDetails) { + const callerAgent = callerInfo.callerAgentDetails; + baseCallerDetails = { + callerId: callerAgent.agentAUID, + callerUpn: callerAgent.agentUPN, + callerName: callerAgent.agentName, + tenantId: callerAgent.tenantId, + }; + } + super( - spanKind ?? SpanKind.CLIENT, + spanDetails?.spanKind ?? SpanKind.CLIENT, OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, agent.agentName ? `${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${agent.agentName}` : OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, agent, tenantDetails, - parentContext, - startTime, - endTime, - callerDetails + spanDetails?.parentContext, + spanDetails?.startTime, + spanDetails?.endTime, + baseCallerDetails ); // Set provider name from agent details this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, agent.providerName); - // Set session ID and endpoint information + // Set session ID this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, invokeAgentDetails.sessionId); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, agent.agentBlueprintId); @@ -93,23 +97,24 @@ export class InvokeAgentScope extends OpenTelemetryScope { } } - // Set channel tags from request (aligned with .NET/Python: channel lives inside request) + // 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); } - // Use explicit conversationId param, falling back to agent.conversationId - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, conversationId ?? agent.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_UPN_KEY, callerAgentDetails.agentUPN); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, callerAgentDetails.platformId); + // Use explicit conversationId from request, falling back to agent.conversationId + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request?.conversationId ?? agent.conversationId); + + // Set caller agent details tags for A2A scenarios + const callerAgent = callerInfo?.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_UPN_KEY, callerAgent.agentUPN); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, callerAgent.platformId); } } diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index 8f0036b3..bbb419b9 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, CallerDetails, OutputResponse, OutputRequest, SpanDetails } from '../contracts'; import { OpenTelemetryConstants } from '../constants'; /** @@ -16,43 +15,37 @@ export class OutputScope extends OpenTelemetryScope { /** * Creates and starts a new scope for output message tracing. + * + * @param request Optional request context (conversationId, channel). * @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 callerDetails Optional caller identity details. + * @param spanDetails Optional span configuration (parentContext, startTime, endTime). * @returns A new OutputScope instance. */ public static start( + request: OutputRequest | undefined, response: OutputResponse, agentDetails: AgentDetails, - tenantDetails: TenantDetails, callerDetails?: CallerDetails, - conversationId?: string, - channel?: Pick, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput + spanDetails?: SpanDetails ): OutputScope { - return new OutputScope(response, agentDetails, tenantDetails, callerDetails, conversationId, channel, parentContext, startTime, endTime); + return new OutputScope(request, response, agentDetails, callerDetails, spanDetails); } private constructor( + request: OutputRequest | undefined, response: OutputResponse, agentDetails: AgentDetails, - tenantDetails: TenantDetails, callerDetails?: CallerDetails, - conversationId?: string, - channel?: Pick, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput + spanDetails?: SpanDetails ) { + // Derive tenant details from agentDetails.tenantId (required for telemetry) + if (!agentDetails.tenantId) { + throw new Error('OutputScope: tenantId is required on agentDetails'); + } + const tenantDetails = { tenantId: agentDetails.tenantId }; + super( SpanKind.CLIENT, OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME, @@ -61,9 +54,9 @@ export class OutputScope extends OpenTelemetryScope { : `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentId}`, agentDetails, tenantDetails, - parentContext, - startTime, - endTime, + spanDetails?.parentContext, + spanDetails?.startTime, + spanDetails?.endTime, callerDetails ); @@ -77,9 +70,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); } From b14fe0add499761559d196431b28ec066cab6ef3 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 20 Mar 2026 12:39:46 -0700 Subject: [PATCH 04/16] =?UTF-8?q?refactor:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20rename=20types=20and=20unify=20Request=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename AgentRequest → Request, use across all scopes (remove InferenceRequest, ToolRequest, OutputRequest) - Remove executionType from Request interface - Rename CallerDetails → UserDetails (human caller) - Rename InvokeAgentCallerDetails → CallerDetails (composite caller) with userDetails property instead of callerDetails - InvokeAgentScope.start: request param is now required (not undefined) - Remove caller derivation from agent details — caller is always human - Fix stale JSDoc on populateInvokeAgentScopeFromTurnContext - Update all tests for new type names Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/middleware/OutputLoggingMiddleware.ts | 16 +++--- .../src/utils/ScopeUtils.ts | 39 +++++++------- .../agents-a365-observability/src/index.ts | 7 +-- .../src/tracing/contracts.ts | 53 ++++--------------- .../src/tracing/scopes/ExecuteToolScope.ts | 18 +++---- .../src/tracing/scopes/InferenceScope.ts | 18 +++---- .../src/tracing/scopes/InvokeAgentScope.ts | 35 ++++-------- .../src/tracing/scopes/OpenTelemetryScope.ts | 16 +++--- .../src/tracing/scopes/OutputScope.ts | 16 +++--- .../core/parent-span-ref.test.ts | 14 ++--- tests/observability/core/scopes.test.ts | 44 +++++++-------- .../core/trace-context-propagation.test.ts | 4 +- .../extension/hosting/scope-utils.test.ts | 2 +- 13 files changed, 113 insertions(+), 169 deletions(-) diff --git a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts index c4c2ac2c..0c0b723b 100644 --- a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts +++ b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts @@ -5,9 +5,9 @@ import { TurnContext, Middleware, SendActivitiesHandler } from '@microsoft/agent import { OutputScope, AgentDetails, - CallerDetails, + UserDetails, ParentSpanRef, - OutputRequest, + Request, SpanDetails, logger, isPerRequestExportEnabled, @@ -46,17 +46,17 @@ export class OutputLoggingMiddleware implements Middleware { return; } - const callerDetails = ScopeUtils.deriveCallerDetails(context); + const userDetails = ScopeUtils.deriveCallerDetails(context); const conversationId = ScopeUtils.deriveConversationId(context); const channel = ScopeUtils.deriveChannelObject(context); - const request: OutputRequest = { + const request: Request = { conversationId, channel, }; context.onSendActivities( - this._createSendHandler(context, agentDetails, callerDetails, request) + this._createSendHandler(context, agentDetails, userDetails, request) ); await next(); @@ -86,8 +86,8 @@ export class OutputLoggingMiddleware implements Middleware { private _createSendHandler( turnContext: TurnContext, agentDetails: AgentDetails, - callerDetails?: CallerDetails, - request?: OutputRequest, + userDetails?: UserDetails, + request?: Request, ): SendActivitiesHandler { return async (_ctx, activities, sendNext) => { const messages = activities @@ -113,7 +113,7 @@ export class OutputLoggingMiddleware implements Middleware { request, { messages }, agentDetails, - callerDetails, + userDetails, spanDetails, ); try { diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index ba7344a7..47f7eca9 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -10,14 +10,12 @@ import { InferenceScope, ExecuteToolScope, AgentDetails, + UserDetails, CallerDetails, InferenceDetails, InvokeAgentDetails, - InvokeAgentCallerDetails, ToolCallDetails, - AgentRequest, - InferenceRequest, - ToolRequest, + Request, SpanDetails, } from '@microsoft/agents-a365-observability'; import { resolveEmbodiedAgentIds } from './TurnContextUtils'; @@ -97,11 +95,11 @@ export class ScopeUtils { /** - * Derive caller identity details (id, upn, name, tenant, client ip) from the activity from. + * Derive human caller identity details (id, upn, 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 { @@ -109,7 +107,7 @@ export class ScopeUtils { callerUpn: from.agenticUserId, callerName: from.name, tenantId: from.tenantId, - } as CallerDetails; + } as UserDetails; } /** @@ -161,7 +159,7 @@ export class ScopeUtils { } const hasChannel = channel.name !== undefined || channel.description !== undefined; - const request: InferenceRequest | undefined = (conversationId || hasChannel) + const request: Request | undefined = (conversationId || hasChannel) ? { conversationId, ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), @@ -179,9 +177,10 @@ export class ScopeUtils { /** * 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 `callerAgentDetails` (from caller) and `callerDetails` (from user). - * Also sets execution type and input messages from the context if present. + * Builds a separate `Request` with `conversationId` and `channel` from context. + * Merges agent identity from context into `invokeAgentDetails.details`. + * Derives `callerAgentDetails` (from caller) and `userDetails` (human caller). + * Also records input messages from the context if present. * @param details The invoke-agent call details to be augmented and used for the scope. * @param turnContext The current activity context to derive scope parameters from. * @param authToken Auth token for resolving agent identity from token claims. @@ -208,16 +207,14 @@ export class ScopeUtils { // Build the request only when there is concrete channel or conversationId info const hasChannel = channel.name !== undefined || channel.description !== undefined; - const request: AgentRequest | undefined = (conversationId || hasChannel) - ? { - conversationId, - ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), - } - : undefined; + const request: Request = { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + }; // Build caller info with both human caller and caller agent details - const callerInfo: InvokeAgentCallerDetails = { - callerDetails: caller, + const callerInfo: CallerDetails = { + userDetails: caller, callerAgentDetails: callerAgent, }; @@ -288,7 +285,7 @@ export class ScopeUtils { } const hasChannel = channel.name !== undefined || channel.description !== undefined; - const request: ToolRequest | undefined = (conversationId || hasChannel) + const request: Request | undefined = (conversationId || hasChannel) ? { conversationId, ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index 86f0bd59..311369cc 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -33,11 +33,12 @@ export { ExecutionType, InvocationRole, Channel, - AgentRequest, + Request, AgentDetails, TenantDetails, ToolCallDetails, InvokeAgentDetails, + UserDetails, CallerDetails, EnhancedAgentDetails, ServiceEndpoint, @@ -45,11 +46,7 @@ export { InferenceOperationType, InferenceResponse, OutputResponse, - OutputRequest, SpanDetails, - InferenceRequest, - ToolRequest, - InvokeAgentCallerDetails, } from './tracing/contracts'; // Scopes diff --git a/packages/agents-a365-observability/src/tracing/contracts.ts b/packages/agents-a365-observability/src/tracing/contracts.ts index 357ecb05..db0eb12e 100644 --- a/packages/agents-a365-observability/src/tracing/contracts.ts +++ b/packages/agents-a365-observability/src/tracing/contracts.ts @@ -70,15 +70,13 @@ 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; @@ -89,28 +87,6 @@ export interface AgentRequest { conversationId?: string; } -/** - * Represents a request context for inference operations - */ -export interface InferenceRequest { - /** Optional conversation identifier */ - conversationId?: string; - - /** Optional channel; only `name` (channel name) and `description` (channel link/URL) are used for tagging. */ - channel?: Pick; -} - -/** - * Represents a request context for tool execution operations - */ -export interface ToolRequest { - /** Optional conversation identifier */ - conversationId?: string; - - /** Optional channel; only `name` (channel name) and `description` (channel link/URL) are used for tagging. */ - channel?: Pick; -} - /** * Details about a tenant */ @@ -181,9 +157,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 */ callerId?: string; @@ -204,12 +180,12 @@ export interface CallerDetails { } /** - * Caller details for agent invocation scopes. + * Caller details for scope creation. * Supports human callers, agent callers, or both (A2A with a human in the chain). */ -export interface InvokeAgentCallerDetails { - /** Optional human/non-agentic caller identity */ - callerDetails?: CallerDetails; +export interface CallerDetails { + /** Optional human caller identity */ + userDetails?: UserDetails; /** Optional calling agent identity for A2A (agent-to-agent) scenarios */ callerAgentDetails?: AgentDetails; @@ -299,17 +275,6 @@ export interface InferenceResponse { } -/** - * Represents a request context for output message operations - */ -export interface OutputRequest { - /** Optional conversation identifier */ - conversationId?: string; - - /** Optional channel; only `name` (channel name) and `description` (channel link/URL) are used for tagging. */ - channel?: Pick; -} - /** * Represents a response containing output messages from an agent. * Used with OutputScope for output message tracing. diff --git a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts index c2bfa669..5bdfc1c1 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts @@ -6,8 +6,8 @@ import { OpenTelemetryScope } from './OpenTelemetryScope'; import { ToolCallDetails, AgentDetails, - CallerDetails, - ToolRequest, + UserDetails, + Request, SpanDetails, } from '../contracts'; import { OpenTelemetryConstants } from '../constants'; @@ -22,25 +22,25 @@ export class ExecuteToolScope extends OpenTelemetryScope { * @param request Optional request context (conversationId, channel). * @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 callerDetails Optional caller identity. + * @param userDetails Optional human caller identity. * @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanKind). * @returns A new ExecuteToolScope instance. */ public static start( - request: ToolRequest | undefined, + request: Request | undefined, details: ToolCallDetails, agentDetails: AgentDetails, - callerDetails?: CallerDetails, + userDetails?: UserDetails, spanDetails?: SpanDetails ): ExecuteToolScope { - return new ExecuteToolScope(request, details, agentDetails, callerDetails, spanDetails); + return new ExecuteToolScope(request, details, agentDetails, userDetails, spanDetails); } private constructor( - request: ToolRequest | undefined, + request: Request | undefined, details: ToolCallDetails, agentDetails: AgentDetails, - callerDetails?: CallerDetails, + userDetails?: UserDetails, spanDetails?: SpanDetails ) { // Derive tenant details from agentDetails.tenantId (required for telemetry) @@ -58,7 +58,7 @@ export class ExecuteToolScope extends OpenTelemetryScope { spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, - callerDetails + userDetails ); // Destructure the details object diff --git a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts index 4a32b49b..64d0d2a9 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts @@ -7,8 +7,8 @@ import { OpenTelemetryConstants } from '../constants'; import { InferenceDetails, AgentDetails, - CallerDetails, - InferenceRequest, + UserDetails, + Request, SpanDetails, } from '../contracts'; @@ -22,25 +22,25 @@ export class InferenceScope extends OpenTelemetryScope { * @param request Optional request context (conversationId, channel). * @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 callerDetails Optional caller identity. + * @param userDetails Optional human caller identity. * @param spanDetails Optional span configuration (parentContext, startTime, endTime). * @returns A new InferenceScope instance */ public static start( - request: InferenceRequest | undefined, + request: Request | undefined, details: InferenceDetails, agentDetails: AgentDetails, - callerDetails?: CallerDetails, + userDetails?: UserDetails, spanDetails?: SpanDetails ): InferenceScope { - return new InferenceScope(request, details, agentDetails, callerDetails, spanDetails); + return new InferenceScope(request, details, agentDetails, userDetails, spanDetails); } private constructor( - request: InferenceRequest | undefined, + request: Request | undefined, details: InferenceDetails, agentDetails: AgentDetails, - callerDetails?: CallerDetails, + userDetails?: UserDetails, spanDetails?: SpanDetails ) { // Derive tenant details from agentDetails.tenantId (required for telemetry) @@ -58,7 +58,7 @@ export class InferenceScope extends OpenTelemetryScope { spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, - callerDetails + userDetails ); // Set core inference information diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts index 5a2ca2a4..96b22267 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -6,8 +6,7 @@ import { OpenTelemetryScope } from './OpenTelemetryScope'; import { InvokeAgentDetails, CallerDetails, - InvokeAgentCallerDetails, - AgentRequest, + Request, SpanDetails, } from '../contracts'; import { OpenTelemetryConstants } from '../constants'; @@ -19,30 +18,29 @@ export class InvokeAgentScope extends OpenTelemetryScope { /** * Creates and starts a new scope for agent invocation tracing. * - * @param request Optional request payload (content, executionType, channel, conversationId). + * @param request Request payload (channel, conversationId, content, sessionId). * @param invokeAgentDetails The details of the agent invocation (agent identity via `.details`, endpoint). * Tenant ID is derived from `invokeAgentDetails.details.tenantId`. * @param callerInfo Optional caller information. Supports three scenarios: - * - Human caller only: `{ callerDetails: { callerId, callerName, ... } }` - * - Agent caller only: `{ callerAgentDetails: { agentId, agentName, ... } }` — - * `microsoft.caller.*` attributes are derived from the agent details. - * - Both (A2A with human in chain): `{ callerDetails: { ... }, callerAgentDetails: { ... } }` + * - Human caller only: `{ userDetails: { callerId, callerName, ... } }` + * - 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( - request: AgentRequest | undefined, + request: Request, invokeAgentDetails: InvokeAgentDetails, - callerInfo?: InvokeAgentCallerDetails, + callerInfo?: CallerDetails, spanDetails?: SpanDetails ): InvokeAgentScope { return new InvokeAgentScope(request, invokeAgentDetails, callerInfo, spanDetails); } private constructor( - request: AgentRequest | undefined, + request: Request, invokeAgentDetails: InvokeAgentDetails, - callerInfo?: InvokeAgentCallerDetails, + callerInfo?: CallerDetails, spanDetails?: SpanDetails ) { const agent = invokeAgentDetails.details; @@ -53,19 +51,6 @@ export class InvokeAgentScope extends OpenTelemetryScope { } const tenantDetails = { tenantId: agent.tenantId }; - // Resolve CallerDetails for the base class (microsoft.caller.* attributes). - // When only callerAgentDetails is provided (agent-only caller), derive CallerDetails from it. - let baseCallerDetails: CallerDetails | undefined = callerInfo?.callerDetails; - if (!baseCallerDetails && callerInfo?.callerAgentDetails) { - const callerAgent = callerInfo.callerAgentDetails; - baseCallerDetails = { - callerId: callerAgent.agentAUID, - callerUpn: callerAgent.agentUPN, - callerName: callerAgent.agentName, - tenantId: callerAgent.tenantId, - }; - } - super( spanDetails?.spanKind ?? SpanKind.CLIENT, OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, @@ -77,7 +62,7 @@ export class InvokeAgentScope extends OpenTelemetryScope { spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, - baseCallerDetails + callerInfo?.userDetails ); // Set provider name from agent details diff --git a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts index 24017905..92513ef7 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts @@ -3,7 +3,7 @@ import { trace, SpanKind, Span, SpanStatusCode, context, AttributeValue, SpanContext, TimeInput } from '@opentelemetry/api'; import { OpenTelemetryConstants } from '../constants'; -import { AgentDetails, TenantDetails, CallerDetails } from '../contracts'; +import { AgentDetails, TenantDetails, UserDetails } from '../contracts'; import { createContextWithParentSpanRef } from '../context/parent-span-context'; import { ParentContext, isParentSpanRef } from '../context/trace-context-propagation'; import logger from '../../utils/logging'; @@ -37,7 +37,7 @@ export abstract class OpenTelemetryScope implements Disposable { * 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, @@ -48,7 +48,7 @@ export abstract class OpenTelemetryScope implements Disposable { parentContext?: ParentContext, startTime?: TimeInput, endTime?: TimeInput, - callerDetails?: CallerDetails + userDetails?: UserDetails ) { // Determine the context to use for span creation let currentContext = context.active(); @@ -101,11 +101,11 @@ export abstract class OpenTelemetryScope implements Disposable { } // Set caller details if provided - if (callerDetails) { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_ID_KEY, callerDetails.callerId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_UPN_KEY, callerDetails.callerUpn); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_NAME_KEY, callerDetails.callerName); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, callerDetails.callerClientIp); + if (userDetails) { + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_ID_KEY, userDetails.callerId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_UPN_KEY, userDetails.callerUpn); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_NAME_KEY, userDetails.callerName); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, userDetails.callerClientIp); } } diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index bbb419b9..f092d7e2 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts @@ -3,7 +3,7 @@ import { SpanKind } from '@opentelemetry/api'; import { OpenTelemetryScope } from './OpenTelemetryScope'; -import { AgentDetails, CallerDetails, OutputResponse, OutputRequest, SpanDetails } from '../contracts'; +import { AgentDetails, UserDetails, OutputResponse, Request, SpanDetails } from '../contracts'; import { OpenTelemetryConstants } from '../constants'; /** @@ -19,25 +19,25 @@ export class OutputScope extends OpenTelemetryScope { * @param request Optional request context (conversationId, channel). * @param response The response containing initial output messages. * @param agentDetails The agent producing the output. Tenant ID is derived from `agentDetails.tenantId`. - * @param callerDetails Optional caller identity details. + * @param userDetails Optional human caller identity details. * @param spanDetails Optional span configuration (parentContext, startTime, endTime). * @returns A new OutputScope instance. */ public static start( - request: OutputRequest | undefined, + request: Request | undefined, response: OutputResponse, agentDetails: AgentDetails, - callerDetails?: CallerDetails, + userDetails?: UserDetails, spanDetails?: SpanDetails ): OutputScope { - return new OutputScope(request, response, agentDetails, callerDetails, spanDetails); + return new OutputScope(request, response, agentDetails, userDetails, spanDetails); } private constructor( - request: OutputRequest | undefined, + request: Request | undefined, response: OutputResponse, agentDetails: AgentDetails, - callerDetails?: CallerDetails, + userDetails?: UserDetails, spanDetails?: SpanDetails ) { // Derive tenant details from agentDetails.tenantId (required for telemetry) @@ -57,7 +57,7 @@ export class OutputScope extends OpenTelemetryScope { spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, - callerDetails + userDetails ); // Initialize accumulated messages list from the response diff --git a/tests/observability/core/parent-span-ref.test.ts b/tests/observability/core/parent-span-ref.test.ts index 2ebd932e..25b02155 100644 --- a/tests/observability/core/parent-span-ref.test.ts +++ b/tests/observability/core/parent-span-ref.test.ts @@ -101,7 +101,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { tenantId: 'test-tenant-456' } }; - return InvokeAgentScope.start(undefined, invokeAgentDetails, undefined, { parentContext: parentRef }); + return InvokeAgentScope.start({}, invokeAgentDetails, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('invokeagent') || name.toLowerCase().includes('invoke_agent'), ], @@ -178,7 +178,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { }; const nestedScope = InvokeAgentScope.start( - undefined, + {}, invokeAgentDetails ); @@ -207,7 +207,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); const spanContext = scope.getSpanContext(); expect(spanContext).toBeDefined(); @@ -258,7 +258,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { details: { agentId: 'sampled-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); scope.dispose(); }); @@ -287,7 +287,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { details: { agentId: 'unsampled-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); scope.dispose(); }); @@ -316,7 +316,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { details: { agentId: 'default-sampled-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); scope.dispose(); }); @@ -351,7 +351,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { details: { agentId: 'inherited-flags-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); scope.dispose(); }); }); diff --git a/tests/observability/core/scopes.test.ts b/tests/observability/core/scopes.test.ts index d5945b1f..be415ce5 100644 --- a/tests/observability/core/scopes.test.ts +++ b/tests/observability/core/scopes.test.ts @@ -10,7 +10,7 @@ import { ToolCallDetails, InferenceDetails, InferenceOperationType, - CallerDetails, + UserDetails, OpenTelemetryConstants, OpenTelemetryScope, } from '@microsoft/agents-a365-observability'; @@ -49,7 +49,7 @@ describe('Scopes', () => { } }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -60,7 +60,7 @@ describe('Scopes', () => { details: { agentId: 'simple-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -78,7 +78,7 @@ describe('Scopes', () => { } }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -94,7 +94,7 @@ describe('Scopes', () => { } }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -109,14 +109,14 @@ describe('Scopes', () => { } }; - const callerDetails: CallerDetails = { + const callerDetails: UserDetails = { callerId: 'user-123', callerName: 'Test User', callerUpn: 'test.user@contoso.com', tenantId: 'test-tenant' }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails, { callerDetails }); + const scope = InvokeAgentScope.start({}, invokeAgentDetails, { userDetails: callerDetails }); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -124,7 +124,7 @@ describe('Scopes', () => { it('should record response', () => { const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); expect(() => scope?.recordResponse('Test response')).not.toThrow(); scope?.dispose(); @@ -132,7 +132,7 @@ describe('Scopes', () => { it('should record input and output messages', () => { const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); expect(() => scope?.recordInputMessages(['Input message 1', 'Input message 2'])).not.toThrow(); expect(() => scope?.recordOutputMessages(['Output message 1', 'Output message 2'])).not.toThrow(); @@ -141,7 +141,7 @@ describe('Scopes', () => { it('should record error', () => { const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); const error = new Error('Test error'); expect(() => scope?.recordError(error)).not.toThrow(); @@ -165,7 +165,7 @@ describe('Scopes', () => { it('should fall back to agent.conversationId when conversationId param is omitted', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( - undefined, + {}, { details: { agentId: 'test-agent', conversationId: 'from-details', tenantId: 'test-tenant-456' } } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -202,7 +202,7 @@ describe('Scopes', () => { } }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -231,7 +231,7 @@ describe('Scopes', () => { platformId: 'caller-platform-xyz' } as any; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails, { callerAgentDetails }); + const scope = InvokeAgentScope.start({}, invokeAgentDetails, { callerAgentDetails }); expect(scope).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -252,7 +252,7 @@ describe('Scopes', () => { tenantId: 'test-tenant-456' } }; - const callerDetails: CallerDetails = { + const callerDetails: UserDetails = { callerId: 'user-123', tenantId: 'test-tenant', callerClientIp: '10.0.0.5' @@ -265,9 +265,9 @@ describe('Scopes', () => { agentClientIP: '192.168.1.100' } as any; - const scope1 = InvokeAgentScope.start(undefined, invokeAgentDetails, { callerDetails }); + const scope1 = InvokeAgentScope.start({}, invokeAgentDetails, { userDetails: callerDetails }); expect(scope1).toBeInstanceOf(InvokeAgentScope); - const scope2 = InvokeAgentScope.start(undefined, invokeAgentDetails, { callerAgentDetails }); + const scope2 = InvokeAgentScope.start({}, invokeAgentDetails, { callerAgentDetails }); expect(scope2).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -284,7 +284,7 @@ describe('Scopes', () => { describe('ExecuteToolScope', () => { it('should create scope with tool details', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const callerDetails: CallerDetails = { + const callerDetails: UserDetails = { callerId: 'caller-tool-1', callerUpn: 'tool.user@contoso.com', callerName: 'Tool User', @@ -406,7 +406,7 @@ 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( - undefined, + {}, { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' }, endpoint: { host: 'agent.example.com', port: 9090 } } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -420,7 +420,7 @@ describe('Scopes', () => { it('should omit port 443 on InvokeAgentScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( - undefined, + {}, { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' }, endpoint: { host: 'agent.example.com', port: 443 } } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -435,7 +435,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 = { callerId: 'caller-inf-1', callerUpn: 'inf.user@contoso.com', callerName: 'Inf User', @@ -525,7 +525,7 @@ describe('Scopes', () => { describe('Dispose pattern', () => { it('should support manual dispose', () => { const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + const scope = InvokeAgentScope.start({}, invokeAgentDetails); scope?.recordResponse('Manual dispose test'); expect(() => scope?.dispose()).not.toThrow(); @@ -667,7 +667,7 @@ describe('Scopes', () => { ['SERVER', SpanKind.SERVER, SpanKind.SERVER], ])('InvokeAgentScope spanKind: %s', (_label, input, expected) => { const scope = InvokeAgentScope.start( - undefined, + {}, { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }, undefined, input !== undefined ? { spanKind: input } : undefined diff --git a/tests/observability/core/trace-context-propagation.test.ts b/tests/observability/core/trace-context-propagation.test.ts index ab3a5619..0d83265d 100644 --- a/tests/observability/core/trace-context-propagation.test.ts +++ b/tests/observability/core/trace-context-propagation.test.ts @@ -139,7 +139,7 @@ describe('Trace Context Propagation', () => { const extractedCtx = extractContextFromHeaders({ traceparent: `00-${traceId}-${spanId}-01` }); const scope = InvokeAgentScope.start( - undefined, + {}, { details: { agentId: 'ctx-agent', tenantId: 'test-tenant' } }, undefined, { parentContext: extractedCtx } @@ -164,7 +164,7 @@ describe('Trace Context Propagation', () => { }; const scope = InvokeAgentScope.start( - undefined, + {}, { details: { agentId: 'remote-agent', tenantId: 'test-tenant' } }, undefined, { parentContext: parentRef } diff --git a/tests/observability/extension/hosting/scope-utils.test.ts b/tests/observability/extension/hosting/scope-utils.test.ts index 61a9b8d3..e4695d4f 100644 --- a/tests/observability/extension/hosting/scope-utils.test.ts +++ b/tests/observability/extension/hosting/scope-utils.test.ts @@ -236,7 +236,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({ callerId: 'uid', From fe814318a13255e1a772f59690266ac019983302 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 20 Mar 2026 13:23:26 -0700 Subject: [PATCH 05/16] docs: update CHANGELOG and design doc for type renames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG: fix all stale type references (InvokeAgentCallerDetails → CallerDetails, InferenceRequest/ToolRequest/OutputRequest → Request, callerDetails → userDetails, AgentRequest → Request) - design.md: update code examples to use new scope signatures - design.md: fix InvokeAgentDetails to composition model - ScopeUtils: fix stale comment on request building Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 23 ++++----- .../src/utils/ScopeUtils.ts | 2 +- .../agents-a365-observability/docs/design.md | 49 ++++++++----------- 3 files changed, 32 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b5ab99..5b089be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,17 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes (`@microsoft/agents-a365-observability`) - **`InvokeAgentDetails` — inheritance → composition.** No longer extends `AgentDetails`. Agent identity is accessed via `invokeAgentDetails.details.agentId`. Removed `request`, `providerName`, `agentBlueprintId` fields. -- **`InvokeAgentScope.start()` — new signature.** `start(request?, invokeAgentDetails, callerInfo?, spanDetails?)`. Tenant ID is derived from `invokeAgentDetails.details.tenantId` (required). `callerDetails` and `callerAgentDetails` are wrapped in `InvokeAgentCallerDetails`. Span options (`parentContext`, `startTime`, `endTime`, `spanKind`) are grouped in `SpanDetails`. -- **`InferenceScope.start()` — new signature.** `start(request?, details, agentDetails, callerDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `channel` and `conversationId` moved into `InferenceRequest`. -- **`ExecuteToolScope.start()` — new signature.** `start(request?, details, agentDetails, callerDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `channel` and `conversationId` moved into `ToolRequest`. -- **`OutputScope.start()` — new signature.** `start(request?, response, agentDetails, callerDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). +- **`InvokeAgentScope.start()` — new signature.** `start(request, invokeAgentDetails, callerInfo?, spanDetails?)`. `request` is required. Tenant ID is derived from `invokeAgentDetails.details.tenantId` (required). `userDetails` and `callerAgentDetails` are wrapped in `CallerDetails`. Span options (`parentContext`, `startTime`, `endTime`, `spanKind`) are grouped in `SpanDetails`. +- **`InferenceScope.start()` — new signature.** `start(request?, details, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `channel` and `conversationId` moved into `Request`. +- **`ExecuteToolScope.start()` — new signature.** `start(request?, details, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `channel` and `conversationId` moved into `Request`. +- **`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()`**. -- **`OpenTelemetryScope.setStartTime()` removed.** Use `SpanDetails.startTime` on scope construction instead. -- **`AgentRequest.conversationId`** — New optional field for passing conversation ID via the request object. -- **`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. - **`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. @@ -28,11 +27,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added (`@microsoft/agents-a365-observability`) - **`SpanDetails`** — New interface grouping `parentContext`, `startTime`, `endTime`, `spanKind` for scope construction. -- **`InvokeAgentCallerDetails`** — New interface wrapping `callerDetails` and `callerAgentDetails` for `InvokeAgentScope`. -- **`InferenceRequest`** — New interface for inference scope request context (`conversationId`, `channel`). -- **`ToolRequest`** — New interface for tool scope request context (`conversationId`, `channel`). -- **`OutputRequest`** — New interface for output scope request context (`conversationId`, `channel`). -- **`OpenTelemetryScope.recordCancellation()`** — Records a cancellation event on the span with `error.type = 'TaskCanceledException'` (aligned with Python SDK). +- **`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. diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index 47f7eca9..ac660589 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -205,7 +205,7 @@ export class ScopeUtils { // Merge agent identity from TurnContext into details.details const invokeAgentDetails = ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); - // Build the request only when there is concrete channel or conversationId info + // Build the request with channel and conversationId from context const hasChannel = channel.name !== undefined || channel.description !== undefined; const request: Request = { conversationId, diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index 7a9c48ba..79db2d57 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -129,23 +129,20 @@ abstract class OpenTelemetryScope implements Disposable { Traces agent invocation operations: ```typescript -import { InvokeAgentScope, InvokeAgentDetails, TenantDetails } from '@microsoft/agents-a365-observability'; +import { InvokeAgentScope, InvokeAgentDetails, 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' } }; +const invokeAgentDetails: InvokeAgentDetails = { + details: { agentId: 'agent-123', agentName: 'MyAgent', tenantId: 'tenant-789' }, + endpoint: { host: 'api.example.com', port: 443 }, + sessionId: 'session-456' +}; +const callerInfo: CallerDetails = { + userDetails: { callerId: 'user-1', callerName: 'User' }, + callerAgentDetails: callerAgent // Optional, for A2A scenarios +}; + +using scope = InvokeAgentScope.start(request, invokeAgentDetails, callerInfo); scope.recordInputMessages(['Hello']); // ... agent processing ... @@ -156,9 +153,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 +166,13 @@ Traces LLM/AI model inference calls: import { InferenceScope, InferenceDetails, InferenceOperationType } from '@microsoft/agents-a365-observability'; using scope = InferenceScope.start( + { conversationId: 'conv-123' }, // Request (optional) { operationName: InferenceOperationType.CHAT, model: 'gpt-4', providerName: 'openai' }, - agentDetails, - tenantDetails, - conversationId, - channel + agentDetails // Must include tenantId ); scope.recordInputMessages(['User message']); @@ -197,6 +192,7 @@ Traces tool execution operations: import { ExecuteToolScope, ToolCallDetails } from '@microsoft/agents-a365-observability'; using scope = ExecuteToolScope.start( + { conversationId: 'conv-123' }, // Request (optional) { toolName: 'search', arguments: JSON.stringify({ query: 'weather' }), @@ -204,10 +200,7 @@ using scope = ExecuteToolScope.start( toolType: 'mcp', endpoint: { host: 'tools.example.com' } }, - agentDetails, - tenantDetails, - conversationId, - channel + agentDetails // Must include tenantId ); // ... tool execution ... @@ -267,8 +260,8 @@ const scope2 = BaggageBuilder.setRequestContext( ### InvokeAgentDetails ```typescript -interface InvokeAgentDetails extends AgentDetails { - request?: AgentRequest; +interface InvokeAgentDetails { + details: AgentDetails; endpoint?: ServiceEndpoint; sessionId?: string; } From 3b9bb823e4a183872d151ee34bd1661fa27dec17 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 20 Mar 2026 13:59:58 -0700 Subject: [PATCH 06/16] =?UTF-8?q?refactor:=20rename=20callerInfo=20?= =?UTF-8?q?=E2=86=92=20callerDetails=20on=20InvokeAgentScope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 +- .../src/utils/ScopeUtils.ts | 4 ++-- .../src/tracing/scopes/InvokeAgentScope.ts | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b089be3..f285b8ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes (`@microsoft/agents-a365-observability`) - **`InvokeAgentDetails` — inheritance → composition.** No longer extends `AgentDetails`. Agent identity is accessed via `invokeAgentDetails.details.agentId`. Removed `request`, `providerName`, `agentBlueprintId` fields. -- **`InvokeAgentScope.start()` — new signature.** `start(request, invokeAgentDetails, callerInfo?, spanDetails?)`. `request` is required. Tenant ID is derived from `invokeAgentDetails.details.tenantId` (required). `userDetails` and `callerAgentDetails` are wrapped in `CallerDetails`. Span options (`parentContext`, `startTime`, `endTime`, `spanKind`) are grouped in `SpanDetails`. +- **`InvokeAgentScope.start()` — new signature.** `start(request, invokeAgentDetails, callerDetails?, spanDetails?)`. `request` is required. Tenant ID is derived from `invokeAgentDetails.details.tenantId` (required). `userDetails` and `callerAgentDetails` are wrapped in `CallerDetails`. Span options (`parentContext`, `startTime`, `endTime`, `spanKind`) are grouped in `SpanDetails`. - **`InferenceScope.start()` — new signature.** `start(request?, details, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `channel` and `conversationId` moved into `Request`. - **`ExecuteToolScope.start()` — new signature.** `start(request?, details, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `channel` and `conversationId` moved into `Request`. - **`OutputScope.start()` — new signature.** `start(request?, response, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index ac660589..63c0b6e6 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -213,7 +213,7 @@ export class ScopeUtils { }; // Build caller info with both human caller and caller agent details - const callerInfo: CallerDetails = { + const callerDetails: CallerDetails = { userDetails: caller, callerAgentDetails: callerAgent, }; @@ -222,7 +222,7 @@ export class ScopeUtils { ? { startTime, endTime, spanKind } : undefined; - const scope = InvokeAgentScope.start(request, invokeAgentDetails, callerInfo, spanDetailsObj); + const scope = InvokeAgentScope.start(request, invokeAgentDetails, callerDetails, spanDetailsObj); this.setInputMessageTags(scope, turnContext); return scope; } diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts index 96b22267..1c244e07 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -21,7 +21,7 @@ export class InvokeAgentScope extends OpenTelemetryScope { * @param request Request payload (channel, conversationId, content, sessionId). * @param invokeAgentDetails The details of the agent invocation (agent identity via `.details`, endpoint). * Tenant ID is derived from `invokeAgentDetails.details.tenantId`. - * @param callerInfo Optional caller information. Supports three scenarios: + * @param callerDetails Optional caller information. Supports three scenarios: * - Human caller only: `{ userDetails: { callerId, callerName, ... } }` * - Agent caller only: `{ callerAgentDetails: { agentId, agentName, ... } }` * - Both (A2A with human in chain): `{ userDetails: { ... }, callerAgentDetails: { ... } }` @@ -31,16 +31,16 @@ export class InvokeAgentScope extends OpenTelemetryScope { public static start( request: Request, invokeAgentDetails: InvokeAgentDetails, - callerInfo?: CallerDetails, + callerDetails?: CallerDetails, spanDetails?: SpanDetails ): InvokeAgentScope { - return new InvokeAgentScope(request, invokeAgentDetails, callerInfo, spanDetails); + return new InvokeAgentScope(request, invokeAgentDetails, callerDetails, spanDetails); } private constructor( request: Request, invokeAgentDetails: InvokeAgentDetails, - callerInfo?: CallerDetails, + callerDetails?: CallerDetails, spanDetails?: SpanDetails ) { const agent = invokeAgentDetails.details; @@ -62,7 +62,7 @@ export class InvokeAgentScope extends OpenTelemetryScope { spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, - callerInfo?.userDetails + callerDetails?.userDetails ); // Set provider name from agent details @@ -92,7 +92,7 @@ export class InvokeAgentScope extends OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request?.conversationId ?? agent.conversationId); // Set caller agent details tags for A2A scenarios - const callerAgent = callerInfo?.callerAgentDetails; + 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); From 8a03a7b8c7534930002e8a23bc19d946d9d9e1cb Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 20 Mar 2026 14:19:36 -0700 Subject: [PATCH 07/16] refactor: make request required on all scopes, rename callerInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make request: Request required (not | undefined) on InferenceScope, ExecuteToolScope, and OutputScope to match InvokeAgentScope - Rename callerInfo → callerDetails on InvokeAgentScope - Fix JSDoc example in trace-context-propagation.ts - Update ScopeUtils to always build Request objects - Update all tests to pass {} instead of undefined for request Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/middleware/OutputLoggingMiddleware.ts | 2 +- .../src/utils/ScopeUtils.ts | 20 ++++++++----------- .../context/trace-context-propagation.ts | 2 +- .../src/tracing/scopes/ExecuteToolScope.ts | 4 ++-- .../src/tracing/scopes/InferenceScope.ts | 4 ++-- .../src/tracing/scopes/OutputScope.ts | 4 ++-- tests/observability/core/output-scope.test.ts | 4 ++-- .../core/parent-span-ref.test.ts | 6 +++--- tests/observability/core/scopes.test.ts | 14 ++++++------- 9 files changed, 28 insertions(+), 32 deletions(-) diff --git a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts index 0c0b723b..96df2dc1 100644 --- a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts +++ b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts @@ -110,7 +110,7 @@ export class OutputLoggingMiddleware implements Middleware { : undefined; const outputScope = OutputScope.start( - request, + request ?? {}, { messages }, agentDetails, userDetails, diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index 63c0b6e6..950a8a5f 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -159,12 +159,10 @@ export class ScopeUtils { } const hasChannel = channel.name !== undefined || channel.description !== undefined; - const request: Request | undefined = (conversationId || hasChannel) - ? { - conversationId, - ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), - } - : undefined; + const request: Request = { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + }; const spanDetails: SpanDetails | undefined = (startTime || endTime) ? { startTime, endTime } @@ -285,12 +283,10 @@ export class ScopeUtils { } const hasChannel = channel.name !== undefined || channel.description !== undefined; - const request: Request | undefined = (conversationId || hasChannel) - ? { - conversationId, - ...(hasChannel ? { channel: { name: channel.name, description: 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 } 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 cc989bb5..f2bcb3a3 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 @@ -98,7 +98,7 @@ export function extractContextFromHeaders( * @example * ```typescript * runWithExtractedTraceContext(req.headers, () => { - * const scope = InvokeAgentScope.start(undefined, invokeAgentDetails); + * const scope = InvokeAgentScope.start({}, invokeAgentDetails); * scope.dispose(); * }); * ``` diff --git a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts index 5bdfc1c1..c44e3a7a 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts @@ -27,7 +27,7 @@ export class ExecuteToolScope extends OpenTelemetryScope { * @returns A new ExecuteToolScope instance. */ public static start( - request: Request | undefined, + request: Request, details: ToolCallDetails, agentDetails: AgentDetails, userDetails?: UserDetails, @@ -37,7 +37,7 @@ export class ExecuteToolScope extends OpenTelemetryScope { } private constructor( - request: Request | undefined, + request: Request, details: ToolCallDetails, agentDetails: AgentDetails, userDetails?: UserDetails, diff --git a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts index 64d0d2a9..fc20e0a3 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts @@ -27,7 +27,7 @@ export class InferenceScope extends OpenTelemetryScope { * @returns A new InferenceScope instance */ public static start( - request: Request | undefined, + request: Request, details: InferenceDetails, agentDetails: AgentDetails, userDetails?: UserDetails, @@ -37,7 +37,7 @@ export class InferenceScope extends OpenTelemetryScope { } private constructor( - request: Request | undefined, + request: Request, details: InferenceDetails, agentDetails: AgentDetails, userDetails?: UserDetails, diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index f092d7e2..d6c99c37 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts @@ -24,7 +24,7 @@ export class OutputScope extends OpenTelemetryScope { * @returns A new OutputScope instance. */ public static start( - request: Request | undefined, + request: Request, response: OutputResponse, agentDetails: AgentDetails, userDetails?: UserDetails, @@ -34,7 +34,7 @@ export class OutputScope extends OpenTelemetryScope { } private constructor( - request: Request | undefined, + request: Request, response: OutputResponse, agentDetails: AgentDetails, userDetails?: UserDetails, diff --git a/tests/observability/core/output-scope.test.ts b/tests/observability/core/output-scope.test.ts index 608b20e8..d8693cc1 100644 --- a/tests/observability/core/output-scope.test.ts +++ b/tests/observability/core/output-scope.test.ts @@ -71,7 +71,7 @@ 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(undefined, response, testAgentDetails); + const scope = OutputScope.start({}, response, testAgentDetails); expect(scope).toBeInstanceOf(OutputScope); scope.dispose(); @@ -89,7 +89,7 @@ describe('OutputScope', () => { it('should append messages with recordOutputMessages and flush on dispose', async () => { const response: OutputResponse = { messages: ['Initial'] }; - const scope = OutputScope.start(undefined, response, testAgentDetails); + const scope = OutputScope.start({}, response, testAgentDetails); scope.recordOutputMessages(['Appended 1']); scope.recordOutputMessages(['Appended 2', 'Appended 3']); scope.dispose(); diff --git a/tests/observability/core/parent-span-ref.test.ts b/tests/observability/core/parent-span-ref.test.ts index 25b02155..f650134b 100644 --- a/tests/observability/core/parent-span-ref.test.ts +++ b/tests/observability/core/parent-span-ref.test.ts @@ -113,7 +113,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { model: 'gpt-4', providerName: 'openai', }; - return InferenceScope.start(undefined, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }); + return InferenceScope.start({}, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('chat'), ], @@ -124,7 +124,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { toolName: 'test-tool', arguments: '{"param": "value"}', }; - return ExecuteToolScope.start(undefined, toolDetails, testAgentDetails, undefined, { parentContext: parentRef }); + return ExecuteToolScope.start({}, toolDetails, testAgentDetails, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('execute_tool'), ], @@ -225,7 +225,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(undefined, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }) + InferenceScope.start({}, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }) ); expect(childScope.getSpanContext().traceId).toBe(spanContext.traceId); diff --git a/tests/observability/core/scopes.test.ts b/tests/observability/core/scopes.test.ts index be415ce5..d0513fad 100644 --- a/tests/observability/core/scopes.test.ts +++ b/tests/observability/core/scopes.test.ts @@ -291,7 +291,7 @@ describe('Scopes', () => { tenantId: 'tool-tenant', callerClientIp: '10.0.0.10' }; - const scope = ExecuteToolScope.start(undefined, { + const scope = ExecuteToolScope.start({}, { toolName: 'test-tool', arguments: '{"param": "value"}', toolCallId: 'call-123', @@ -316,7 +316,7 @@ describe('Scopes', () => { }); it('should record response', () => { - const scope = ExecuteToolScope.start(undefined, { toolName: 'test-tool' }, testAgentDetails); + const scope = ExecuteToolScope.start({}, { toolName: 'test-tool' }, testAgentDetails); expect(() => scope?.recordResponse('Tool result')).not.toThrow(); scope?.dispose(); @@ -451,7 +451,7 @@ describe('Scopes', () => { finishReasons: ['stop'] }; - const scope = InferenceScope.start(undefined, inferenceDetails, testAgentDetails, callerDetails); + const scope = InferenceScope.start({}, inferenceDetails, testAgentDetails, callerDetails); expect(scope).toBeInstanceOf(InferenceScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -475,7 +475,7 @@ describe('Scopes', () => { model: 'gpt-3.5-turbo' }; - const scope = InferenceScope.start(undefined, inferenceDetails, testAgentDetails); + const scope = InferenceScope.start({}, inferenceDetails, testAgentDetails); expect(scope).toBeInstanceOf(InferenceScope); scope?.dispose(); @@ -487,7 +487,7 @@ describe('Scopes', () => { model: 'gpt-4' }; - const scope = InferenceScope.start(undefined, inferenceDetails, testAgentDetails); + const scope = InferenceScope.start({}, inferenceDetails, testAgentDetails); expect(() => scope?.recordInputMessages(['Input message'])).not.toThrow(); expect(() => scope?.recordOutputMessages(['Generated response'])).not.toThrow(); @@ -535,7 +535,7 @@ describe('Scopes', () => { const toolDetails: ToolCallDetails = { toolName: 'test-tool' }; expect(() => { - const scope = ExecuteToolScope.start(undefined, toolDetails, testAgentDetails); + const scope = ExecuteToolScope.start({}, toolDetails, testAgentDetails); try { scope?.recordResponse('Automatic disposal test'); } finally { @@ -650,7 +650,7 @@ describe('Scopes', () => { it('should use wall-clock time when no custom times are provided', () => { const before = Date.now(); - const scope = ExecuteToolScope.start(undefined, { toolName: 'my-tool' }, testAgentDetails); + const scope = ExecuteToolScope.start({}, { toolName: 'my-tool' }, testAgentDetails); scope.dispose(); const after = Date.now(); From c9b838eead8ffc4445020f2acae56d15a50491a3 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 20 Mar 2026 15:26:52 -0700 Subject: [PATCH 08/16] refactor: make request optional on Inference/ExecuteTool/OutputScope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move request param after required params so it can be truly optional (details, agentDetails, request?, userDetails?, spanDetails?) - Revert sessionId fallback — read only from invokeAgentDetails to match .NET/Python SDKs - Update CHANGELOG and design docs Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 6 +-- .../src/middleware/OutputLoggingMiddleware.ts | 2 +- .../src/utils/ScopeUtils.ts | 4 +- .../agents-a365-observability/docs/design.md | 5 +- .../src/tracing/scopes/ExecuteToolScope.ts | 8 +-- .../src/tracing/scopes/InferenceScope.ts | 8 +-- .../src/tracing/scopes/OutputScope.ts | 8 +-- tests/observability/core/output-scope.test.ts | 8 +-- .../core/parent-span-ref.test.ts | 6 +-- tests/observability/core/scopes.test.ts | 52 +++++++++---------- 10 files changed, 51 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f285b8ca..2131110f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`InvokeAgentDetails` — inheritance → composition.** No longer extends `AgentDetails`. Agent identity is accessed via `invokeAgentDetails.details.agentId`. Removed `request`, `providerName`, `agentBlueprintId` fields. - **`InvokeAgentScope.start()` — new signature.** `start(request, invokeAgentDetails, callerDetails?, spanDetails?)`. `request` is required. Tenant ID is derived from `invokeAgentDetails.details.tenantId` (required). `userDetails` and `callerAgentDetails` are wrapped in `CallerDetails`. Span options (`parentContext`, `startTime`, `endTime`, `spanKind`) are grouped in `SpanDetails`. -- **`InferenceScope.start()` — new signature.** `start(request?, details, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `channel` and `conversationId` moved into `Request`. -- **`ExecuteToolScope.start()` — new signature.** `start(request?, details, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `channel` and `conversationId` moved into `Request`. -- **`OutputScope.start()` — new signature.** `start(request?, response, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). +- **`InferenceScope.start()` — new signature.** `start(details, agentDetails, request?, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `request` is optional. +- **`ExecuteToolScope.start()` — new signature.** `start(details, agentDetails, request?, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `request` is optional. +- **`OutputScope.start()` — new signature.** `start(response, agentDetails, request?, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `request` is optional. - **`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. diff --git a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts index 96df2dc1..36e5201e 100644 --- a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts +++ b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts @@ -110,9 +110,9 @@ export class OutputLoggingMiddleware implements Middleware { : undefined; const outputScope = OutputScope.start( - request ?? {}, { messages }, agentDetails, + request, userDetails, spanDetails, ); diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index 950a8a5f..6406ac9c 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -168,7 +168,7 @@ export class ScopeUtils { ? { startTime, endTime } : undefined; - const scope = InferenceScope.start(request, details, agent, caller, spanDetails); + const scope = InferenceScope.start(details, agent, request, caller, spanDetails); this.setInputMessageTags(scope, turnContext); return scope; } @@ -292,7 +292,7 @@ export class ScopeUtils { ? { startTime, endTime, spanKind } : undefined; - const scope = ExecuteToolScope.start(request, details, agent, caller, spanDetailsObj); + const scope = ExecuteToolScope.start(details, agent, request, caller, spanDetailsObj); return scope; } diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index 79db2d57..6cf20060 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -166,13 +166,13 @@ Traces LLM/AI model inference calls: import { InferenceScope, InferenceDetails, InferenceOperationType } from '@microsoft/agents-a365-observability'; using scope = InferenceScope.start( - { conversationId: 'conv-123' }, // Request (optional) { operationName: InferenceOperationType.CHAT, model: 'gpt-4', providerName: 'openai' }, - agentDetails // Must include tenantId + agentDetails, // Must include tenantId + { conversationId: 'conv-123' } // Request (optional) ); scope.recordInputMessages(['User message']); @@ -192,7 +192,6 @@ Traces tool execution operations: import { ExecuteToolScope, ToolCallDetails } from '@microsoft/agents-a365-observability'; using scope = ExecuteToolScope.start( - { conversationId: 'conv-123' }, // Request (optional) { toolName: 'search', arguments: JSON.stringify({ query: 'weather' }), diff --git a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts index c44e3a7a..f8d0aab7 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts @@ -19,27 +19,27 @@ export class ExecuteToolScope extends OpenTelemetryScope { /** * Creates and starts a new scope for tool execution tracing. * - * @param request Optional request context (conversationId, channel). * @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 request Optional request context (conversationId, channel). * @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, + request?: Request, userDetails?: UserDetails, spanDetails?: SpanDetails ): ExecuteToolScope { - return new ExecuteToolScope(request, details, agentDetails, userDetails, spanDetails); + return new ExecuteToolScope(details, agentDetails, request, userDetails, spanDetails); } private constructor( - request: Request, details: ToolCallDetails, agentDetails: AgentDetails, + request?: Request, userDetails?: UserDetails, spanDetails?: SpanDetails ) { diff --git a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts index fc20e0a3..b391c479 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts @@ -19,27 +19,27 @@ export class InferenceScope extends OpenTelemetryScope { /** * Creates and starts a new scope for inference tracing. * - * @param request Optional request context (conversationId, channel). * @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 request Optional request context (conversationId, channel). * @param userDetails Optional human caller identity. * @param spanDetails Optional span configuration (parentContext, startTime, endTime). * @returns A new InferenceScope instance */ public static start( - request: Request, details: InferenceDetails, agentDetails: AgentDetails, + request?: Request, userDetails?: UserDetails, spanDetails?: SpanDetails ): InferenceScope { - return new InferenceScope(request, details, agentDetails, userDetails, spanDetails); + return new InferenceScope(details, agentDetails, request, userDetails, spanDetails); } private constructor( - request: Request, details: InferenceDetails, agentDetails: AgentDetails, + request?: Request, userDetails?: UserDetails, spanDetails?: SpanDetails ) { diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index d6c99c37..0d664e22 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts @@ -16,27 +16,27 @@ export class OutputScope extends OpenTelemetryScope { /** * Creates and starts a new scope for output message tracing. * - * @param request Optional request context (conversationId, channel). * @param response The response containing initial output messages. * @param agentDetails The agent producing the output. Tenant ID is derived from `agentDetails.tenantId`. + * @param request Optional request context (conversationId, channel). * @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, + request?: Request, userDetails?: UserDetails, spanDetails?: SpanDetails ): OutputScope { - return new OutputScope(request, response, agentDetails, userDetails, spanDetails); + return new OutputScope(response, agentDetails, request, userDetails, spanDetails); } private constructor( - request: Request, response: OutputResponse, agentDetails: AgentDetails, + request?: Request, userDetails?: UserDetails, spanDetails?: SpanDetails ) { diff --git a/tests/observability/core/output-scope.test.ts b/tests/observability/core/output-scope.test.ts index d8693cc1..ff7bdd92 100644 --- a/tests/observability/core/output-scope.test.ts +++ b/tests/observability/core/output-scope.test.ts @@ -71,7 +71,7 @@ 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); + const scope = OutputScope.start(response, testAgentDetails); expect(scope).toBeInstanceOf(OutputScope); scope.dispose(); @@ -89,7 +89,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); + const scope = OutputScope.start(response, testAgentDetails); scope.recordOutputMessages(['Appended 1']); scope.recordOutputMessages(['Appended 2', 'Appended 3']); scope.dispose(); @@ -106,8 +106,8 @@ describe('OutputScope', () => { const parentSpanId = 'abcdefabcdef1234'; const scope = OutputScope.start( - undefined, { messages: ['Test'] }, testAgentDetails, - undefined, { parentContext: { traceId: parentTraceId, spanId: parentSpanId } as ParentSpanRef } + { messages: ['Test'] }, testAgentDetails, + undefined, undefined, { parentContext: { traceId: parentTraceId, spanId: parentSpanId } as ParentSpanRef } ); scope.dispose(); diff --git a/tests/observability/core/parent-span-ref.test.ts b/tests/observability/core/parent-span-ref.test.ts index f650134b..db3b5365 100644 --- a/tests/observability/core/parent-span-ref.test.ts +++ b/tests/observability/core/parent-span-ref.test.ts @@ -113,7 +113,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { model: 'gpt-4', providerName: 'openai', }; - return InferenceScope.start({}, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }); + return InferenceScope.start(inferenceDetails, testAgentDetails, undefined, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('chat'), ], @@ -124,7 +124,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { toolName: 'test-tool', arguments: '{"param": "value"}', }; - return ExecuteToolScope.start({}, toolDetails, testAgentDetails, undefined, { parentContext: parentRef }); + return ExecuteToolScope.start(toolDetails, testAgentDetails, undefined, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('execute_tool'), ], @@ -225,7 +225,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, undefined, { parentContext: parentRef }) + InferenceScope.start(inferenceDetails, testAgentDetails, undefined, undefined, { parentContext: parentRef }) ); expect(childScope.getSpanContext().traceId).toBe(spanContext.traceId); diff --git a/tests/observability/core/scopes.test.ts b/tests/observability/core/scopes.test.ts index d0513fad..3a5ffbec 100644 --- a/tests/observability/core/scopes.test.ts +++ b/tests/observability/core/scopes.test.ts @@ -291,13 +291,13 @@ describe('Scopes', () => { tenantId: 'tool-tenant', callerClientIp: '10.0.0.10' }; - const scope = ExecuteToolScope.start({}, { + const scope = ExecuteToolScope.start({ toolName: 'test-tool', arguments: '{"param": "value"}', toolCallId: 'call-123', description: 'A test tool', toolType: 'test' - }, testAgentDetails, callerDetails); + }, testAgentDetails, undefined, callerDetails); expect(scope).toBeInstanceOf(ExecuteToolScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -316,7 +316,7 @@ describe('Scopes', () => { }); it('should record response', () => { - const scope = ExecuteToolScope.start({}, { toolName: 'test-tool' }, testAgentDetails); + const scope = ExecuteToolScope.start({ toolName: 'test-tool' }, testAgentDetails); expect(() => scope?.recordResponse('Tool result')).not.toThrow(); scope?.dispose(); @@ -325,8 +325,8 @@ describe('Scopes', () => { it('should set conversationId and channel tags when provided', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = ExecuteToolScope.start( - { conversationId: 'conv-tool-123', channel: { name: 'ChannelTool', description: 'https://channel/tool' } }, - { toolName: 'test-tool' }, testAgentDetails + { toolName: 'test-tool' }, testAgentDetails, + { conversationId: 'conv-tool-123', channel: { name: 'ChannelTool', description: 'https://channel/tool' } } ); expect(scope).toBeInstanceOf(ExecuteToolScope); @@ -346,7 +346,6 @@ 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( - undefined, { toolName: 'test-tool', endpoint: { host: 'tools.example.com', port: 8080 } }, testAgentDetails ); @@ -361,7 +360,6 @@ describe('Scopes', () => { it('should omit port 443 on ExecuteToolScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = ExecuteToolScope.start( - undefined, { toolName: 'test-tool', endpoint: { host: 'tools.example.com', port: 443 } }, testAgentDetails ); @@ -376,7 +374,6 @@ 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( - undefined, { operationName: InferenceOperationType.CHAT, model: 'gpt-4', endpoint: { host: 'api.openai.com', port: 8443 } }, testAgentDetails ); @@ -391,7 +388,6 @@ describe('Scopes', () => { it('should omit port 443 on InferenceScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InferenceScope.start( - undefined, { operationName: InferenceOperationType.CHAT, model: 'gpt-4', endpoint: { host: 'api.openai.com', port: 443 } }, testAgentDetails ); @@ -451,7 +447,7 @@ describe('Scopes', () => { finishReasons: ['stop'] }; - const scope = InferenceScope.start({}, inferenceDetails, testAgentDetails, callerDetails); + const scope = InferenceScope.start(inferenceDetails, testAgentDetails, undefined, callerDetails); expect(scope).toBeInstanceOf(InferenceScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -475,7 +471,7 @@ describe('Scopes', () => { model: 'gpt-3.5-turbo' }; - const scope = InferenceScope.start({}, inferenceDetails, testAgentDetails); + const scope = InferenceScope.start(inferenceDetails, testAgentDetails); expect(scope).toBeInstanceOf(InferenceScope); scope?.dispose(); @@ -487,7 +483,7 @@ describe('Scopes', () => { model: 'gpt-4' }; - const scope = InferenceScope.start({}, inferenceDetails, testAgentDetails); + const scope = InferenceScope.start(inferenceDetails, testAgentDetails); expect(() => scope?.recordInputMessages(['Input message'])).not.toThrow(); expect(() => scope?.recordOutputMessages(['Generated response'])).not.toThrow(); @@ -505,8 +501,8 @@ describe('Scopes', () => { }; const scope = InferenceScope.start( - { conversationId: 'conv-inf-123', channel: { name: 'ChannelInf', description: 'https://channel/inf' } }, - inferenceDetails, testAgentDetails + inferenceDetails, testAgentDetails, + { conversationId: 'conv-inf-123', channel: { name: 'ChannelInf', description: 'https://channel/inf' } } ); expect(scope).toBeInstanceOf(InferenceScope); @@ -535,7 +531,7 @@ describe('Scopes', () => { const toolDetails: ToolCallDetails = { toolName: 'test-tool' }; expect(() => { - const scope = ExecuteToolScope.start({}, toolDetails, testAgentDetails); + const scope = ExecuteToolScope.start(toolDetails, testAgentDetails); try { scope?.recordResponse('Automatic disposal test'); } finally { @@ -591,8 +587,8 @@ describe('Scopes', () => { const customEnd = 1700000005000; // 5 seconds later const scope = ExecuteToolScope.start( - undefined, { toolName: 'my-tool' }, testAgentDetails, - undefined, { startTime: customStart, endTime: customEnd } + { toolName: 'my-tool' }, testAgentDetails, + undefined, undefined, { startTime: customStart, endTime: customEnd } ); scope.dispose(); @@ -606,8 +602,8 @@ describe('Scopes', () => { const laterEnd = 1700000048000; // 8 seconds later const scope = ExecuteToolScope.start( - undefined, { toolName: 'my-tool' }, testAgentDetails, - undefined, { startTime: customStart } + { toolName: 'my-tool' }, testAgentDetails, + undefined, undefined, { startTime: customStart } ); scope.setEndTime(laterEnd); scope.dispose(); @@ -622,8 +618,8 @@ describe('Scopes', () => { const customEnd = new Date('2023-11-14T22:13:25.000Z'); // 5 seconds later const scope = ExecuteToolScope.start( - undefined, { toolName: 'my-tool' }, testAgentDetails, - undefined, { startTime: customStart, endTime: customEnd } + { toolName: 'my-tool' }, testAgentDetails, + undefined, undefined, { startTime: customStart, endTime: customEnd } ); scope.dispose(); @@ -638,8 +634,8 @@ describe('Scopes', () => { const customEnd: [number, number] = [1700000005, 500000000]; // 5.5 seconds later const scope = ExecuteToolScope.start( - undefined, { toolName: 'my-tool' }, testAgentDetails, - undefined, { startTime: customStart, endTime: customEnd } + { toolName: 'my-tool' }, testAgentDetails, + undefined, undefined, { startTime: customStart, endTime: customEnd } ); scope.dispose(); @@ -650,7 +646,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); + const scope = ExecuteToolScope.start({ toolName: 'my-tool' }, testAgentDetails); scope.dispose(); const after = Date.now(); @@ -681,8 +677,8 @@ describe('Scopes', () => { ['CLIENT', SpanKind.CLIENT, SpanKind.CLIENT], ])('ExecuteToolScope spanKind: %s', (_label, input, expected) => { const scope = ExecuteToolScope.start( - undefined, { toolName: 'my-tool' }, testAgentDetails, - undefined, input !== undefined ? { spanKind: input } : undefined + { toolName: 'my-tool' }, testAgentDetails, + undefined, undefined, input !== undefined ? { spanKind: input } : undefined ); scope.dispose(); expect(getFinishedSpan().kind).toBe(expected); @@ -690,7 +686,7 @@ describe('Scopes', () => { it('recordCancellation should set error status and error.type attribute with default reason', () => { const scope = ExecuteToolScope.start( - undefined, { toolName: 'my-tool' }, testAgentDetails + { toolName: 'my-tool' }, testAgentDetails ); scope.recordCancellation(); scope.dispose(); @@ -703,7 +699,7 @@ describe('Scopes', () => { it('recordCancellation should use custom reason', () => { const scope = ExecuteToolScope.start( - undefined, { toolName: 'my-tool' }, testAgentDetails + { toolName: 'my-tool' }, testAgentDetails ); scope.recordCancellation('User aborted'); scope.dispose(); From 0e048861dae1d1cee0ee020f5892ecc163fc2566 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 20 Mar 2026 16:00:02 -0700 Subject: [PATCH 09/16] fix: add tenantId check in OutputLoggingMiddleware, remove try/catch Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/middleware/OutputLoggingMiddleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts index 36e5201e..be7cbe84 100644 --- a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts +++ b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts @@ -41,7 +41,7 @@ export class OutputLoggingMiddleware implements Middleware { const authToken = this.resolveAuthToken(context); const agentDetails = ScopeUtils.deriveAgentDetails(context, authToken); - if (!agentDetails) { + if (!agentDetails || !agentDetails.tenantId) { await next(); return; } From 0bf75eab4d36bdd5a0b7ba384ea657f9d7dbd2d1 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 20 Mar 2026 17:36:19 -0700 Subject: [PATCH 10/16] fix: add defensive null check for InvokeAgentScope.details, add serviceNamespace test - Guard against undefined invokeAgentDetails.details before accessing tenantId - Add test for withServiceNamespace builder option (service.namespace resource attribute) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/tracing/scopes/InvokeAgentScope.ts | 3 +++ .../core/observabilityBuilder-options.test.ts | 27 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts index 1c244e07..3a220a71 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -44,6 +44,9 @@ export class InvokeAgentScope extends OpenTelemetryScope { spanDetails?: SpanDetails ) { const agent = invokeAgentDetails.details; + if (!agent) { + throw new Error('InvokeAgentScope: details is required on invokeAgentDetails'); + } // Derive tenant details from agent.tenantId (required for telemetry) if (!agent.tenantId) { 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(); + }); }); From 0e4308f701637a4f37a7926877bf2d5ba77c231a Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 24 Mar 2026 10:01:25 -0700 Subject: [PATCH 11/16] docs: add CallerDetails migration JSDoc, fix baggage key table in design.md --- .../agents-a365-observability/docs/design.md | 21 ++++++++++--------- .../src/tracing/contracts.ts | 7 +++++++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index 6cf20060..6586b9e0 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -166,13 +166,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, // Must include tenantId - { conversationId: 'conv-123' } // Request (optional) + agentDetails // Must include tenantId ); scope.recordInputMessages(['User message']); @@ -192,6 +192,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' }), @@ -241,18 +242,18 @@ const scope2 = BaggageBuilder.setRequestContext( | Method | Baggage Key | |--------|-------------| -| `tenantId(value)` | `tenant_id` | +| `tenantId(value)` | `microsoft.tenant.id` | | `agentId(value)` | `gen_ai.agent.id` | -| `agentAuid(value)` | `gen_ai.agent.auid` | -| `agentUpn(value)` | `gen_ai.agent.upn` | +| `agentAuid(value)` | `microsoft.agent.user.id` | +| `agentUpn(value)` | `microsoft.agent.user.upn` | | `correlationId(value)` | `correlation_id` | -| `callerId(value)` | `gen_ai.caller.id` | -| `sessionId(value)` | `session_id` | +| `callerId(value)` | `microsoft.caller.id` | +| `sessionId(value)` | `microsoft.session.id` | | `conversationId(value)` | `gen_ai.conversation.id` | -| `callerUpn(value)` | `gen_ai.caller.upn` | +| `callerUpn(value)` | `microsoft.caller.upn` | | `operationSource(value)` | `service.name` | -| `channelName(value)` | `gen_ai.execution.source.name` | -| `channelLink(value)` | `gen_ai.execution.source.description` | +| `channelName(value)` | `microsoft.channel.name` | +| `channelLink(value)` | `microsoft.channel.link` | ## Data Interfaces diff --git a/packages/agents-a365-observability/src/tracing/contracts.ts b/packages/agents-a365-observability/src/tracing/contracts.ts index db0eb12e..c82de894 100644 --- a/packages/agents-a365-observability/src/tracing/contracts.ts +++ b/packages/agents-a365-observability/src/tracing/contracts.ts @@ -182,6 +182,13 @@ export interface UserDetails { /** * 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 */ From ae8d7ddc5448d3df6131f646eda7f54e7a5d5340 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 24 Mar 2026 10:05:33 -0700 Subject: [PATCH 12/16] refactor: make request first required param on OutputScope.start(), aligned with other scopes --- CHANGELOG.md | 6 +++--- .../src/middleware/OutputLoggingMiddleware.ts | 2 +- .../src/tracing/scopes/OutputScope.ts | 8 ++++---- tests/observability/core/output-scope.test.ts | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2131110f..43ac9bca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`InvokeAgentDetails` — inheritance → composition.** No longer extends `AgentDetails`. Agent identity is accessed via `invokeAgentDetails.details.agentId`. Removed `request`, `providerName`, `agentBlueprintId` fields. - **`InvokeAgentScope.start()` — new signature.** `start(request, invokeAgentDetails, callerDetails?, spanDetails?)`. `request` is required. Tenant ID is derived from `invokeAgentDetails.details.tenantId` (required). `userDetails` and `callerAgentDetails` are wrapped in `CallerDetails`. Span options (`parentContext`, `startTime`, `endTime`, `spanKind`) are grouped in `SpanDetails`. -- **`InferenceScope.start()` — new signature.** `start(details, agentDetails, request?, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `request` is optional. -- **`ExecuteToolScope.start()` — new signature.** `start(details, agentDetails, request?, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `request` is optional. -- **`OutputScope.start()` — new signature.** `start(response, agentDetails, request?, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). `request` is optional. +- **`InferenceScope.start()` — new signature.** `start(request, details, agentDetails, userDetails?, spanDetails?)`. `request` is required and first. Tenant ID derived from `agentDetails.tenantId` (required). +- **`ExecuteToolScope.start()` — new signature.** `start(request, details, agentDetails, userDetails?, spanDetails?)`. `request` is required and first. Tenant ID derived from `agentDetails.tenantId` (required). +- **`OutputScope.start()` — new signature.** `start(request, response, agentDetails, userDetails?, spanDetails?)`. `request` is required and first. 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. diff --git a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts index be7cbe84..2b0a525d 100644 --- a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts +++ b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts @@ -110,9 +110,9 @@ export class OutputLoggingMiddleware implements Middleware { : undefined; const outputScope = OutputScope.start( + request ?? {}, { messages }, agentDetails, - request, userDetails, spanDetails, ); diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index 0d664e22..1399fc89 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts @@ -16,27 +16,27 @@ 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 agent producing the output. Tenant ID is derived from `agentDetails.tenantId`. - * @param request Optional request context (conversationId, channel). * @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, - request?: Request, userDetails?: UserDetails, spanDetails?: SpanDetails ): OutputScope { - return new OutputScope(response, agentDetails, request, userDetails, spanDetails); + return new OutputScope(request, response, agentDetails, userDetails, spanDetails); } private constructor( + request: Request, response: OutputResponse, agentDetails: AgentDetails, - request?: Request, userDetails?: UserDetails, spanDetails?: SpanDetails ) { diff --git a/tests/observability/core/output-scope.test.ts b/tests/observability/core/output-scope.test.ts index ff7bdd92..63e011b4 100644 --- a/tests/observability/core/output-scope.test.ts +++ b/tests/observability/core/output-scope.test.ts @@ -71,7 +71,7 @@ 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); + const scope = OutputScope.start({}, response, testAgentDetails); expect(scope).toBeInstanceOf(OutputScope); scope.dispose(); @@ -89,7 +89,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); + const scope = OutputScope.start({}, response, testAgentDetails); scope.recordOutputMessages(['Appended 1']); scope.recordOutputMessages(['Appended 2', 'Appended 3']); scope.dispose(); @@ -106,8 +106,8 @@ describe('OutputScope', () => { const parentSpanId = 'abcdefabcdef1234'; const scope = OutputScope.start( - { messages: ['Test'] }, testAgentDetails, - undefined, undefined, { parentContext: { traceId: parentTraceId, spanId: parentSpanId } as ParentSpanRef } + {}, { messages: ['Test'] }, testAgentDetails, + undefined, { parentContext: { traceId: parentTraceId, spanId: parentSpanId } as ParentSpanRef } ); scope.dispose(); From 20e8508ef5981ad2085d840ad12a7b28114f1ef5 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 24 Mar 2026 10:49:15 -0700 Subject: [PATCH 13/16] test: populate request object in all scope tests for realistic coverage --- CHANGELOG.md | 8 +- .../src/utils/ScopeUtils.ts | 4 +- .../src/tracing/scopes/ExecuteToolScope.ts | 8 +- .../src/tracing/scopes/InferenceScope.ts | 8 +- .../src/tracing/scopes/OutputScope.ts | 5 + tests/observability/core/output-scope.test.ts | 12 +- .../core/parent-span-ref.test.ts | 22 ++-- tests/observability/core/scopes.test.ts | 123 +++++++++++------- 8 files changed, 120 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ac9bca..7df3ed9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes (`@microsoft/agents-a365-observability`) - **`InvokeAgentDetails` — inheritance → composition.** No longer extends `AgentDetails`. Agent identity is accessed via `invokeAgentDetails.details.agentId`. Removed `request`, `providerName`, `agentBlueprintId` fields. -- **`InvokeAgentScope.start()` — new signature.** `start(request, invokeAgentDetails, callerDetails?, spanDetails?)`. `request` is required. Tenant ID is derived from `invokeAgentDetails.details.tenantId` (required). `userDetails` and `callerAgentDetails` are wrapped in `CallerDetails`. Span options (`parentContext`, `startTime`, `endTime`, `spanKind`) are grouped in `SpanDetails`. -- **`InferenceScope.start()` — new signature.** `start(request, details, agentDetails, userDetails?, spanDetails?)`. `request` is required and first. Tenant ID derived from `agentDetails.tenantId` (required). -- **`ExecuteToolScope.start()` — new signature.** `start(request, details, agentDetails, userDetails?, spanDetails?)`. `request` is required and first. Tenant ID derived from `agentDetails.tenantId` (required). -- **`OutputScope.start()` — new signature.** `start(request, response, agentDetails, userDetails?, spanDetails?)`. `request` is required and first. Tenant ID derived from `agentDetails.tenantId` (required). +- **`InvokeAgentScope.start()` — new signature.** `start(request, invokeAgentDetails, callerDetails?, spanDetails?)`. Tenant ID is derived from `invokeAgentDetails.details.tenantId` (required). `userDetails` and `callerAgentDetails` are wrapped in `CallerDetails`. Span options (`parentContext`, `startTime`, `endTime`, `spanKind`) are 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. diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index 6406ac9c..950a8a5f 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -168,7 +168,7 @@ export class ScopeUtils { ? { startTime, endTime } : undefined; - const scope = InferenceScope.start(details, agent, request, caller, spanDetails); + const scope = InferenceScope.start(request, details, agent, caller, spanDetails); this.setInputMessageTags(scope, turnContext); return scope; } @@ -292,7 +292,7 @@ export class ScopeUtils { ? { startTime, endTime, spanKind } : undefined; - const scope = ExecuteToolScope.start(details, agent, request, caller, spanDetailsObj); + const scope = ExecuteToolScope.start(request, details, agent, caller, spanDetailsObj); return scope; } diff --git a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts index f8d0aab7..621f0275 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts @@ -19,27 +19,27 @@ export class ExecuteToolScope extends OpenTelemetryScope { /** * Creates and starts a new scope for tool execution tracing. * + * @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 request Optional request context (conversationId, channel). * @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, - request?: Request, userDetails?: UserDetails, spanDetails?: SpanDetails ): ExecuteToolScope { - return new ExecuteToolScope(details, agentDetails, request, userDetails, spanDetails); + return new ExecuteToolScope(request, details, agentDetails, userDetails, spanDetails); } private constructor( + request: Request, details: ToolCallDetails, agentDetails: AgentDetails, - request?: Request, userDetails?: UserDetails, spanDetails?: SpanDetails ) { diff --git a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts index b391c479..f5abb200 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts @@ -19,27 +19,27 @@ export class InferenceScope extends OpenTelemetryScope { /** * Creates and starts a new scope for inference tracing. * + * @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 request Optional request context (conversationId, channel). * @param userDetails Optional human caller identity. * @param spanDetails Optional span configuration (parentContext, startTime, endTime). * @returns A new InferenceScope instance */ public static start( + request: Request, details: InferenceDetails, agentDetails: AgentDetails, - request?: Request, userDetails?: UserDetails, spanDetails?: SpanDetails ): InferenceScope { - return new InferenceScope(details, agentDetails, request, userDetails, spanDetails); + return new InferenceScope(request, details, agentDetails, userDetails, spanDetails); } private constructor( + request: Request, details: InferenceDetails, agentDetails: AgentDetails, - request?: Request, userDetails?: UserDetails, spanDetails?: SpanDetails ) { diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index 1399fc89..767583a6 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts @@ -40,6 +40,11 @@ export class OutputScope extends OpenTelemetryScope { userDetails?: UserDetails, spanDetails?: SpanDetails ) { + // Validate request (required for all scopes) + if (!request) { + throw new Error('OutputScope: request is required'); + } + // Derive tenant details from agentDetails.tenantId (required for telemetry) if (!agentDetails.tenantId) { throw new Error('OutputScope: tenantId is required on agentDetails'); diff --git a/tests/observability/core/output-scope.test.ts b/tests/observability/core/output-scope.test.ts index 63e011b4..8a76b80f 100644 --- a/tests/observability/core/output-scope.test.ts +++ b/tests/observability/core/output-scope.test.ts @@ -22,6 +22,8 @@ describe('OutputScope', () => { 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 @@ -71,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); + const scope = OutputScope.start( + { conversationId: 'conv-out-1', channel: { name: 'Email', description: 'https://email.link' } }, + response, testAgentDetails + ); expect(scope).toBeInstanceOf(OutputScope); scope.dispose(); @@ -82,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']); }); @@ -89,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); + const scope = OutputScope.start(testRequest, response, testAgentDetails); scope.recordOutputMessages(['Appended 1']); scope.recordOutputMessages(['Appended 2', 'Appended 3']); scope.dispose(); diff --git a/tests/observability/core/parent-span-ref.test.ts b/tests/observability/core/parent-span-ref.test.ts index db3b5365..7954dfbd 100644 --- a/tests/observability/core/parent-span-ref.test.ts +++ b/tests/observability/core/parent-span-ref.test.ts @@ -71,6 +71,8 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { 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', () => { @@ -101,7 +103,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { tenantId: 'test-tenant-456' } }; - return InvokeAgentScope.start({}, invokeAgentDetails, undefined, { parentContext: parentRef }); + return InvokeAgentScope.start(testRequest, invokeAgentDetails, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('invokeagent') || name.toLowerCase().includes('invoke_agent'), ], @@ -113,7 +115,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { model: 'gpt-4', providerName: 'openai', }; - return InferenceScope.start(inferenceDetails, testAgentDetails, undefined, undefined, { parentContext: parentRef }); + return InferenceScope.start(testRequest, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('chat'), ], @@ -124,7 +126,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { toolName: 'test-tool', arguments: '{"param": "value"}', }; - return ExecuteToolScope.start(toolDetails, testAgentDetails, undefined, undefined, { parentContext: parentRef }); + return ExecuteToolScope.start(testRequest, toolDetails, testAgentDetails, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('execute_tool'), ], @@ -178,7 +180,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { }; const nestedScope = InvokeAgentScope.start( - {}, + testRequest, invokeAgentDetails ); @@ -207,7 +209,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); const spanContext = scope.getSpanContext(); expect(spanContext).toBeDefined(); @@ -225,7 +227,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, undefined, undefined, { parentContext: parentRef }) + InferenceScope.start(testRequest, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }) ); expect(childScope.getSpanContext().traceId).toBe(spanContext.traceId); @@ -258,7 +260,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { details: { agentId: 'sampled-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); scope.dispose(); }); @@ -287,7 +289,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { details: { agentId: 'unsampled-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); scope.dispose(); }); @@ -316,7 +318,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { details: { agentId: 'default-sampled-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); scope.dispose(); }); @@ -351,7 +353,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { details: { agentId: 'inherited-flags-agent', tenantId: 'test-tenant-456' }, }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); scope.dispose(); }); }); diff --git a/tests/observability/core/scopes.test.ts b/tests/observability/core/scopes.test.ts index 3a5ffbec..042a33ff 100644 --- a/tests/observability/core/scopes.test.ts +++ b/tests/observability/core/scopes.test.ts @@ -38,8 +38,11 @@ describe('Scopes', () => { 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 spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', @@ -49,10 +52,20 @@ describe('Scopes', () => { } }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start( + { conversationId: 'conv-req-1', channel: { name: 'Teams', description: 'https://teams.link' } }, + invokeAgentDetails + ); 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', () => { @@ -60,7 +73,7 @@ describe('Scopes', () => { details: { agentId: 'simple-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -78,7 +91,7 @@ describe('Scopes', () => { } }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -94,7 +107,7 @@ describe('Scopes', () => { } }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -116,7 +129,7 @@ describe('Scopes', () => { tenantId: 'test-tenant' }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails, { userDetails: callerDetails }); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails, { userDetails: callerDetails }); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); @@ -124,7 +137,7 @@ describe('Scopes', () => { it('should record response', () => { const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); expect(() => scope?.recordResponse('Test response')).not.toThrow(); scope?.dispose(); @@ -132,7 +145,7 @@ describe('Scopes', () => { it('should record input and output messages', () => { const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); expect(() => scope?.recordInputMessages(['Input message 1', 'Input message 2'])).not.toThrow(); expect(() => scope?.recordOutputMessages(['Output message 1', 'Output message 2'])).not.toThrow(); @@ -141,7 +154,7 @@ describe('Scopes', () => { it('should record error', () => { const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); const error = new Error('Test error'); expect(() => scope?.recordError(error)).not.toThrow(); @@ -202,7 +215,7 @@ describe('Scopes', () => { } }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); expect(scope).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -231,7 +244,7 @@ describe('Scopes', () => { platformId: 'caller-platform-xyz' } as any; - const scope = InvokeAgentScope.start({}, invokeAgentDetails, { callerAgentDetails }); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails, { callerAgentDetails }); expect(scope).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -265,9 +278,9 @@ describe('Scopes', () => { agentClientIP: '192.168.1.100' } as any; - const scope1 = InvokeAgentScope.start({}, invokeAgentDetails, { userDetails: callerDetails }); + const scope1 = InvokeAgentScope.start(testRequest, invokeAgentDetails, { userDetails: callerDetails }); expect(scope1).toBeInstanceOf(InvokeAgentScope); - const scope2 = InvokeAgentScope.start({}, invokeAgentDetails, { callerAgentDetails }); + const scope2 = InvokeAgentScope.start(testRequest, invokeAgentDetails, { callerAgentDetails }); expect(scope2).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -291,13 +304,13 @@ describe('Scopes', () => { 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, undefined, callerDetails); + }, testAgentDetails, callerDetails); expect(scope).toBeInstanceOf(ExecuteToolScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -316,17 +329,28 @@ describe('Scopes', () => { }); it('should record response', () => { - const scope = ExecuteToolScope.start({ toolName: 'test-tool' }, testAgentDetails); + 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.start( - { toolName: 'test-tool' }, testAgentDetails, - { conversationId: 'conv-tool-123', channel: { name: 'ChannelTool', description: 'https://channel/tool' } } + { conversationId: 'conv-tool-123', channel: { name: 'ChannelTool', description: 'https://channel/tool' } }, + { toolName: 'test-tool' }, testAgentDetails ); expect(scope).toBeInstanceOf(ExecuteToolScope); @@ -346,7 +370,7 @@ 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 } }, + 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] })); @@ -360,7 +384,7 @@ 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 } }, + 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] })); @@ -374,7 +398,7 @@ 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 } }, + 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] })); @@ -388,7 +412,7 @@ 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 } }, + 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] })); @@ -402,7 +426,7 @@ 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( - {}, + testRequest, { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' }, endpoint: { host: 'agent.example.com', port: 9090 } } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -416,7 +440,7 @@ describe('Scopes', () => { it('should omit port 443 on InvokeAgentScope', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( - {}, + testRequest, { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' }, endpoint: { host: 'agent.example.com', port: 443 } } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -447,7 +471,7 @@ describe('Scopes', () => { finishReasons: ['stop'] }; - const scope = InferenceScope.start(inferenceDetails, testAgentDetails, 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] })); @@ -466,15 +490,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); + 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', () => { @@ -483,7 +518,7 @@ describe('Scopes', () => { model: 'gpt-4' }; - const scope = InferenceScope.start(inferenceDetails, testAgentDetails); + const scope = InferenceScope.start(testRequest, inferenceDetails, testAgentDetails); expect(() => scope?.recordInputMessages(['Input message'])).not.toThrow(); expect(() => scope?.recordOutputMessages(['Generated response'])).not.toThrow(); @@ -501,8 +536,8 @@ describe('Scopes', () => { }; const scope = InferenceScope.start( - inferenceDetails, testAgentDetails, - { conversationId: 'conv-inf-123', channel: { name: 'ChannelInf', description: 'https://channel/inf' } } + { conversationId: 'conv-inf-123', channel: { name: 'ChannelInf', description: 'https://channel/inf' } }, + inferenceDetails, testAgentDetails ); expect(scope).toBeInstanceOf(InferenceScope); @@ -521,7 +556,7 @@ describe('Scopes', () => { describe('Dispose pattern', () => { it('should support manual dispose', () => { const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start({}, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); scope?.recordResponse('Manual dispose test'); expect(() => scope?.dispose()).not.toThrow(); @@ -531,7 +566,7 @@ describe('Scopes', () => { const toolDetails: ToolCallDetails = { toolName: 'test-tool' }; expect(() => { - const scope = ExecuteToolScope.start(toolDetails, testAgentDetails); + const scope = ExecuteToolScope.start(testRequest, toolDetails, testAgentDetails); try { scope?.recordResponse('Automatic disposal test'); } finally { @@ -587,8 +622,8 @@ describe('Scopes', () => { const customEnd = 1700000005000; // 5 seconds later const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, - undefined, undefined, { startTime: customStart, endTime: customEnd } + testRequest, { toolName: 'my-tool' }, testAgentDetails, + undefined, { startTime: customStart, endTime: customEnd } ); scope.dispose(); @@ -602,8 +637,8 @@ describe('Scopes', () => { const laterEnd = 1700000048000; // 8 seconds later const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, - undefined, undefined, { startTime: customStart } + testRequest, { toolName: 'my-tool' }, testAgentDetails, + undefined, { startTime: customStart } ); scope.setEndTime(laterEnd); scope.dispose(); @@ -618,8 +653,8 @@ describe('Scopes', () => { const customEnd = new Date('2023-11-14T22:13:25.000Z'); // 5 seconds later const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, - undefined, undefined, { startTime: customStart, endTime: customEnd } + testRequest, { toolName: 'my-tool' }, testAgentDetails, + undefined, { startTime: customStart, endTime: customEnd } ); scope.dispose(); @@ -634,8 +669,8 @@ describe('Scopes', () => { const customEnd: [number, number] = [1700000005, 500000000]; // 5.5 seconds later const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, - undefined, undefined, { startTime: customStart, endTime: customEnd } + testRequest, { toolName: 'my-tool' }, testAgentDetails, + undefined, { startTime: customStart, endTime: customEnd } ); scope.dispose(); @@ -646,7 +681,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); + const scope = ExecuteToolScope.start(testRequest, { toolName: 'my-tool' }, testAgentDetails); scope.dispose(); const after = Date.now(); @@ -663,7 +698,7 @@ describe('Scopes', () => { ['SERVER', SpanKind.SERVER, SpanKind.SERVER], ])('InvokeAgentScope spanKind: %s', (_label, input, expected) => { const scope = InvokeAgentScope.start( - {}, + testRequest, { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }, undefined, input !== undefined ? { spanKind: input } : undefined @@ -677,8 +712,8 @@ describe('Scopes', () => { ['CLIENT', SpanKind.CLIENT, SpanKind.CLIENT], ])('ExecuteToolScope spanKind: %s', (_label, input, expected) => { const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails, - undefined, undefined, input !== undefined ? { spanKind: input } : undefined + testRequest, { toolName: 'my-tool' }, testAgentDetails, + undefined, input !== undefined ? { spanKind: input } : undefined ); scope.dispose(); expect(getFinishedSpan().kind).toBe(expected); @@ -686,7 +721,7 @@ describe('Scopes', () => { it('recordCancellation should set error status and error.type attribute with default reason', () => { const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails + testRequest, { toolName: 'my-tool' }, testAgentDetails ); scope.recordCancellation(); scope.dispose(); @@ -699,7 +734,7 @@ describe('Scopes', () => { it('recordCancellation should use custom reason', () => { const scope = ExecuteToolScope.start( - { toolName: 'my-tool' }, testAgentDetails + testRequest, { toolName: 'my-tool' }, testAgentDetails ); scope.recordCancellation('User aborted'); scope.dispose(); From d91fe81201be504a7b4c6edcac7095444423d4f4 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 24 Mar 2026 12:46:52 -0700 Subject: [PATCH 14/16] refactor: split InvokeAgentDetails into InvokeAgentScopeDetails + AgentDetails, move sessionId to Request --- CHANGELOG.md | 6 +- .../src/utils/ScopeUtils.ts | 46 +++--- .../agents-a365-observability/docs/design.md | 35 ++--- .../agents-a365-observability/src/index.ts | 2 +- .../src/tracing/scopes/InvokeAgentScope.ts | 57 +++---- .../core/parent-span-ref.test.ts | 46 ++---- tests/observability/core/scopes.test.ts | 142 ++++++++---------- .../core/trace-context-propagation.test.ts | 6 +- .../extension/hosting/scope-utils.test.ts | 34 ++--- 9 files changed, 155 insertions(+), 219 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7df3ed9f..3da94d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes (`@microsoft/agents-a365-observability`) -- **`InvokeAgentDetails` — inheritance → composition.** No longer extends `AgentDetails`. Agent identity is accessed via `invokeAgentDetails.details.agentId`. Removed `request`, `providerName`, `agentBlueprintId` fields. -- **`InvokeAgentScope.start()` — new signature.** `start(request, invokeAgentDetails, callerDetails?, spanDetails?)`. Tenant ID is derived from `invokeAgentDetails.details.tenantId` (required). `userDetails` and `callerAgentDetails` are wrapped in `CallerDetails`. Span options (`parentContext`, `startTime`, `endTime`, `spanKind`) are grouped in `SpanDetails`. +- **`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). @@ -42,7 +42,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()` renamed to `ScopeUtils.buildAgentDetailsFromContext()`** — Returns `AgentDetails` directly instead of `InvokeAgentDetails`. ### Added diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index 950a8a5f..3072774e 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -13,7 +13,7 @@ import { UserDetails, CallerDetails, InferenceDetails, - InvokeAgentDetails, + InvokeAgentScopeDetails, ToolCallDetails, Request, SpanDetails, @@ -176,10 +176,9 @@ export class ScopeUtils { /** * Create an `InvokeAgentScope` using `details` and values derived from the provided `TurnContext`. * Builds a separate `Request` with `conversationId` and `channel` from context. - * Merges agent identity from context into `invokeAgentDetails.details`. - * Derives `callerAgentDetails` (from caller) and `userDetails` (human caller). + * Derives agent identity from context, caller details (agent and human). * Also records input messages from the context if present. - * @param details The invoke-agent call details to be augmented and used for the scope. + * @param scopeDetails The invoke-agent scope details (endpoint). * @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). @@ -188,7 +187,7 @@ export class ScopeUtils { * @returns A started `InvokeAgentScope` enriched with context-derived parameters. */ static populateInvokeAgentScopeFromTurnContext( - details: InvokeAgentDetails, + scopeDetails: InvokeAgentScopeDetails, turnContext: TurnContext, authToken: string, startTime?: TimeInput, @@ -200,8 +199,13 @@ export class ScopeUtils { const conversationId = ScopeUtils.deriveConversationId(turnContext); const channel = ScopeUtils.deriveChannelObject(turnContext); - // Merge agent identity from TurnContext into details.details - const invokeAgentDetails = ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); + // Derive agent identity from TurnContext + const agentDetails = ScopeUtils.deriveAgentDetails(turnContext, authToken); + if (!agentDetails) { + throw new Error('populateInvokeAgentScopeFromTurnContext: Missing agent details on TurnContext (recipient)'); + } + // Merge conversationId from context + agentDetails.conversationId = conversationId ?? agentDetails.conversationId; // Build the request with channel and conversationId from context const hasChannel = channel.name !== undefined || channel.description !== undefined; @@ -220,37 +224,23 @@ export class ScopeUtils { ? { startTime, endTime, spanKind } : undefined; - const scope = InvokeAgentScope.start(request, invokeAgentDetails, callerDetails, spanDetailsObj); + const scope = InvokeAgentScope.start(request, scopeDetails, agentDetails, callerDetails, spanDetailsObj); this.setInputMessageTags(scope, turnContext); return scope; } /** - * Build InvokeAgentDetails by merging provided details with agent info from the TurnContext. - * @param details Base invoke-agent details to augment + * Derive agent details from the TurnContext, merging with conversationId. * @param turnContext Activity context * @param authToken Auth token for resolving agent identity from token claims. - * @returns New InvokeAgentDetails with merged `details.details` (agent identity). + * @returns Merged AgentDetails. */ - public static buildInvokeAgentDetails(details: InvokeAgentDetails, turnContext: TurnContext, authToken: string): InvokeAgentDetails { - return ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); - } - - private static buildInvokeAgentDetailsCore(details: InvokeAgentDetails, turnContext: TurnContext, authToken: string): InvokeAgentDetails { + public static buildAgentDetailsFromContext(turnContext: TurnContext, authToken: string): AgentDetails | undefined { const agent = ScopeUtils.deriveAgentDetails(turnContext, authToken); + if (!agent) return undefined; const conversationId = ScopeUtils.deriveConversationId(turnContext); - - // Merge derived agent identity into details.details - const mergedAgent: AgentDetails = { - ...details.details, - ...(agent ?? {}), - conversationId: conversationId ?? details.details?.conversationId, - }; - - return { - ...details, - details: mergedAgent, - }; + agent.conversationId = conversationId ?? agent.conversationId; + return agent; } /** diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index 6586b9e0..305d37a3 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -129,20 +129,19 @@ abstract class OpenTelemetryScope implements Disposable { Traces agent invocation operations: ```typescript -import { InvokeAgentScope, InvokeAgentDetails, Request, CallerDetails } from '@microsoft/agents-a365-observability'; +import { InvokeAgentScope, InvokeAgentScopeDetails, AgentDetails, Request, CallerDetails } from '@microsoft/agents-a365-observability'; -const request: Request = { content: 'Hello', channel: { name: 'Teams' } }; -const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'agent-123', agentName: 'MyAgent', tenantId: 'tenant-789' }, - endpoint: { host: 'api.example.com', port: 443 }, - sessionId: 'session-456' +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: { callerId: 'user-1', callerName: 'User' }, callerAgentDetails: callerAgent // Optional, for A2A scenarios }; -using scope = InvokeAgentScope.start(request, invokeAgentDetails, callerInfo); +using scope = InvokeAgentScope.start(request, scopeDetails, agentDetails, callerInfo); scope.recordInputMessages(['Hello']); // ... agent processing ... @@ -242,28 +241,26 @@ const scope2 = BaggageBuilder.setRequestContext( | Method | Baggage Key | |--------|-------------| -| `tenantId(value)` | `microsoft.tenant.id` | +| `tenantId(value)` | `tenant_id` | | `agentId(value)` | `gen_ai.agent.id` | -| `agentAuid(value)` | `microsoft.agent.user.id` | -| `agentUpn(value)` | `microsoft.agent.user.upn` | +| `agentAuid(value)` | `gen_ai.agent.auid` | +| `agentUpn(value)` | `gen_ai.agent.upn` | | `correlationId(value)` | `correlation_id` | -| `callerId(value)` | `microsoft.caller.id` | -| `sessionId(value)` | `microsoft.session.id` | +| `callerId(value)` | `gen_ai.caller.id` | +| `sessionId(value)` | `session_id` | | `conversationId(value)` | `gen_ai.conversation.id` | -| `callerUpn(value)` | `microsoft.caller.upn` | +| `callerUpn(value)` | `gen_ai.caller.upn` | | `operationSource(value)` | `service.name` | -| `channelName(value)` | `microsoft.channel.name` | -| `channelLink(value)` | `microsoft.channel.link` | +| `channelName(value)` | `gen_ai.execution.source.name` | +| `channelLink(value)` | `gen_ai.execution.source.description` | ## Data Interfaces -### InvokeAgentDetails +### InvokeAgentScopeDetails ```typescript -interface InvokeAgentDetails { - details: AgentDetails; +interface InvokeAgentScopeDetails { endpoint?: ServiceEndpoint; - sessionId?: string; } ``` diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index 311369cc..21642269 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -37,7 +37,7 @@ export { AgentDetails, TenantDetails, ToolCallDetails, - InvokeAgentDetails, + InvokeAgentScopeDetails, UserDetails, CallerDetails, EnhancedAgentDetails, diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts index 3a220a71..552fc81b 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -4,10 +4,11 @@ import { SpanKind } from '@opentelemetry/api'; import { OpenTelemetryScope } from './OpenTelemetryScope'; import { - InvokeAgentDetails, + InvokeAgentScopeDetails, CallerDetails, Request, SpanDetails, + AgentDetails, } from '../contracts'; import { OpenTelemetryConstants } from '../constants'; @@ -19,8 +20,8 @@ export class InvokeAgentScope extends OpenTelemetryScope { * Creates and starts a new scope for agent invocation tracing. * * @param request Request payload (channel, conversationId, content, sessionId). - * @param invokeAgentDetails The details of the agent invocation (agent identity via `.details`, endpoint). - * Tenant ID is derived from `invokeAgentDetails.details.tenantId`. + * @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: { callerId, callerName, ... } }` * - Agent caller only: `{ callerAgentDetails: { agentId, agentName, ... } }` @@ -30,37 +31,39 @@ export class InvokeAgentScope extends OpenTelemetryScope { */ public static start( request: Request, - invokeAgentDetails: InvokeAgentDetails, + invokeScopeDetails: InvokeAgentScopeDetails, + agentDetails: AgentDetails, callerDetails?: CallerDetails, - spanDetails?: SpanDetails + spanDetails?: SpanDetails, ): InvokeAgentScope { - return new InvokeAgentScope(request, invokeAgentDetails, callerDetails, spanDetails); + return new InvokeAgentScope(request, invokeScopeDetails, agentDetails, callerDetails, spanDetails); } private constructor( request: Request, - invokeAgentDetails: InvokeAgentDetails, + invokeScopeDetails: InvokeAgentScopeDetails, + agentDetails: AgentDetails, callerDetails?: CallerDetails, spanDetails?: SpanDetails ) { - const agent = invokeAgentDetails.details; - if (!agent) { - throw new Error('InvokeAgentScope: details is required on invokeAgentDetails'); + // Validate agent details + if (!agentDetails) { + throw new Error('InvokeAgentScope: agentDetails is required'); } - // Derive tenant details from agent.tenantId (required for telemetry) - if (!agent.tenantId) { - throw new Error('InvokeAgentScope: tenantId is required on invokeAgentDetails.details'); + // Derive tenant details from agentDetails.tenantId (required for telemetry) + if (!agentDetails.tenantId) { + throw new Error('InvokeAgentScope: tenantId is required on agentDetails'); } - const tenantDetails = { tenantId: agent.tenantId }; + const tenantDetails = { tenantId: agentDetails.tenantId }; super( spanDetails?.spanKind ?? SpanKind.CLIENT, OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, - agent.agentName - ? `${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${agent.agentName}` + agentDetails.agentName + ? `${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${agentDetails.agentName}` : OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, - agent, + agentDetails, tenantDetails, spanDetails?.parentContext, spanDetails?.startTime, @@ -69,19 +72,19 @@ export class InvokeAgentScope extends OpenTelemetryScope { ); // Set provider name from agent details - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, agent.providerName); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, agentDetails.providerName); - // Set session ID - 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, agent.agentBlueprintId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, agentDetails.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); } } @@ -91,8 +94,8 @@ export class InvokeAgentScope extends OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel.description); } - // Use explicit conversationId from request, falling back to agent.conversationId - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request?.conversationId ?? agent.conversationId); + // Use explicit conversationId from request, falling back to agentDetails.conversationId + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request?.conversationId ?? agentDetails.conversationId); // Set caller agent details tags for A2A scenarios const callerAgent = callerDetails?.callerAgentDetails; diff --git a/tests/observability/core/parent-span-ref.test.ts b/tests/observability/core/parent-span-ref.test.ts index 7954dfbd..6a48adf5 100644 --- a/tests/observability/core/parent-span-ref.test.ts +++ b/tests/observability/core/parent-span-ref.test.ts @@ -11,7 +11,7 @@ import { InvokeAgentScope, InferenceScope, ExecuteToolScope, - InvokeAgentDetails, + InvokeAgentScopeDetails, InferenceDetails, InferenceOperationType, ToolCallDetails, @@ -96,14 +96,11 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { [ 'InvokeAgentScope', (parentRef: ParentSpanRef) => { - const invokeAgentDetails: InvokeAgentDetails = { - details: { + return InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', agentName: 'Test Agent', tenantId: 'test-tenant-456' - } - }; - return InvokeAgentScope.start(testRequest, invokeAgentDetails, undefined, { parentContext: parentRef }); + }, undefined, { parentContext: parentRef }); }, (name: string) => name.toLowerCase().includes('invokeagent') || name.toLowerCase().includes('invoke_agent'), ], @@ -175,13 +172,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 = { - details: { agentId: 'nested-agent', tenantId: 'test-tenant-456' }, - }; - const nestedScope = InvokeAgentScope.start( testRequest, - invokeAgentDetails + {}, + { agentId: 'nested-agent', tenantId: 'test-tenant-456' } ); const nestedSpanContext = nestedScope.getSpanContext(); @@ -205,11 +199,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 = { - details: { agentId: 'test-agent', tenantId: 'test-tenant-456' }, - }; - - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); const spanContext = scope.getSpanContext(); expect(spanContext).toBeDefined(); @@ -256,11 +246,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { }; runWithParentSpanRef(parentRef, () => { - const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'sampled-agent', tenantId: 'test-tenant-456' }, - }; - - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'sampled-agent', tenantId: 'test-tenant-456' }); scope.dispose(); }); @@ -285,11 +271,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { }; runWithParentSpanRef(parentRef, () => { - const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'unsampled-agent', tenantId: 'test-tenant-456' }, - }; - - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'unsampled-agent', tenantId: 'test-tenant-456' }); scope.dispose(); }); @@ -314,11 +296,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { }; runWithParentSpanRef(parentRef, () => { - const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'default-sampled-agent', tenantId: 'test-tenant-456' }, - }; - - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'default-sampled-agent', tenantId: 'test-tenant-456' }); scope.dispose(); }); @@ -349,11 +327,7 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { const baseCtx = trace.setSpan(otelContext.active(), rootSpan); await otelContext.with(baseCtx, async () => { runWithParentSpanRef(parentRef, () => { - const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'inherited-flags-agent', tenantId: 'test-tenant-456' }, - }; - - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'inherited-flags-agent', tenantId: 'test-tenant-456' }); scope.dispose(); }); }); diff --git a/tests/observability/core/scopes.test.ts b/tests/observability/core/scopes.test.ts index 042a33ff..977863ad 100644 --- a/tests/observability/core/scopes.test.ts +++ b/tests/observability/core/scopes.test.ts @@ -6,7 +6,7 @@ import { InvokeAgentScope, InferenceScope, AgentDetails, - InvokeAgentDetails, + InvokeAgentScopeDetails, ToolCallDetails, InferenceDetails, InferenceOperationType, @@ -43,18 +43,16 @@ describe('Scopes', () => { describe('InvokeAgentScope', () => { it('should create scope with agent details', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const invokeAgentDetails: InvokeAgentDetails = { - details: { + + 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' } - }; - - const scope = InvokeAgentScope.start( - { conversationId: 'conv-req-1', channel: { name: 'Teams', description: 'https://teams.link' } }, - invokeAgentDetails ); expect(scope).toBeInstanceOf(InvokeAgentScope); @@ -69,59 +67,39 @@ describe('Scopes', () => { }); it('should create scope with agent ID only', () => { - const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'simple-agent', tenantId: 'test-tenant-456' } - }; - - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); + 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 = { - details: { - agentId: 'test-agent', - agentName: 'Test Agent', - agentDescription: 'A test agent', - conversationId: 'conv-123', - iconUri: 'https://example.com/icon.png', - tenantId: 'test-tenant-456' - } - }; - - const scope = InvokeAgentScope.start(testRequest, 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', + tenantId: 'test-tenant-456' + }); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); }); it('should create scope with platformId', () => { - const invokeAgentDetails: InvokeAgentDetails = { - details: { - agentId: 'test-agent', - agentName: 'Test Agent', - platformId: 'platform-xyz-123', - tenantId: 'test-tenant-456' - } - }; - - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { + agentId: 'test-agent', + agentName: 'Test Agent', + platformId: 'platform-xyz-123', + tenantId: 'test-tenant-456' + }); expect(scope).toBeInstanceOf(InvokeAgentScope); scope?.dispose(); }); it('should create scope with caller details', () => { - const invokeAgentDetails: InvokeAgentDetails = { - details: { - agentId: 'test-agent', - agentName: 'Test Agent', - tenantId: 'test-tenant-456' - } - }; - const callerDetails: UserDetails = { callerId: 'user-123', callerName: 'Test User', @@ -129,23 +107,25 @@ describe('Scopes', () => { tenantId: 'test-tenant' }; - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails, { userDetails: 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 record response', () => { - const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); + 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 = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); + 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(); @@ -153,8 +133,7 @@ describe('Scopes', () => { }); it('should record error', () => { - const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); const error = new Error('Test error'); expect(() => scope?.recordError(error)).not.toThrow(); @@ -165,7 +144,8 @@ describe('Scopes', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( { conversationId: 'explicit-conv-id' }, - { details: { agentId: 'test-agent', conversationId: 'from-details', tenantId: 'test-tenant-456' } } + {}, + { agentId: 'test-agent', conversationId: 'from-details', tenantId: 'test-tenant-456' } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).toEqual(expect.arrayContaining([ @@ -179,7 +159,8 @@ describe('Scopes', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( {}, - { details: { agentId: 'test-agent', conversationId: 'from-details', tenantId: 'test-tenant-456' } } + {}, + { agentId: 'test-agent', conversationId: 'from-details', tenantId: 'test-tenant-456' } ); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); expect(calls).toEqual(expect.arrayContaining([ @@ -193,7 +174,8 @@ describe('Scopes', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( { channel: { name: 'Teams', description: 'https://teams.link' } }, - { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } } + {}, + { 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([ @@ -206,16 +188,13 @@ describe('Scopes', () => { it('should propagate platformId in span attributes', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const invokeAgentDetails: InvokeAgentDetails = { - details: { - agentId: 'test-agent', - agentName: 'Test Agent', - platformId: 'test-platform-123', - tenantId: 'test-tenant-456' - } - }; - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { + agentId: 'test-agent', + agentName: 'Test Agent', + 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] })); @@ -229,13 +208,6 @@ describe('Scopes', () => { it('should propagate caller agent platformId in span attributes', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const invokeAgentDetails: InvokeAgentDetails = { - details: { - agentId: 'test-agent', - agentName: 'Test Agent', - tenantId: 'test-tenant-456' - } - }; const callerAgentDetails: AgentDetails = { agentId: 'caller-agent', agentName: 'Caller Agent', @@ -244,7 +216,11 @@ describe('Scopes', () => { platformId: 'caller-platform-xyz' } as any; - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails, { callerAgentDetails }); + 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] })); @@ -258,12 +234,10 @@ describe('Scopes', () => { it('should set caller and caller-agent IP tags', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const invokeAgentDetails: InvokeAgentDetails = { - details: { - agentId: 'test-agent', - agentName: 'Test Agent', - tenantId: 'test-tenant-456' - } + const agentDets = { + agentId: 'test-agent', + agentName: 'Test Agent', + tenantId: 'test-tenant-456' }; const callerDetails: UserDetails = { callerId: 'user-123', @@ -278,9 +252,9 @@ describe('Scopes', () => { agentClientIP: '192.168.1.100' } as any; - const scope1 = InvokeAgentScope.start(testRequest, invokeAgentDetails, { userDetails: callerDetails }); + const scope1 = InvokeAgentScope.start(testRequest, {}, agentDets, { userDetails: callerDetails }); expect(scope1).toBeInstanceOf(InvokeAgentScope); - const scope2 = InvokeAgentScope.start(testRequest, invokeAgentDetails, { callerAgentDetails }); + const scope2 = InvokeAgentScope.start(testRequest, {}, agentDets, { callerAgentDetails }); expect(scope2).toBeInstanceOf(InvokeAgentScope); const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); @@ -427,7 +401,8 @@ describe('Scopes', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( testRequest, - { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' }, endpoint: { host: 'agent.example.com', port: 9090 } } + { 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([ @@ -441,7 +416,8 @@ describe('Scopes', () => { const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); const scope = InvokeAgentScope.start( testRequest, - { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' }, endpoint: { host: 'agent.example.com', port: 443 } } + { 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([ @@ -555,8 +531,7 @@ describe('Scopes', () => { describe('Dispose pattern', () => { it('should support manual dispose', () => { - const invokeAgentDetails: InvokeAgentDetails = { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }; - const scope = InvokeAgentScope.start(testRequest, invokeAgentDetails); + const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); scope?.recordResponse('Manual dispose test'); expect(() => scope?.dispose()).not.toThrow(); @@ -699,7 +674,8 @@ describe('Scopes', () => { ])('InvokeAgentScope spanKind: %s', (_label, input, expected) => { const scope = InvokeAgentScope.start( testRequest, - { details: { agentId: 'test-agent', tenantId: 'test-tenant-456' } }, + {}, + { agentId: 'test-agent', tenantId: 'test-tenant-456' }, undefined, input !== undefined ? { spanKind: input } : undefined ); diff --git a/tests/observability/core/trace-context-propagation.test.ts b/tests/observability/core/trace-context-propagation.test.ts index 0d83265d..beb6c1b1 100644 --- a/tests/observability/core/trace-context-propagation.test.ts +++ b/tests/observability/core/trace-context-propagation.test.ts @@ -140,7 +140,8 @@ describe('Trace Context Propagation', () => { const scope = InvokeAgentScope.start( {}, - { details: { agentId: 'ctx-agent', tenantId: 'test-tenant' } }, + {}, + { agentId: 'ctx-agent', tenantId: 'test-tenant' }, undefined, { parentContext: extractedCtx } ); @@ -165,7 +166,8 @@ describe('Trace Context Propagation', () => { const scope = InvokeAgentScope.start( {}, - { details: { agentId: 'remote-agent', tenantId: 'test-tenant' } }, + {}, + { agentId: 'remote-agent', tenantId: 'test-tenant' }, undefined, { parentContext: parentRef } ); diff --git a/tests/observability/extension/hosting/scope-utils.test.ts b/tests/observability/extension/hosting/scope-utils.test.ts index e4695d4f..75a0c596 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, ExecutionType, OpenTelemetryScope, InvokeAgentScopeDetails } from '@microsoft/agents-a365-observability'; import { SpanKind } from '@opentelemetry/api'; import { RoleTypes } from '@microsoft/agents-activity'; import type { TurnContext } from '@microsoft/agents-hosting'; @@ -128,15 +128,15 @@ describe('ScopeUtils.populateFromTurnContext', () => { }); test('populateInvokeAgentScopeFromTurnContext throws when tenant details are missing', () => { - const details: InvokeAgentDetails = { details: { agentId: 'aid' } } as any; + const details: InvokeAgentScopeDetails = {}; 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('InvokeAgentScope: tenantId is required on invokeAgentDetails.details'); + .toThrow('InvokeAgentScope: tenantId is required on agentDetails'); }); }); test('build InvokeAgentScope based on turn context', () => { - const details: InvokeAgentDetails = { details: { agentId: 'invoke-agent', providerName: 'internal' } }; + const details: InvokeAgentScopeDetails = {}; 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; @@ -271,10 +271,7 @@ test('deriveChannelObject returns undefined fields when none', () => { expect(ScopeUtils.deriveChannelObject(ctx)).toEqual({ name: undefined, description: undefined }); }); -test('buildInvokeAgentDetails merges agent (recipient) and conversationId into details', () => { - const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'provided' }, - }; +test('buildAgentDetailsFromContext merges agent (recipient) and conversationId into details', () => { const ctx = makeCtx({ activity: { recipient: { name: 'Rec', role: 'bot' }, @@ -288,20 +285,16 @@ test('buildInvokeAgentDetails merges agent (recipient) and conversationId into d } as any }); - const result = ScopeUtils.buildInvokeAgentDetails(invokeAgentDetails, ctx, testAuthToken); - // Agent identity is merged into result.details - expect(result.details.agentName).toBe('Rec'); - expect(result.details.conversationId).toBe('c-2'); + const result = ScopeUtils.buildAgentDetailsFromContext(ctx, testAuthToken); + // Agent identity is merged into result + expect(result!.agentName).toBe('Rec'); + expect(result!.conversationId).toBe('c-2'); }); -test('buildInvokeAgentDetails keeps base details when TurnContext has no overrides', () => { - const invokeAgentDetails: InvokeAgentDetails = { - details: { agentId: 'base-agent' }, - }; +test('buildAgentDetailsFromContext returns undefined when TurnContext has no recipient', () => { const ctx = makeCtx({ activity: {} as any }); - const result = ScopeUtils.buildInvokeAgentDetails(invokeAgentDetails, ctx, testAuthToken); - expect(result.details.agentId).toBe('base-agent'); - expect(result.details.conversationId).toBeUndefined(); + const result = ScopeUtils.buildAgentDetailsFromContext(ctx, testAuthToken); + expect(result).toBeUndefined(); }); describe('ScopeUtils spanKind forwarding', () => { @@ -309,11 +302,12 @@ describe('ScopeUtils spanKind forwarding', () => { const spy = jest.spyOn(InvokeAgentScope, 'start'); const ctx = makeTurnContext('hello', 'web', 'https://web', 'conv-span'); const scope = ScopeUtils.populateInvokeAgentScopeFromTurnContext( - { details: { agentId: 'test-agent' } }, ctx, testAuthToken, + {}, ctx, testAuthToken, undefined, undefined, SpanKind.SERVER ); expect(spy).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.anything(), + expect.anything(), expect.objectContaining({ spanKind: SpanKind.SERVER }) ); scope?.dispose(); From 476311dc1b95394523e618f32ff7a879128551b9 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 24 Mar 2026 15:55:14 -0700 Subject: [PATCH 15/16] refactor: flatten TenantDetails into AgentDetails, rename InvokeAgentDetails to InvokeAgentScopeDetails Simplify contracts by removing single-field TenantDetails wrapper and absorbing tenantId directly into AgentDetails. Update all scope signatures, ScopeUtils helpers, docs, and tests accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 +- docs/design.md | 20 ++-- .../docs/design.md | 7 +- .../src/utils/ScopeUtils.ts | 45 +++++---- .../src/ObservabilityBuilder.ts | 5 +- .../agents-a365-observability/src/index.ts | 5 +- .../context/trace-context-propagation.ts | 4 +- .../src/tracing/contracts.ts | 13 +-- .../src/tracing/scopes/ExecuteToolScope.ts | 6 +- .../src/tracing/scopes/InferenceScope.ts | 8 +- .../src/tracing/scopes/InvokeAgentScope.ts | 9 +- .../src/tracing/scopes/OpenTelemetryScope.ts | 3 +- .../src/tracing/scopes/OutputScope.ts | 6 +- tests/observability/core/output-scope.test.ts | 8 ++ .../core/parent-span-ref.test.ts | 1 - tests/observability/core/scopes.test.ts | 95 +++++++++++++++---- .../extension/hosting/scope-utils.test.ts | 21 ++-- 17 files changed, 156 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da94d22..ecd8a2a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,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()` renamed to `ScopeUtils.buildAgentDetailsFromContext()`** — Returns `AgentDetails` directly instead of `InvokeAgentDetails`. +- **`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..9e424c3c 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 (callerId, callerUpn, callerName, 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: { callerId, callerName } } // 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/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index 3072774e..547df609 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -13,10 +13,11 @@ import { UserDetails, CallerDetails, InferenceDetails, - InvokeAgentScopeDetails, ToolCallDetails, Request, SpanDetails, + InvokeAgentScopeDetails, + TenantDetails, } from '@microsoft/agents-a365-observability'; import { resolveEmbodiedAgentIds } from './TurnContextUtils'; @@ -45,7 +46,7 @@ export class ScopeUtils { * @param turnContext Activity context * @returns Tenant details if a recipient tenant id is present; otherwise undefined. */ - public static deriveTenantDetails(turnContext: TurnContext): { tenantId: string } | undefined { + public static deriveTenantDetails(turnContext: TurnContext): TenantDetails | undefined { const tenantId = turnContext?.activity?.getAgenticTenantId?.(); return tenantId ? { tenantId } : undefined; } @@ -176,9 +177,10 @@ export class ScopeUtils { /** * Create an `InvokeAgentScope` using `details` and values derived from the provided `TurnContext`. * Builds a separate `Request` with `conversationId` and `channel` from context. - * Derives agent identity from context, caller details (agent and human). + * Merges agent identity from context into `invokeAgentDetails.details`. + * Derives `callerAgentDetails` (from caller) and `userDetails` (human caller). * Also records input messages from the context if present. - * @param scopeDetails The invoke-agent scope details (endpoint). + * @param details The invoke agent call details to be augmented and used for the scope. * @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). @@ -187,6 +189,7 @@ export class ScopeUtils { * @returns A started `InvokeAgentScope` enriched with context-derived parameters. */ static populateInvokeAgentScopeFromTurnContext( + details: AgentDetails, scopeDetails: InvokeAgentScopeDetails, turnContext: TurnContext, authToken: string, @@ -199,13 +202,8 @@ export class ScopeUtils { const conversationId = ScopeUtils.deriveConversationId(turnContext); const channel = ScopeUtils.deriveChannelObject(turnContext); - // Derive agent identity from TurnContext - const agentDetails = ScopeUtils.deriveAgentDetails(turnContext, authToken); - if (!agentDetails) { - throw new Error('populateInvokeAgentScopeFromTurnContext: Missing agent details on TurnContext (recipient)'); - } - // Merge conversationId from context - agentDetails.conversationId = conversationId ?? agentDetails.conversationId; + // 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; @@ -230,17 +228,26 @@ export class ScopeUtils { } /** - * Derive agent details from the TurnContext, merging with conversationId. + * 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 Merged AgentDetails. + * @returns Merged AgentDetails with context-derived identity. */ - public static buildAgentDetailsFromContext(turnContext: TurnContext, authToken: string): AgentDetails | undefined { - const agent = ScopeUtils.deriveAgentDetails(turnContext, authToken); - if (!agent) return undefined; - const conversationId = ScopeUtils.deriveConversationId(turnContext); - agent.conversationId = conversationId ?? agent.conversationId; - return agent; + public static buildInvokeAgentDetails(details: AgentDetails, turnContext: TurnContext, authToken: string): AgentDetails { + return ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); + } + + 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, + ...(derivedAgentDetails ?? {}), + }; + + return mergedAgent; } /** diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index d1691eda..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'; diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index 21642269..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'; 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 f2bcb3a3..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 @@ -76,7 +76,7 @@ export function injectContextToHeaders( * @example * ```typescript * const parentCtx = extractContextFromHeaders(req.headers); - * const scope = InvokeAgentScope.start(request, invokeAgentDetails, undefined, { parentContext: parentCtx }); + * const scope = InvokeAgentScope.start(request, scopeDetails, agentDetails, undefined, { parentContext: parentCtx }); * ``` */ export function extractContextFromHeaders( @@ -98,7 +98,7 @@ export function extractContextFromHeaders( * @example * ```typescript * runWithExtractedTraceContext(req.headers, () => { - * const scope = InvokeAgentScope.start({}, invokeAgentDetails); + * const scope = InvokeAgentScope.start(request, scopeDetails, agentDetails); * scope.dispose(); * }); * ``` diff --git a/packages/agents-a365-observability/src/tracing/contracts.ts b/packages/agents-a365-observability/src/tracing/contracts.ts index c82de894..1658fa53 100644 --- a/packages/agents-a365-observability/src/tracing/contracts.ts +++ b/packages/agents-a365-observability/src/tracing/contracts.ts @@ -102,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; @@ -219,17 +216,11 @@ export interface ServiceEndpoint { } /** - * Details for invoking another agent. + * Details for invoking agent scope. */ -export interface InvokeAgentDetails { - /** The agent identity details. */ - details: AgentDetails; - +export interface InvokeAgentScopeDetails { /** The endpoint for the agent invocation */ endpoint?: ServiceEndpoint; - - /** Session ID for the invocation */ - sessionId?: string; } /** diff --git a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts index 621f0275..9d48f1ff 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts @@ -69,9 +69,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, request?.conversationId); - 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, 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 f5abb200..e5d316bc 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts @@ -23,7 +23,7 @@ export class InferenceScope extends OpenTelemetryScope { * @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). + * @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( @@ -69,9 +69,9 @@ export class InferenceScope extends OpenTelemetryScope { 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, request?.conversationId); - 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, 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 552fc81b..2a1767c9 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -75,9 +75,7 @@ export class InvokeAgentScope extends OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, agentDetails.providerName); // Set session ID from request - this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request?.sessionId); - - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, agentDetails.agentBlueprintId); + this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request.sessionId); if (invokeScopeDetails.endpoint) { this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, invokeScopeDetails.endpoint.host); @@ -89,13 +87,12 @@ export class InvokeAgentScope extends OpenTelemetryScope { } // Set channel tags from request - if (request?.channel) { + if (request.channel) { this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel.name); this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel.description); } - // Use explicit conversationId from request, falling back to agentDetails.conversationId - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request?.conversationId ?? agentDetails.conversationId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); // Set caller agent details tags for A2A scenarios const callerAgent = callerDetails?.callerAgentDetails; diff --git a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts index 92513ef7..f3fa579a 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts @@ -88,7 +88,6 @@ 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_UPN_KEY, agentDetails.agentUPN); @@ -235,7 +234,7 @@ export abstract class OpenTelemetryScope implements Disposable { code: SpanStatusCode.ERROR, message }); - this.span.setAttributes({ [OpenTelemetryConstants.ERROR_TYPE_KEY]: OpenTelemetryConstants.ERROR_TYPE_CANCELLED }); + this.errorType = OpenTelemetryConstants.ERROR_TYPE_CANCELLED; } /** diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index 767583a6..b29fb22c 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts @@ -75,9 +75,9 @@ export class OutputScope extends OpenTelemetryScope { ); // Set conversation and channel - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request?.conversationId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request?.channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request?.channel?.description); + 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/output-scope.test.ts b/tests/observability/core/output-scope.test.ts index 8a76b80f..5f0e7a58 100644 --- a/tests/observability/core/output-scope.test.ts +++ b/tests/observability/core/output-scope.test.ts @@ -124,4 +124,12 @@ describe('OutputScope', () => { expect(span.spanContext().traceId).toBe(parentTraceId); expect(span.parentSpanContext?.spanId).toBe(parentSpanId); }); + + it('should throw when request is null', () => { + expect(() => OutputScope.start(null as any, { messages: ['m'] }, testAgentDetails)).toThrow('OutputScope: request is required'); + }); + + 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 6a48adf5..bdb96b4f 100644 --- a/tests/observability/core/parent-span-ref.test.ts +++ b/tests/observability/core/parent-span-ref.test.ts @@ -67,7 +67,6 @@ describe('ParentSpanRef - Explicit Parent Span Support', () => { agentId: 'test-agent', agentName: 'Test Agent', agentDescription: 'A test agent', - conversationId: 'test-conv-123', tenantId: 'test-tenant-456' }; diff --git a/tests/observability/core/scopes.test.ts b/tests/observability/core/scopes.test.ts index 977863ad..51cab8ad 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'; @@ -34,7 +37,6 @@ describe('Scopes', () => { agentId: 'test-agent', agentName: 'Test Agent', agentDescription: 'A test agent', - conversationId: 'test-conv-123', tenantId: 'test-tenant-456' }; @@ -117,6 +119,21 @@ describe('Scopes', () => { 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 scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); @@ -140,12 +157,12 @@ describe('Scopes', () => { scope?.dispose(); }); - it('should set conversationId from explicit param', () => { + 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', conversationId: 'from-details', tenantId: 'test-tenant-456' } + { 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([ @@ -155,21 +172,6 @@ describe('Scopes', () => { spy.mockRestore(); }); - it('should fall back to agent.conversationId when conversationId param is omitted', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = InvokeAgentScope.start( - {}, - {}, - { agentId: 'test-agent', conversationId: 'from-details', 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: 'from-details' }) - ])); - 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( @@ -212,7 +214,6 @@ describe('Scopes', () => { agentId: 'caller-agent', agentName: 'Caller Agent', agentDescription: 'desc', - conversationId: 'conv', platformId: 'caller-platform-xyz' } as any; @@ -248,7 +249,6 @@ describe('Scopes', () => { agentId: 'caller-agent', agentName: 'Caller Agent', agentDescription: 'desc', - conversationId: 'conv', agentClientIP: '192.168.1.100' } as any; @@ -266,6 +266,60 @@ describe('Scopes', () => { scope2?.dispose(); spy.mockRestore(); }); + + it('should throw when agentDetails is null', () => { + expect(() => InvokeAgentScope.start(testRequest, {}, null as any)).toThrow('InvokeAgentScope: agentDetails is required'); + }); + + 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: { callerId: 'user-1', callerName: '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.GEN_AI_CALLER_ID_KEY, val: 'user-1' }), + expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CALLER_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', () => { @@ -406,6 +460,7 @@ describe('Scopes', () => { ); 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(); diff --git a/tests/observability/extension/hosting/scope-utils.test.ts b/tests/observability/extension/hosting/scope-utils.test.ts index 75a0c596..0ce2d125 100644 --- a/tests/observability/extension/hosting/scope-utils.test.ts +++ b/tests/observability/extension/hosting/scope-utils.test.ts @@ -128,18 +128,16 @@ describe('ScopeUtils.populateFromTurnContext', () => { }); test('populateInvokeAgentScopeFromTurnContext throws when tenant details are missing', () => { - const details: InvokeAgentScopeDetails = {}; 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)) + 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: InvokeAgentScopeDetails = {}; 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 = [ @@ -271,7 +269,7 @@ test('deriveChannelObject returns undefined fields when none', () => { expect(ScopeUtils.deriveChannelObject(ctx)).toEqual({ name: undefined, description: undefined }); }); -test('buildAgentDetailsFromContext merges agent (recipient) and conversationId into details', () => { +test('buildInvokeAgentDetails merges agent (recipient) into details', () => { const ctx = makeCtx({ activity: { recipient: { name: 'Rec', role: 'bot' }, @@ -285,16 +283,15 @@ test('buildAgentDetailsFromContext merges agent (recipient) and conversationId i } as any }); - const result = ScopeUtils.buildAgentDetailsFromContext(ctx, testAuthToken); + const result = ScopeUtils.buildInvokeAgentDetails({ agentId: 'provided' } as any, ctx, testAuthToken); // Agent identity is merged into result - expect(result!.agentName).toBe('Rec'); - expect(result!.conversationId).toBe('c-2'); + expect(result.agentName).toBe('Rec'); }); -test('buildAgentDetailsFromContext returns undefined when TurnContext has no recipient', () => { +test('buildInvokeAgentDetails keeps base details when TurnContext has no overrides', () => { const ctx = makeCtx({ activity: {} as any }); - const result = ScopeUtils.buildAgentDetailsFromContext(ctx, testAuthToken); - expect(result).toBeUndefined(); + const result = ScopeUtils.buildInvokeAgentDetails({ agentId: 'base-agent' } as any, ctx, testAuthToken); + expect(result.agentId).toBe('base-agent'); }); describe('ScopeUtils spanKind forwarding', () => { @@ -302,7 +299,7 @@ describe('ScopeUtils spanKind forwarding', () => { const spy = jest.spyOn(InvokeAgentScope, 'start'); const ctx = makeTurnContext('hello', 'web', 'https://web', 'conv-span'); const scope = ScopeUtils.populateInvokeAgentScopeFromTurnContext( - {}, ctx, testAuthToken, + { agentId: 'test-agent' } as any, {}, ctx, testAuthToken, undefined, undefined, SpanKind.SERVER ); expect(spy).toHaveBeenCalledWith( From 55a29bc669c50b5fbc13da524c51089a66fc737e Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 24 Mar 2026 23:33:14 -0700 Subject: [PATCH 16/16] =?UTF-8?q?refactor:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20remove=20tenantDetails=20from=20base=20scope,=20fix?= =?UTF-8?q?=20stale=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove tenantDetails param from OpenTelemetryScope constructor; tenant ID now read directly from agentDetails.tenantId - Remove redundant tenantDetails local variable from all 4 scope subclasses - Remove duplicate operationName tag in InferenceScope (already set by base) - Remove inconsistent request null-check from OutputScope (align with other scopes) - Remove unused imports (ExecutionType, InvokeAgentScopeDetails, TenantDetails) - Fix stale property names in design docs (callerId→userId, callerUpn→userEmail) - Fix duplicate SourceMetadata changelog entry Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 3 +-- docs/design.md | 4 ++-- .../src/utils/ScopeUtils.ts | 4 ++-- packages/agents-a365-observability/docs/design.md | 12 ++++++------ .../src/tracing/scopes/ExecuteToolScope.ts | 4 +--- .../src/tracing/scopes/InferenceScope.ts | 5 +---- .../src/tracing/scopes/InvokeAgentScope.ts | 11 ++--------- .../src/tracing/scopes/OpenTelemetryScope.ts | 14 +++++--------- .../src/tracing/scopes/OutputScope.ts | 9 +-------- tests/observability/core/output-scope.test.ts | 4 ---- tests/observability/core/parent-span-ref.test.ts | 1 - tests/observability/core/scopes.test.ts | 11 +++-------- .../extension/hosting/scope-utils.test.ts | 2 +- 13 files changed, 25 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 679a8fb6..529fd08b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,8 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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. diff --git a/docs/design.md b/docs/design.md index 9e424c3c..d036fd7c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -131,7 +131,7 @@ The foundation for distributed tracing in agent applications. Built on OpenTelem | `InferenceDetails` | Model name, tokens, provider information | | `ToolCallDetails` | Tool name, arguments, endpoint | | `CallerDetails` | Wrapper for caller identity: `userDetails` (human) and/or `callerAgentDetails` (A2A) | -| `UserDetails` | Human caller identification (callerId, callerUpn, callerName, callerClientIp) | +| `UserDetails` | Human caller identification (userId, userEmail, userName, callerClientIp) | | `SpanDetails` | Optional span configuration (parentContext, startTime, endTime, spanKind) | **Usage Example:** @@ -164,7 +164,7 @@ scope.run(() => { { conversationId, channel: { name: 'Teams' } }, // Request { endpoint: { host: 'api.example.com' } }, // InvokeAgentScopeDetails { agentId, agentName, tenantId }, // AgentDetails - { userDetails: { callerId, callerName } } // CallerDetails (optional) + { userDetails: { userId, userName } } // CallerDetails (optional) ); // Agent logic here diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index 38eab869..2e16ab66 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -177,10 +177,10 @@ export class ScopeUtils { /** * Create an `InvokeAgentScope` using `details` and values derived from the provided `TurnContext`. * Builds a separate `Request` with `conversationId` and `channel` from context. - * Merges agent identity from context into `invokeAgentDetails.details`. + * 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 invoke agent call details to be augmented and used for the scope. + * @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). diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index 305d37a3..76bb81b6 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -137,7 +137,7 @@ const scopeDetails: InvokeAgentScopeDetails = { }; const agentDetails: AgentDetails = { agentId: 'agent-123', agentName: 'MyAgent', tenantId: 'tenant-789' }; const callerInfo: CallerDetails = { - userDetails: { callerId: 'user-1', callerName: 'User' }, + userDetails: { userId: 'user-1', userName: 'User' }, callerAgentDetails: callerAgent // Optional, for A2A scenarios }; @@ -218,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(); @@ -244,12 +244,12 @@ 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` | diff --git a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts index 9d48f1ff..e2337d45 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts @@ -43,18 +43,16 @@ export class ExecuteToolScope extends OpenTelemetryScope { userDetails?: UserDetails, spanDetails?: SpanDetails ) { - // Derive tenant details from agentDetails.tenantId (required for telemetry) + // Validate tenantId is present (required for telemetry) if (!agentDetails.tenantId) { throw new Error('ExecuteToolScope: tenantId is required on agentDetails'); } - const tenantDetails = { tenantId: agentDetails.tenantId }; super( spanDetails?.spanKind ?? SpanKind.INTERNAL, OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, `${OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME} ${details.toolName}`, agentDetails, - tenantDetails, spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, diff --git a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts index e5d316bc..0479fab8 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts @@ -43,18 +43,16 @@ export class InferenceScope extends OpenTelemetryScope { userDetails?: UserDetails, spanDetails?: SpanDetails ) { - // Derive tenant details from agentDetails.tenantId (required for telemetry) + // Validate tenantId is present (required for telemetry) if (!agentDetails.tenantId) { throw new Error('InferenceScope: tenantId is required on agentDetails'); } - const tenantDetails = { tenantId: agentDetails.tenantId }; super( SpanKind.CLIENT, details.operationName.toString(), `${details.operationName} ${details.model}`, agentDetails, - tenantDetails, spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, @@ -62,7 +60,6 @@ export class InferenceScope extends OpenTelemetryScope { ); // Set core inference information - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY, details.operationName.toString()); 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); diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts index bce25ac2..1e913f51 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -23,7 +23,7 @@ export class InvokeAgentScope extends OpenTelemetryScope { * @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: { callerId, callerName, ... } }` + * - 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). @@ -46,16 +46,10 @@ export class InvokeAgentScope extends OpenTelemetryScope { callerDetails?: CallerDetails, spanDetails?: SpanDetails ) { - // Validate agent details - if (!agentDetails) { - throw new Error('InvokeAgentScope: agentDetails is required'); - } - - // Derive tenant details from agentDetails.tenantId (required for telemetry) + // Validate tenantId is present (required for telemetry) if (!agentDetails.tenantId) { throw new Error('InvokeAgentScope: tenantId is required on agentDetails'); } - const tenantDetails = { tenantId: agentDetails.tenantId }; super( spanDetails?.spanKind ?? SpanKind.CLIENT, @@ -64,7 +58,6 @@ export class InvokeAgentScope extends OpenTelemetryScope { ? `${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${agentDetails.agentName}` : OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, agentDetails, - tenantDetails, spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, diff --git a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts index 70ff1fe4..070bc307 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts @@ -3,7 +3,7 @@ import { trace, SpanKind, Span, SpanStatusCode, context, AttributeValue, SpanContext, TimeInput } from '@opentelemetry/api'; import { OpenTelemetryConstants } from '../constants'; -import { AgentDetails, TenantDetails, UserDetails } 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,8 +27,7 @@ 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 extractContextFromHeaders} for W3C header propagation). @@ -44,7 +43,6 @@ export abstract class OpenTelemetryScope implements Disposable { operationName: string, spanName: string, agentDetails?: AgentDetails, - tenantDetails?: TenantDetails, parentContext?: ParentContext, startTime?: TimeInput, endTime?: TimeInput, @@ -63,7 +61,7 @@ export abstract class OpenTelemetryScope implements Disposable { } } - 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, { @@ -94,10 +92,8 @@ export abstract class OpenTelemetryScope implements Disposable { 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 (userDetails) { diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index b29fb22c..34107f43 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts @@ -40,16 +40,10 @@ export class OutputScope extends OpenTelemetryScope { userDetails?: UserDetails, spanDetails?: SpanDetails ) { - // Validate request (required for all scopes) - if (!request) { - throw new Error('OutputScope: request is required'); - } - - // Derive tenant details from agentDetails.tenantId (required for telemetry) + // Validate tenantId is present (required for telemetry) if (!agentDetails.tenantId) { throw new Error('OutputScope: tenantId is required on agentDetails'); } - const tenantDetails = { tenantId: agentDetails.tenantId }; super( SpanKind.CLIENT, @@ -58,7 +52,6 @@ export class OutputScope extends OpenTelemetryScope { ? `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentName}` : `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentId}`, agentDetails, - tenantDetails, spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, diff --git a/tests/observability/core/output-scope.test.ts b/tests/observability/core/output-scope.test.ts index 5f0e7a58..4839d5ba 100644 --- a/tests/observability/core/output-scope.test.ts +++ b/tests/observability/core/output-scope.test.ts @@ -125,10 +125,6 @@ describe('OutputScope', () => { expect(span.parentSpanContext?.spanId).toBe(parentSpanId); }); - it('should throw when request is null', () => { - expect(() => OutputScope.start(null as any, { messages: ['m'] }, testAgentDetails)).toThrow('OutputScope: request is required'); - }); - 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 bdb96b4f..7811ce37 100644 --- a/tests/observability/core/parent-span-ref.test.ts +++ b/tests/observability/core/parent-span-ref.test.ts @@ -11,7 +11,6 @@ import { InvokeAgentScope, InferenceScope, ExecuteToolScope, - InvokeAgentScopeDetails, InferenceDetails, InferenceOperationType, ToolCallDetails, diff --git a/tests/observability/core/scopes.test.ts b/tests/observability/core/scopes.test.ts index a9804f6a..156a40c3 100644 --- a/tests/observability/core/scopes.test.ts +++ b/tests/observability/core/scopes.test.ts @@ -80,7 +80,6 @@ describe('Scopes', () => { agentId: 'test-agent', agentName: 'Test Agent', agentDescription: 'A test agent', - conversationId: 'conv-123', iconUri: 'https://example.com/icon.png', tenantId: 'test-tenant-456' }); @@ -267,10 +266,6 @@ describe('Scopes', () => { spy.mockRestore(); }); - it('should throw when agentDetails is null', () => { - expect(() => InvokeAgentScope.start(testRequest, {}, null as any)).toThrow('InvokeAgentScope: agentDetails is required'); - }); - it('should throw when agentDetails.tenantId is missing', () => { expect(() => InvokeAgentScope.start(testRequest, {}, { agentId: 'a' } as any)).toThrow('InvokeAgentScope: tenantId is required on agentDetails'); }); @@ -282,14 +277,14 @@ describe('Scopes', () => { agentName: 'Test Agent', tenantId: 'test-tenant-456' }, { - userDetails: { callerId: 'user-1', callerName: 'User One' }, + 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.GEN_AI_CALLER_ID_KEY, val: 'user-1' }), - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CALLER_NAME_KEY, val: 'User One' }), + 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' }), ])); diff --git a/tests/observability/extension/hosting/scope-utils.test.ts b/tests/observability/extension/hosting/scope-utils.test.ts index b8a796ab..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, InvokeAgentScopeDetails } 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';