Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Breaking Changes (`@microsoft/agents-a365-observability-hosting`)

- **`ScopeUtils.deriveAgentDetails(turnContext, authToken)`** — New required `authToken: string` parameter.
- **`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.

### Added
- **OutputScope**: Tracing scope for outgoing agent messages with caller details, conversation ID, source metadata, and parent span linking.
- **BaggageMiddleware**: Middleware for automatic OpenTelemetry baggage propagation from TurnContext.
- **OutputLoggingMiddleware**: Middleware that creates OutputScope spans for outgoing messages with lazy parent span linking via `A365_PARENT_SPAN_KEY`.
- **ObservabilityHostingManager**: Manager for configuring hosting-layer observability middleware with `ObservabilityHostingOptions`.

### Changed
- `ScopeUtils.deriveAgentDetails` now resolves `agentId` via `activity.getAgenticInstanceId()` for embodied (agentic) agents only. For non-embodied agents, `agentId` is `undefined` since the token's app ID cannot reliably be attributed to the agent.
- `ScopeUtils.deriveAgentDetails` now resolves `agentBlueprintId` from the JWT `xms_par_app_azp` claim via `RuntimeUtility.getAgentIdFromToken()` instead of reading `recipient.agenticAppBlueprintId`.
- `ScopeUtils.deriveAgentDetails` now resolves `agentUPN` via `activity.getAgenticUser()` instead of `recipient.agenticUserId`.
- `ScopeUtils.deriveTenantDetails` now uses `activity.getAgenticTenantId()` instead of `recipient.tenantId`.
- `getTargetAgentBaggagePairs` now uses `activity.getAgenticInstanceId()` instead of `recipient.agenticAppId`.
- `getTenantIdPair` now uses `activity.getAgenticTenantId()` instead of manual `channelData` parsing.
- `InferenceScope.recordInputMessages()` / `recordOutputMessages()` now use JSON array format instead of comma-separated strings.
- `InvokeAgentScope.recordInputMessages()` / `recordOutputMessages()` now use JSON array format instead of comma-separated strings.

Expand Down
2 changes: 1 addition & 1 deletion packages/agents-a365-observability-hosting/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ export * from './utils/ScopeUtils';
export * from './utils/TurnContextUtils';
export { AgenticTokenCache, AgenticTokenCacheInstance } from './caching/AgenticTokenCache';
export { BaggageMiddleware } from './middleware/BaggageMiddleware';
export { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY } from './middleware/OutputLoggingMiddleware';
export { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY, A365_AUTH_TOKEN_KEY } from './middleware/OutputLoggingMiddleware';
export { ObservabilityHostingManager } from './middleware/ObservabilityHostingManager';
export type { ObservabilityHostingOptions } from './middleware/ObservabilityHostingManager';
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,24 @@ import {
CallerDetails,
ParentSpanRef,
logger,
isPerRequestExportEnabled,
} from '@microsoft/agents-a365-observability';
import { ScopeUtils } from '../utils/ScopeUtils';
import { AgenticTokenCacheInstance } from '../caching/AgenticTokenCache';

/**
* TurnState key for the parent span reference.
* Set this in `turnState` to link OutputScope spans as children of an InvokeAgentScope.
*/
export const A365_PARENT_SPAN_KEY = 'A365ParentSpanId';

/**
* TurnState key for the auth token.
* Set this in `turnState` so middleware can resolve the agent blueprint ID
* from token claims (used for embodied/agentic requests).
*/
export const A365_AUTH_TOKEN_KEY = 'A365AuthToken';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to reuse the TokenCache?
If possible id dont want to introduce another way to access the token.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, we might not know if the token for non‑embodied agent case has app id or not or the appid in the token claim is the right id for the agent or not. For implication and before that question is answered, I will not make any change to use the cached token.

Comment on lines +23 to +28
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment for A365_AUTH_TOKEN_KEY says the token is used “when the request is not an agentic request”, but ScopeUtils.deriveAgentDetails/resolveEmbodiedAgentIds currently only use authToken when activity.isAgenticRequest() is true (blueprint id resolution). Please update the comment (or the logic) so the intended usage matches the implementation.

Copilot uses AI. Check for mistakes.

/**
* Middleware that creates {@link OutputScope} spans for outgoing messages.
* Links to a parent span when {@link A365_PARENT_SPAN_KEY} is set in turnState.
Expand All @@ -28,7 +37,8 @@ export const A365_PARENT_SPAN_KEY = 'A365ParentSpanId';
export class OutputLoggingMiddleware implements Middleware {

async onTurn(context: TurnContext, next: () => Promise<void>): Promise<void> {
const agentDetails = ScopeUtils.deriveAgentDetails(context);
const authToken = this.resolveAuthToken(context);
const agentDetails = ScopeUtils.deriveAgentDetails(context, authToken);
const tenantDetails = ScopeUtils.deriveTenantDetails(context);

if (!agentDetails || !tenantDetails) {
Expand All @@ -47,6 +57,23 @@ export class OutputLoggingMiddleware implements Middleware {
await next();
}

/**
* Resolve the auth token for agent identity resolution.
* When per-request export is enabled, reads from turnState.
* Otherwise, reads from the cached observability token.
*/
private resolveAuthToken(context: TurnContext): string {
if (isPerRequestExportEnabled()) {
return context.turnState.get(A365_AUTH_TOKEN_KEY) as string ?? '';
}
const agentId = context.activity?.getAgenticInstanceId?.() ?? '';
const tenantId = context.activity?.getAgenticTenantId?.() ?? '';
if (agentId && tenantId) {
return AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId) ?? '';
}
return '';
}

/**
* Creates a send handler that wraps outgoing messages in OutputScope spans.
* Reads parent span ref lazily so the agent handler can set it during `next()`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
InvokeAgentDetails,
ToolCallDetails,
} from '@microsoft/agents-a365-observability';
import { resolveEmbodiedAgentIds } from './TurnContextUtils';

/**
* Unified utilities to populate scope tags from a TurnContext.
Expand All @@ -40,26 +41,30 @@ export class ScopeUtils {
* @returns Tenant details if a recipient tenant id is present; otherwise undefined.
*/
public static deriveTenantDetails(turnContext: TurnContext): TenantDetails | undefined {
const tenantId = turnContext?.activity?.recipient?.tenantId;
const tenantId = turnContext?.activity?.getAgenticTenantId?.();
return tenantId ? { tenantId } : undefined;
Comment on lines 43 to 45
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deriveTenantDetails calls activity.getAgenticTenantId() directly. If callers pass a TurnContext whose activity is a plain object (common in tests/mocks) and the helper isn’t attached, this will throw. Consider turnContext?.activity?.getAgenticTenantId?.() (and a fallback if needed).

Copilot uses AI. Check for mistakes.
}

/**
* Derive target agent details from the activity recipient.
* Uses {@link resolveEmbodiedAgentIds} to resolve the agent ID and blueprint ID, which are only
* set for embodied (agentic) agents — see that function for the rationale.
* @param turnContext Activity context
* @param authToken Auth token for resolving agent identity from token claims.
* @returns Agent details built from recipient properties; otherwise undefined.
*/
public static deriveAgentDetails(turnContext: TurnContext): AgentDetails | undefined {
public static deriveAgentDetails(turnContext: TurnContext, authToken: string): AgentDetails | undefined {
const recipient = turnContext?.activity?.recipient;
if (!recipient) return undefined;
const { agentId, agentBlueprintId } = resolveEmbodiedAgentIds(turnContext, authToken);
return {
agentId: recipient.agenticAppId,
agentId,
agentName: recipient.name,
agentAUID: recipient.aadObjectId,
agentBlueprintId: recipient.agenticAppBlueprintId,
agentUPN: recipient.agenticUserId,
agentBlueprintId,
agentUPN: turnContext?.activity?.getAgenticUser?.(),
agentDescription: recipient.role,
tenantId: recipient.tenantId
tenantId: turnContext?.activity?.getAgenticTenantId?.()
} as AgentDetails;
}

Expand Down Expand Up @@ -127,17 +132,19 @@ export class ScopeUtils {
* 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.
* @param authToken Auth token for resolving agent identity from token claims.
* @param startTime Optional explicit start time (ms epoch, Date, or HrTime).
* @param endTime Optional explicit end time (ms epoch, Date, or HrTime).
* @returns A started `InferenceScope` enriched with context-derived parameters.
*/
static populateInferenceScopeFromTurnContext(
details: InferenceDetails,
turnContext: TurnContext,
authToken: string,
startTime?: TimeInput,
endTime?: TimeInput
): InferenceScope {
const agent = ScopeUtils.deriveAgentDetails(turnContext);
const agent = ScopeUtils.deriveAgentDetails(turnContext, authToken);
const tenant = ScopeUtils.deriveTenantDetails(turnContext);
const conversationId = ScopeUtils.deriveConversationId(turnContext);
const sourceMetadata = ScopeUtils.deriveSourceMetadataObject(turnContext);
Expand All @@ -161,20 +168,22 @@ export class ScopeUtils {
* 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.
* @param authToken Auth token for resolving agent identity from token claims.
* @param startTime Optional explicit start time (ms epoch, Date, or HrTime).
* @param endTime Optional explicit end time (ms epoch, Date, or HrTime).
* @returns A started `InvokeAgentScope` enriched with context-derived parameters.
*/
static populateInvokeAgentScopeFromTurnContext(
details: InvokeAgentDetails,
turnContext: TurnContext,
authToken: string,
startTime?: TimeInput,
endTime?: TimeInput
): InvokeAgentScope {
const tenant = ScopeUtils.deriveTenantDetails(turnContext);
const callerAgent = ScopeUtils.deriveCallerAgent(turnContext);
const caller = ScopeUtils.deriveCallerDetails(turnContext);
const invokeAgentDetails = ScopeUtils.buildInvokeAgentDetails(details, turnContext);
const invokeAgentDetails = ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken);

if (!tenant) {
throw new Error('populateInvokeAgentScopeFromTurnContext: Missing tenant details on TurnContext (recipient)');
Expand All @@ -189,10 +198,15 @@ export class ScopeUtils {
* Build InvokeAgentDetails by merging provided details with agent info, conversation id and source metadata 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.
*/
public static buildInvokeAgentDetails(details: InvokeAgentDetails, turnContext: TurnContext): InvokeAgentDetails {
const agent = ScopeUtils.deriveAgentDetails(turnContext);
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 {
const agent = ScopeUtils.deriveAgentDetails(turnContext, authToken);
const srcMetaFromContext = ScopeUtils.deriveSourceMetadataObject(turnContext);
const baseRequest = details.request ?? {};
const baseSource = baseRequest.sourceMetadata ?? {};
Expand All @@ -217,6 +231,7 @@ export class ScopeUtils {
* Derives `agentDetails`, `tenantDetails`, `conversationId`, and `sourceMetadata` (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.
* @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).
Expand All @@ -225,10 +240,11 @@ export class ScopeUtils {
static populateExecuteToolScopeFromTurnContext(
details: ToolCallDetails,
turnContext: TurnContext,
authToken: string,
startTime?: TimeInput,
endTime?: TimeInput
): ExecuteToolScope {
const agent = ScopeUtils.deriveAgentDetails(turnContext);
const agent = ScopeUtils.deriveAgentDetails(turnContext, authToken);
const tenant = ScopeUtils.deriveTenantDetails(turnContext);
const conversationId = ScopeUtils.deriveConversationId(turnContext);
const sourceMetadata = ScopeUtils.deriveSourceMetadataObject(turnContext);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { TurnContext } from '@microsoft/agents-hosting';
import { ExecutionType, OpenTelemetryConstants } from '@microsoft/agents-a365-observability';
import {RoleTypes} from '@microsoft/agents-activity';
import { Utility as RuntimeUtility } from '@microsoft/agents-a365-runtime';

/**
* TurnContext utility methods.
Expand Down Expand Up @@ -64,17 +65,37 @@ export function getExecutionTypePair(turnContext: TurnContext): Array<[string, s
return [[OpenTelemetryConstants.GEN_AI_EXECUTION_TYPE_KEY, executionType]];
}

/**
* Resolves the agent instance ID and blueprint ID for embodied (agentic) agents only.
* For the non-embodied agent case, we cannot reliably determine whether the token contains an app ID,
* or whether the app ID present in the token claims actually corresponds to this agent. Therefore,
* we only set agentId and agentBlueprintId for embodied (agentic) agents.
* @param turnContext Activity context
* @param authToken Auth token for resolving blueprint ID from token claims.
* @returns Object with agentId and agentBlueprintId, both undefined for non-embodied agents.
*/
export function resolveEmbodiedAgentIds(turnContext: TurnContext, authToken: string): { agentId: string | undefined; agentBlueprintId: string | undefined } {
const isAgentic = turnContext?.activity?.isAgenticRequest?.();
const rawAgentId = isAgentic ? turnContext.activity.getAgenticInstanceId?.() : undefined;
const rawBlueprintId = isAgentic ? RuntimeUtility.getAgentIdFromToken(authToken) : undefined;
return {
agentId: rawAgentId || undefined,
agentBlueprintId: rawBlueprintId || undefined,
};
}

/**
* Extracts agent/recipient-related OpenTelemetry baggage pairs from the TurnContext.
* @param turnContext The current TurnContext (activity context)
* @param authToken Optional auth token for resolving agent blueprint ID from token claims.
* @returns Array of [key, value] pairs for agent identity and description
*/
export function getTargetAgentBaggagePairs(turnContext: TurnContext): Array<[string, string]> {
if (!turnContext || !turnContext.activity?.recipient) {
export function getTargetAgentBaggagePairs(turnContext: TurnContext, authToken?: string): Array<[string, string]> {
if (!turnContext || !turnContext.activity?.recipient) {
return [];
}
const recipient = turnContext.activity.recipient;
const agentId = recipient.agenticAppId;
const recipient = turnContext.activity.recipient;
const { agentId } = authToken ? resolveEmbodiedAgentIds(turnContext, authToken) : { agentId: turnContext.activity?.isAgenticRequest?.() ? turnContext.activity.getAgenticInstanceId?.() : undefined };
const agentName = recipient.name;
const aadObjectId = recipient.aadObjectId;
const agentDescription = recipient.role;
Expand All @@ -88,29 +109,12 @@ export function getTargetAgentBaggagePairs(turnContext: TurnContext): Array<[str
}

/**
* Extracts the tenant ID baggage key-value pair, attempting to retrieve from ChannelData if necessary.
* Extracts the tenant ID baggage key-value pair using the Activity's getAgenticTenantId() helper.
* @param turnContext The current TurnContext (activity context)
* @returns Array of [key, value] for tenant ID
*/
export function getTenantIdPair(turnContext: TurnContext): Array<[string, string]> {
let tenantId = turnContext.activity?.recipient?.tenantId;


// If not found, try to extract from channelData. Accepts both object and JSON string.
if (!tenantId && turnContext.activity?.channelData) {
try {
let channelData: unknown = turnContext.activity.channelData;
if (typeof channelData === 'string') {
channelData = JSON.parse(channelData);
}
if (
typeof channelData === 'object' && channelData !== null) {
tenantId = (channelData as { tenant: { id?: string } })?.tenant?.id;
}
} catch (_err) {
// ignore JSON parse errors
}
}
const tenantId = turnContext.activity?.getAgenticTenantId?.();
return tenantId ? [[OpenTelemetryConstants.TENANT_ID_KEY, tenantId]] : [];
}

Expand Down
3 changes: 3 additions & 0 deletions packages/agents-a365-observability/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,8 @@ export { OutputScope } from './tracing/scopes/OutputScope';
export { logger, setLogger, getLogger, resetLogger, formatError } from './utils/logging';
export type { ILogger } from './utils/logging';

// Exporter utilities
export { isPerRequestExportEnabled } from './tracing/exporter/utils';

// Configuration
export * from './configuration';
Loading