diff --git a/docs/design.md b/docs/design.md index d036fd7c..86924410 100644 --- a/docs/design.md +++ b/docs/design.md @@ -120,6 +120,7 @@ The foundation for distributed tracing in agent applications. Built on OpenTelem | `InferenceScope` | Trace LLM/AI model inference calls | | `ExecuteToolScope` | Trace tool execution operations | | `BaggageBuilder` | Fluent API for context propagation across async boundaries | +| `ResolvedInvocationIdentity` | Process-local validated caller and target identity for span enrichment | **Data Classes:** @@ -181,6 +182,8 @@ Framework-specific instrumentations that integrate with the observability core: | `observability-extensions-openai` | Instrument OpenAI Agents SDK | [design.md](../packages/agents-a365-observability-extensions-openai/docs/design.md) | | `observability-hosting` | Hosting-specific observability utilities | [design.md](../packages/agents-a365-observability-hosting/docs/design.md) | +The hosting package can opt into automatic invocation identity resolution through `ObservabilityHostingManager.configure(adapter, { enableInvocationIdentity: true })`. Resolved identity remains process-local and is applied to core and extension spans by the observability span processor. + ### 4. Tooling (`@microsoft/agents-a365-tooling`) > **Detailed documentation**: [packages/agents-a365-tooling/docs/design.md](../packages/agents-a365-tooling/docs/design.md) diff --git a/packages/agents-a365-observability-hosting/docs/design.md b/packages/agents-a365-observability-hosting/docs/design.md index c422f227..85657178 100644 --- a/packages/agents-a365-observability-hosting/docs/design.md +++ b/packages/agents-a365-observability-hosting/docs/design.md @@ -4,7 +4,9 @@ This document describes the architecture and design of the `@microsoft/agents-a3 ## Overview -The observability hosting package provides hosting-specific utilities for integrating observability with the Microsoft Agents Hosting SDK. It bridges the gap between `TurnContext` and OpenTelemetry baggage/scope creation. +The observability hosting package provides hosting-specific utilities for integrating observability with the Microsoft Agents Hosting SDK. It bridges the gap between `TurnContext`, request-local invocation identity, and OpenTelemetry baggage/scope creation. + +See [Automatic Invocation Identity](invocation-identity.md) for the opt-in caller and target identity feature. ## Architecture @@ -24,6 +26,11 @@ The observability hosting package provides hosting-specific utilities for integr │ │ Extract baggage │───▶│ Populate builder │ │ │ │ pairs from context │ │ from context │ │ │ └─────────────────────┘ └─────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ InvocationIdentityMiddleware + Resolver │ │ +│ │ Validated claims/Activity → private OTel context │ │ +│ └───────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ │ ▼ @@ -35,6 +42,18 @@ The observability hosting package provides hosting-specific utilities for integr ## Key Components +### InvocationIdentityMiddleware + +The opt-in middleware resolves identity once per turn, enters a private OpenTelemetry context, and runs downstream middleware and agent logic inside that context. It never places resolved identity into W3C baggage. + +```typescript +new ObservabilityHostingManager().configure(adapter, { + enableInvocationIdentity: true, +}); +``` + +The core `SpanProcessor` reads the resolved identity from the supplied parent context and stamps human, caller-agent, target-agent, tenant, and invocation-role attributes onto every local span. + ### BaggageBuilderUtils ([BaggageBuilderUtils.ts](../src/utils/BaggageBuilderUtils.ts)) Utilities to populate `BaggageBuilder` from a `TurnContext`: @@ -183,6 +202,12 @@ async function onMessage(turnContext: TurnContext, turnState: TurnState) { ``` src/ ├── index.ts # Public API exports +├── identity/ +│ └── InvocationIdentityResolver.ts # Trusted identity classification +├── middleware/ +│ ├── InvocationIdentityMiddleware.ts # Per-turn identity context +│ ├── BaggageMiddleware.ts +│ └── OutputLoggingMiddleware.ts ├── utils/ │ ├── BaggageBuilderUtils.ts # BaggageBuilder population utilities │ ├── TurnContextUtils.ts # Low-level extraction functions @@ -196,4 +221,4 @@ src/ - `@microsoft/agents-a365-observability` - BaggageBuilder, OpenTelemetryConstants - `@microsoft/agents-a365-runtime` - Runtime utilities - `@microsoft/agents-hosting` - TurnContext type -- `@microsoft/agents-activity` - RoleTypes enum +- `@microsoft/agents-activity` - Activity and role contracts diff --git a/packages/agents-a365-observability-hosting/docs/invocation-identity.md b/packages/agents-a365-observability-hosting/docs/invocation-identity.md new file mode 100644 index 00000000..0662b4c7 --- /dev/null +++ b/packages/agents-a365-observability-hosting/docs/invocation-identity.md @@ -0,0 +1,139 @@ +# Automatic Invocation Identity + +Automatic invocation identity enrichment resolves the caller and target identities for a hosted agent turn and adds them to every OpenTelemetry span created in that turn. + +## Enable the feature + +The feature is opt-in: + +```typescript +new ObservabilityHostingManager().configure(adapter, { + enableInvocationIdentity: true, +}); +``` + +Omitting `enableInvocationIdentity` and setting it to `false` are equivalent. Resolver options do not enable the feature. Baggage and output logging remain separately controlled: + +```typescript +new ObservabilityHostingManager().configure(adapter, { + enableInvocationIdentity: true, + enableBaggage: true, + enableOutputLogging: true, +}); +``` + +Set `enableInvocationIdentity: false` to roll back without changing legacy baggage, output logging, or scope-helper behavior. + +## Trust boundary + +The hosting middleware assumes the standard `authorizeJWT` hosting path and therefore defaults `turnContextIdentityTrustSource` to `StandardAuthorizeJwt`. The middleware only reads `TurnContext.identity` and Activity identity fields when the identity is a nonempty object and is not the anonymous development identity. + +Custom hosts must disable that assumption and provide an already-validated principal: + +```typescript +new ObservabilityHostingManager().configure(adapter, { + enableInvocationIdentity: true, + turnContextIdentityTrustSource: TurnContextIdentityTrustSource.None, + resolveValidatedPrincipal: async (turnContext) => { + return validatePrincipalFromCustomHost(turnContext); + }, +}); +``` + +The resolver never decodes the raw authorization token. It does not use `sub`, email, UPN, `from.id`, channel IDs, or the target agent as a caller identity. + +## Classification + +Validated claims use the following classification: + +| Evidence | Role and caller identity | +| --- | --- | +| `idtyp=user`, valid `oid`, delegated `scp`, no Agent ID marker | Human; `oid` becomes `user.id` | +| Above with `xms_sub_fct` containing `13` | Agent; `oid` becomes `microsoft.a365.caller.agent.user.id` | +| User token with `xms_act_fct` containing `11` or `xms_par_app_azp` | Agent/OBO; `oid` remains the human and the parent app becomes the caller blueprint | +| `idtyp=app`, nonempty `roles`, and an Agent ID marker | Agent; the parent app becomes the caller blueprint | +| Ordinary service application | No trusted caller classification | + +Trusted Activity roles are normalized by removing whitespace, `_`, and `-`, then lowercasing: + +| Activity role | Classification | +| --- | --- | +| `user` | Human | +| `bot`, `skill`, `agent`, `agenticAppInstance`, `agenticUser` | Agent | +| Missing or unsupported | Unknown | +| Event activity | Unknown unless explicitly configured | + +`ContinueConversation` is not automatically classified as Event. Configure `invocationRole: InvocationRole.Event` for a known autonomous event. + +## Precedence + +Role precedence: + +1. Explicit `invocationRole` +2. Application or hosting validated principal +3. Trusted non-Event Activity role +4. Unknown + +Caller-field precedence is application principal, hosting principal, then Activity. Target-field precedence is the current trusted Activity followed by configured target fallback. + +Conflicts are reported through `onIdentityConflict`. The callback receives the field, normalized values, and the winning and losing resolution sources. + +## A2A, OBO, and agent-user identity + +- Direct human calls use `user.id`. +- An OBO call preserves both `user.id` and the immediate caller-agent blueprint. +- An agent-user call uses `microsoft.a365.caller.agent.user.id`, never `user.id`. +- App-only callers use caller instance or blueprint attributes when available. +- Event calls carry target execution identity and do not fabricate a caller. + +Manual `CallerDetails` remain an escape hatch. Explicit nonblank scope values override automatically resolved values. + +## Context and baggage + +Resolved identity is stored in a private process-local OpenTelemetry context key. It is not added to W3C baggage. + +When local identity exists, baggage cannot provide or overwrite these fields: + +- `microsoft.a365.invocation.role` +- `microsoft.tenant.id` +- `gen_ai.agent.id` +- `microsoft.agent.user.id` +- `microsoft.a365.agent.blueprint.id` +- `user.id` +- `microsoft.a365.caller.agent.id` +- `microsoft.a365.caller.agent.user.id` +- `microsoft.a365.caller.agent.blueprint.id` + +Display metadata continues to use legacy behavior. + +## Validation and diagnostics + +All identity join IDs must be non-nil UUIDs and are normalized to lowercase. Strict validation can be enabled with `strictIdentityValidation: true`. Without strict validation, missing identity warns and the turn continues. + +Identity-enriched `invoke_agent` spans warn for: + +- `missing_human_identity` +- `missing_agent_identity` +- `unknown_invocation_role` +- `missing_event_execution_identity` + +Warnings use `console.warn` so they remain visible when the SDK log level is `none`. Repeated warnings are deduplicated by tenant, target agent, and reason. + +## Backend normalization + +The telemetry backend should compute: + +```text +effectivePrincipalId = + user.id + ?? microsoft.a365.caller.agent.user.id + ?? microsoft.a365.caller.agent.id + ?? microsoft.a365.caller.agent.blueprint.id + ?? (invocation.role == Event + ? microsoft.agent.user.id + ?? gen_ai.agent.id + ?? microsoft.a365.agent.blueprint.id + : undefined) +``` + +The backend must retain `principalType` and `principalSource`. Event target fallback uses `principalSource=target-agent`. This backend normalization is not implemented by the Node.js SDK. diff --git a/packages/agents-a365-observability-hosting/package.json b/packages/agents-a365-observability-hosting/package.json index 238e2dc7..d96fe590 100644 --- a/packages/agents-a365-observability-hosting/package.json +++ b/packages/agents-a365-observability-hosting/package.json @@ -26,7 +26,8 @@ "files": [ "dist", "README.md", - "CHANGELOG.md" + "CHANGELOG.md", + "docs" ], "scripts": { "build": "npm run build:cjs && npm run build:esm", @@ -44,6 +45,7 @@ "dependencies": { "@microsoft/agents-a365-observability": "workspace:*", "@microsoft/agents-a365-runtime": "workspace:*", + "@microsoft/agents-activity": "catalog:", "@microsoft/agents-hosting": "catalog:", "@opentelemetry/api": "catalog:" }, diff --git a/packages/agents-a365-observability-hosting/src/identity/InvocationIdentityResolver.ts b/packages/agents-a365-observability-hosting/src/identity/InvocationIdentityResolver.ts new file mode 100644 index 00000000..0a0ddebf --- /dev/null +++ b/packages/agents-a365-observability-hosting/src/identity/InvocationIdentityResolver.ts @@ -0,0 +1,571 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ActivityTypes } from '@microsoft/agents-activity'; +import { TurnContext } from '@microsoft/agents-hosting'; +import { + InvocationIdentityResolutionSource, + InvocationRole, + ResolvedInvocationIdentity, +} from '@microsoft/agents-a365-observability'; + +const UUID_PATTERN = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i; +const NIL_UUID = '00000000-0000-0000-0000-000000000000'; +const AGENT_IDENTITY_FACET = '11'; +const AGENT_USER_FACET = '13'; + +export interface ValidatedInvocationPrincipal { + role?: InvocationRole; + humanOid?: string; + callerAgentUserOid?: string; + callerAgentBlueprintId?: string; + callerAgentInstanceId?: string; +} + +export interface TargetInvocationIdentity { + agentId?: string; + agentAuid?: string; + agentBlueprintId?: string; + tenantId?: string; +} + +export enum TurnContextIdentityTrustSource { + None = 'none', + StandardAuthorizeJwt = 'standard_authorize_jwt', +} + +export type InvocationIdentityField = + | 'role' + | 'humanOid' + | 'callerAgentUserOid' + | 'callerAgentBlueprintId' + | 'callerAgentInstanceId' + | 'targetAgentId' + | 'targetAgentBlueprintId' + | 'targetAgentAuid' + | 'tenantId'; + +export interface InvocationIdentityConflict { + field: InvocationIdentityField; + winningSource: InvocationIdentityResolutionSource; + losingSource: InvocationIdentityResolutionSource; + winningValue: string; + losingValue: string; +} + +export interface InvocationIdentityResolverOptions { + turnContextIdentityTrustSource?: TurnContextIdentityTrustSource; + validatedPrincipal?: ValidatedInvocationPrincipal; + invocationRole?: InvocationRole; + targetIdentity?: TargetInvocationIdentity; + onConflict?: (conflict: InvocationIdentityConflict) => void; +} + +export enum InvocationIdentityValidationCode { + MissingHumanIdentity = 'missing_human_identity', + MissingAgentIdentity = 'missing_agent_identity', + UnknownInvocationRole = 'unknown_invocation_role', + MissingEventExecutionIdentity = 'missing_event_execution_identity', +} + +export class InvocationIdentityValidationError extends Error { + constructor( + public readonly code: InvocationIdentityValidationCode, + message: string, + ) { + super(message); + this.name = 'InvocationIdentityValidationError'; + } +} + +interface Candidate { + value?: T; + source: InvocationIdentityResolutionSource; +} + +interface SelectedCandidate { + value: T; + source: InvocationIdentityResolutionSource; +} + +interface ActivityIdentityEvidence extends ValidatedInvocationPrincipal { + targetAgentId?: string; + targetAgentBlueprintId?: string; + targetAgentAuid?: string; + tenantId?: string; +} + +function normalizeUuid(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + if (!UUID_PATTERN.test(normalized) || normalized === NIL_UUID) { + return undefined; + } + + return normalized; +} + +function normalizeInvocationRole(value: unknown): InvocationRole | undefined { + if (typeof value !== 'string') { + return undefined; + } + + return Object.values(InvocationRole).find( + role => role.toLowerCase() === value.trim().toLowerCase(), + ); +} + +function hasNonEmptyClaim(value: unknown): boolean { + if (typeof value === 'string') { + return value.trim().length > 0; + } + + return Array.isArray(value) && value.some(item => typeof item === 'string' && item.trim().length > 0); +} + +function hasFacet(value: unknown, facet: string): boolean { + if (typeof value === 'string') { + return value.split(/\s+/).includes(facet); + } + + return Array.isArray(value) && value.some(item => String(item) === facet); +} + +function getTrustedClaims( + turnContext: TurnContext, + trustSource: TurnContextIdentityTrustSource, +): Record | undefined { + if (trustSource !== TurnContextIdentityTrustSource.StandardAuthorizeJwt) { + return undefined; + } + + const identity: unknown = turnContext?.identity; + if (typeof identity !== 'object' || identity === null || Array.isArray(identity)) { + return undefined; + } + + const claims = identity as Record; + const keys = Object.keys(claims); + if (keys.length === 0 || (keys.length === 1 && claims.name === 'anonymous')) { + return undefined; + } + + return claims; +} + +function normalizePrincipal( + principal: ValidatedInvocationPrincipal | undefined, +): ValidatedInvocationPrincipal | undefined { + if (!principal) { + return undefined; + } + + return { + role: normalizeInvocationRole(principal.role), + humanOid: normalizeUuid(principal.humanOid), + callerAgentUserOid: normalizeUuid(principal.callerAgentUserOid), + callerAgentBlueprintId: normalizeUuid(principal.callerAgentBlueprintId), + callerAgentInstanceId: normalizeUuid(principal.callerAgentInstanceId), + }; +} + +function normalizeTargetIdentity( + identity: TargetInvocationIdentity | undefined, +): ActivityIdentityEvidence { + return { + targetAgentId: normalizeUuid(identity?.agentId), + targetAgentAuid: normalizeUuid(identity?.agentAuid), + targetAgentBlueprintId: normalizeUuid(identity?.agentBlueprintId), + tenantId: normalizeUuid(identity?.tenantId), + }; +} + +function selectCandidate( + field: InvocationIdentityField, + candidates: Array>, + onConflict?: (conflict: InvocationIdentityConflict) => void, +): SelectedCandidate | undefined { + const available = candidates.filter( + (candidate): candidate is SelectedCandidate => + typeof candidate.value === 'string' && candidate.value.trim().length > 0, + ); + const winner = available[0]; + + if (!winner) { + return undefined; + } + + for (const loser of available.slice(1)) { + if (loser.value !== winner.value) { + onConflict?.({ + field, + winningSource: winner.source, + losingSource: loser.source, + winningValue: winner.value, + losingValue: loser.value, + }); + } + } + + return winner; +} + +function normalizeActivityRole(role: unknown): string | undefined { + if (typeof role !== 'string') { + return undefined; + } + + const normalized = role.replace(/[\s_-]/g, '').toLowerCase(); + return normalized.length > 0 ? normalized : undefined; +} + +function resolveActivityIdentityEvidence( + turnContext: TurnContext, + trustSource: TurnContextIdentityTrustSource, +): ActivityIdentityEvidence { + if (!getTrustedClaims(turnContext, trustSource)) { + return {}; + } + + const activity = turnContext.activity; + const from = activity?.from; + const recipient = activity?.recipient; + const normalizedRole = normalizeActivityRole(from?.role); + const isEvent = activity?.type === ActivityTypes.Event; + const isAgentRole = normalizedRole !== undefined && [ + 'bot', + 'skill', + 'agent', + 'agenticappinstance', + 'agenticuser', + ].includes(normalizedRole); + + let role = InvocationRole.Unknown; + if (!isEvent && normalizedRole === 'user') { + role = InvocationRole.Human; + } else if (!isEvent && isAgentRole) { + role = InvocationRole.Agent; + } + + const isAgenticUser = normalizedRole === 'agenticuser'; + const hasAgentInstance = isAgenticUser || normalizedRole === 'agenticappinstance'; + const isAgenticRequest = activity?.isAgenticRequest?.() === true; + + return { + role, + humanOid: role === InvocationRole.Human + ? normalizeUuid(from?.aadObjectId) + : undefined, + callerAgentUserOid: isAgenticUser + ? normalizeUuid(from?.aadObjectId) + : undefined, + callerAgentBlueprintId: role === InvocationRole.Agent + ? normalizeUuid(from?.agenticAppBlueprintId) + : undefined, + callerAgentInstanceId: hasAgentInstance + ? normalizeUuid(from?.agenticAppId) + : undefined, + targetAgentId: isAgenticRequest + ? normalizeUuid(activity?.getAgenticInstanceId?.()) + : undefined, + targetAgentAuid: normalizeUuid(recipient?.aadObjectId), + targetAgentBlueprintId: normalizeUuid(recipient?.agenticAppBlueprintId), + tenantId: normalizeUuid(activity?.getAgenticTenantId?.() ?? recipient?.tenantId), + }; +} + +export function resolveValidatedPrincipalFromTurnContext( + turnContext: TurnContext, + trustSource: TurnContextIdentityTrustSource = TurnContextIdentityTrustSource.None, +): ValidatedInvocationPrincipal | undefined { + const claims = getTrustedClaims(turnContext, trustSource); + if (!claims) { + return undefined; + } + + const idtyp = typeof claims.idtyp === 'string' + ? claims.idtyp.trim().toLowerCase() + : undefined; + const oid = normalizeUuid(claims.oid); + const callerBlueprintId = normalizeUuid(claims.xms_par_app_azp); + const subjectIsAgentUser = hasFacet(claims.xms_sub_fct, AGENT_USER_FACET); + const subjectIsAgentIdentity = hasFacet(claims.xms_sub_fct, AGENT_IDENTITY_FACET); + const actorIsAgentIdentity = hasFacet(claims.xms_act_fct, AGENT_IDENTITY_FACET); + const hasUserAgentMarker = subjectIsAgentUser + || subjectIsAgentIdentity + || actorIsAgentIdentity + || callerBlueprintId !== undefined; + const hasAppAgentMarker = subjectIsAgentIdentity || actorIsAgentIdentity; + + if (idtyp === 'user' && oid && hasNonEmptyClaim(claims.scp)) { + if (subjectIsAgentUser) { + return { + role: InvocationRole.Agent, + callerAgentUserOid: oid, + callerAgentBlueprintId: callerBlueprintId, + }; + } + + if (actorIsAgentIdentity || callerBlueprintId) { + return { + role: InvocationRole.Agent, + humanOid: oid, + callerAgentBlueprintId: callerBlueprintId, + }; + } + + if (!hasUserAgentMarker) { + return { + role: InvocationRole.Human, + humanOid: oid, + }; + } + } + + if (idtyp === 'app' && hasNonEmptyClaim(claims.roles) && hasAppAgentMarker) { + return { + role: InvocationRole.Agent, + callerAgentBlueprintId: callerBlueprintId, + }; + } + + return undefined; +} + +export function resolveInvocationIdentityFromTurnContext( + turnContext: TurnContext, + options: InvocationIdentityResolverOptions = {}, +): ResolvedInvocationIdentity { + const trustSource = options.turnContextIdentityTrustSource + ?? TurnContextIdentityTrustSource.None; + const applicationPrincipal = normalizePrincipal(options.validatedPrincipal); + const hostingPrincipal = normalizePrincipal( + resolveValidatedPrincipalFromTurnContext(turnContext, trustSource), + ); + const activity = resolveActivityIdentityEvidence(turnContext, trustSource); + const configuredTarget = normalizeTargetIdentity(options.targetIdentity); + const onConflict = options.onConflict; + + const roleCandidate = selectCandidate('role', [ + { + value: normalizeInvocationRole(options.invocationRole), + source: InvocationIdentityResolutionSource.Explicit, + }, + { + value: applicationPrincipal?.role, + source: InvocationIdentityResolutionSource.ValidatedPrincipal, + }, + { + value: hostingPrincipal?.role, + source: InvocationIdentityResolutionSource.HostingPrincipal, + }, + { + value: activity.role === InvocationRole.Unknown ? undefined : activity.role, + source: InvocationIdentityResolutionSource.Activity, + }, + ], onConflict); + + const humanOid = selectCandidate('humanOid', [ + { + value: applicationPrincipal?.humanOid, + source: InvocationIdentityResolutionSource.ValidatedPrincipal, + }, + { + value: hostingPrincipal?.humanOid, + source: InvocationIdentityResolutionSource.HostingPrincipal, + }, + { + value: activity.humanOid, + source: InvocationIdentityResolutionSource.Activity, + }, + ], onConflict); + const callerAgentUserOid = selectCandidate('callerAgentUserOid', [ + { + value: applicationPrincipal?.callerAgentUserOid, + source: InvocationIdentityResolutionSource.ValidatedPrincipal, + }, + { + value: hostingPrincipal?.callerAgentUserOid, + source: InvocationIdentityResolutionSource.HostingPrincipal, + }, + { + value: activity.callerAgentUserOid, + source: InvocationIdentityResolutionSource.Activity, + }, + ], onConflict); + const callerAgentBlueprintId = selectCandidate('callerAgentBlueprintId', [ + { + value: applicationPrincipal?.callerAgentBlueprintId, + source: InvocationIdentityResolutionSource.ValidatedPrincipal, + }, + { + value: hostingPrincipal?.callerAgentBlueprintId, + source: InvocationIdentityResolutionSource.HostingPrincipal, + }, + { + value: activity.callerAgentBlueprintId, + source: InvocationIdentityResolutionSource.Activity, + }, + ], onConflict); + const callerAgentInstanceId = selectCandidate('callerAgentInstanceId', [ + { + value: applicationPrincipal?.callerAgentInstanceId, + source: InvocationIdentityResolutionSource.ValidatedPrincipal, + }, + { + value: hostingPrincipal?.callerAgentInstanceId, + source: InvocationIdentityResolutionSource.HostingPrincipal, + }, + { + value: activity.callerAgentInstanceId, + source: InvocationIdentityResolutionSource.Activity, + }, + ], onConflict); + const targetAgentId = selectCandidate('targetAgentId', [ + { + value: activity.targetAgentId, + source: InvocationIdentityResolutionSource.Activity, + }, + { + value: configuredTarget.targetAgentId, + source: InvocationIdentityResolutionSource.Configuration, + }, + ], onConflict); + const targetAgentBlueprintId = selectCandidate('targetAgentBlueprintId', [ + { + value: activity.targetAgentBlueprintId, + source: InvocationIdentityResolutionSource.Activity, + }, + { + value: configuredTarget.targetAgentBlueprintId, + source: InvocationIdentityResolutionSource.Configuration, + }, + ], onConflict); + const targetAgentAuid = selectCandidate('targetAgentAuid', [ + { + value: activity.targetAgentAuid, + source: InvocationIdentityResolutionSource.Activity, + }, + { + value: configuredTarget.targetAgentAuid, + source: InvocationIdentityResolutionSource.Configuration, + }, + ], onConflict); + const tenantId = selectCandidate('tenantId', [ + { + value: activity.tenantId, + source: InvocationIdentityResolutionSource.Activity, + }, + { + value: configuredTarget.tenantId, + source: InvocationIdentityResolutionSource.Configuration, + }, + ], onConflict); + + const role = roleCandidate?.value ?? InvocationRole.Unknown; + const retainedSources: InvocationIdentityResolutionSource[] = []; + const identity: ResolvedInvocationIdentity = { + role, + targetAgentId: targetAgentId?.value, + targetAgentBlueprintId: targetAgentBlueprintId?.value, + targetAgentAuid: targetAgentAuid?.value, + tenantId: tenantId?.value, + resolutionSource: InvocationIdentityResolutionSource.Unknown, + }; + + if (roleCandidate) { + retainedSources.push(roleCandidate.source); + } + + for (const target of [targetAgentId, targetAgentBlueprintId, targetAgentAuid, tenantId]) { + if (target) { + retainedSources.push(target.source); + } + } + + if (role === InvocationRole.Human) { + identity.humanOid = humanOid?.value; + if (humanOid) { + retainedSources.push(humanOid.source); + } + } else if (role === InvocationRole.Agent) { + const validatedHumanOid = humanOid + && humanOid.source !== InvocationIdentityResolutionSource.Activity + ? humanOid + : undefined; + + identity.humanOid = validatedHumanOid?.value; + identity.callerAgentUserOid = callerAgentUserOid?.value; + identity.callerAgentBlueprintId = callerAgentBlueprintId?.value; + identity.callerAgentInstanceId = callerAgentInstanceId?.value; + + for (const caller of [ + validatedHumanOid, + callerAgentUserOid, + callerAgentBlueprintId, + callerAgentInstanceId, + ]) { + if (caller) { + retainedSources.push(caller.source); + } + } + } + + const uniqueSources = new Set( + retainedSources.filter(source => source !== InvocationIdentityResolutionSource.Unknown), + ); + if (uniqueSources.size === 1) { + identity.resolutionSource = [...uniqueSources][0]; + } else if (uniqueSources.size > 1) { + identity.resolutionSource = InvocationIdentityResolutionSource.Composite; + } + + return identity; +} + +export function validateResolvedInvocationIdentity( + identity: ResolvedInvocationIdentity, +): void { + switch (identity.role) { + case InvocationRole.Human: + if (!identity.humanOid) { + throw new InvocationIdentityValidationError( + InvocationIdentityValidationCode.MissingHumanIdentity, + 'Human invocation identity requires humanOid.', + ); + } + return; + case InvocationRole.Agent: + if ( + !identity.callerAgentUserOid + && !identity.callerAgentInstanceId + && !identity.callerAgentBlueprintId + ) { + throw new InvocationIdentityValidationError( + InvocationIdentityValidationCode.MissingAgentIdentity, + 'Agent invocation identity requires a caller user, instance, or blueprint ID.', + ); + } + return; + case InvocationRole.Event: + if ( + !identity.targetAgentId + && !identity.targetAgentAuid + && !identity.targetAgentBlueprintId + ) { + throw new InvocationIdentityValidationError( + InvocationIdentityValidationCode.MissingEventExecutionIdentity, + 'Event invocation identity requires a target agent ID, AUID, or blueprint ID.', + ); + } + return; + default: + throw new InvocationIdentityValidationError( + InvocationIdentityValidationCode.UnknownInvocationRole, + 'Invocation identity role must be Human, Agent, or Event.', + ); + } +} diff --git a/packages/agents-a365-observability-hosting/src/index.ts b/packages/agents-a365-observability-hosting/src/index.ts index ed0173a3..1eff35a3 100644 --- a/packages/agents-a365-observability-hosting/src/index.ts +++ b/packages/agents-a365-observability-hosting/src/index.ts @@ -11,3 +11,30 @@ export { BaggageMiddleware } from './middleware/BaggageMiddleware'; export { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY, A365_AUTH_TOKEN_KEY } from './middleware/OutputLoggingMiddleware'; export { ObservabilityHostingManager } from './middleware/ObservabilityHostingManager'; export type { ObservabilityHostingOptions } from './middleware/ObservabilityHostingManager'; +export { + InvocationIdentityMiddleware, +} from './middleware/InvocationIdentityMiddleware'; +export type { + InvocationIdentityMiddlewareOptions, + ValidatedInvocationPrincipalResolver, + InvocationRoleResolver, + TargetInvocationIdentityResolver, + InvocationIdentityConflictHandler, + InvocationIdentityResolvedHandler, + InvocationIdentityResolutionErrorHandler, +} from './middleware/InvocationIdentityMiddleware'; +export { + InvocationIdentityValidationCode, + InvocationIdentityValidationError, + TurnContextIdentityTrustSource, + resolveValidatedPrincipalFromTurnContext, + resolveInvocationIdentityFromTurnContext, + validateResolvedInvocationIdentity, +} from './identity/InvocationIdentityResolver'; +export type { + ValidatedInvocationPrincipal, + TargetInvocationIdentity, + InvocationIdentityField, + InvocationIdentityConflict, + InvocationIdentityResolverOptions, +} from './identity/InvocationIdentityResolver'; diff --git a/packages/agents-a365-observability-hosting/src/middleware/InvocationIdentityMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/InvocationIdentityMiddleware.ts new file mode 100644 index 00000000..b84f1b2c --- /dev/null +++ b/packages/agents-a365-observability-hosting/src/middleware/InvocationIdentityMiddleware.ts @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Middleware, TurnContext } from '@microsoft/agents-hosting'; +import { + InvocationRole, + ResolvedInvocationIdentity, + logger, + runWithResolvedInvocationIdentity, +} from '@microsoft/agents-a365-observability'; +import { + InvocationIdentityConflict, + InvocationIdentityValidationError, + TargetInvocationIdentity, + TurnContextIdentityTrustSource, + ValidatedInvocationPrincipal, + resolveInvocationIdentityFromTurnContext, + validateResolvedInvocationIdentity, +} from '../identity/InvocationIdentityResolver'; + +type MaybePromise = T | Promise; + +export type ValidatedInvocationPrincipalResolver = ( + turnContext: TurnContext, +) => MaybePromise; + +export type InvocationRoleResolver = ( + turnContext: TurnContext, +) => MaybePromise; + +export type TargetInvocationIdentityResolver = ( + turnContext: TurnContext, +) => MaybePromise; + +export type InvocationIdentityConflictHandler = ( + conflict: InvocationIdentityConflict, + turnContext: TurnContext, +) => MaybePromise; + +export type InvocationIdentityResolvedHandler = ( + identity: ResolvedInvocationIdentity, + turnContext: TurnContext, +) => MaybePromise; + +export type InvocationIdentityResolutionErrorHandler = ( + error: Error, + turnContext: TurnContext, +) => MaybePromise; + +export interface InvocationIdentityMiddlewareOptions { + turnContextIdentityTrustSource?: TurnContextIdentityTrustSource; + resolveValidatedPrincipal?: ValidatedInvocationPrincipalResolver; + invocationRole?: InvocationRole | InvocationRoleResolver; + targetIdentity?: TargetInvocationIdentity | TargetInvocationIdentityResolver; + onIdentityConflict?: InvocationIdentityConflictHandler; + onIdentityResolved?: InvocationIdentityResolvedHandler; + onIdentityResolutionError?: InvocationIdentityResolutionErrorHandler; + strictIdentityValidation?: boolean; +} + +function toError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + if (typeof error === 'string') { + return new Error(error); + } + + try { + return new Error(JSON.stringify(error) ?? String(error)); + } catch { + return new Error(String(error)); + } +} + +export class InvocationIdentityMiddleware implements Middleware { + constructor(private readonly options: InvocationIdentityMiddlewareOptions = {}) {} + + async onTurn(turnContext: TurnContext, next: () => Promise): Promise { + const errors: Error[] = []; + const conflicts: InvocationIdentityConflict[] = []; + const strict = this.options.strictIdentityValidation === true; + + const validatedPrincipal = await this.resolveCallbackValue( + 'resolveValidatedPrincipal', + this.options.resolveValidatedPrincipal, + turnContext, + errors, + ); + const invocationRole = typeof this.options.invocationRole === 'function' + ? await this.resolveCallbackValue( + 'invocationRole', + this.options.invocationRole, + turnContext, + errors, + ) + : this.options.invocationRole; + const targetIdentity = typeof this.options.targetIdentity === 'function' + ? await this.resolveCallbackValue( + 'targetIdentity', + this.options.targetIdentity, + turnContext, + errors, + ) + : this.options.targetIdentity; + + const identity = resolveInvocationIdentityFromTurnContext(turnContext, { + turnContextIdentityTrustSource: this.options.turnContextIdentityTrustSource + ?? TurnContextIdentityTrustSource.StandardAuthorizeJwt, + validatedPrincipal, + invocationRole, + targetIdentity, + onConflict: conflict => conflicts.push(conflict), + }); + + try { + validateResolvedInvocationIdentity(identity); + } catch (error) { + const validationError = toError(error); + errors.push(validationError); + if (!strict) { + logger.warn(`[InvocationIdentityMiddleware] ${validationError.message}`); + } + } + + await runWithResolvedInvocationIdentity(identity, async () => { + for (const conflict of conflicts) { + await this.invokeHook( + 'onIdentityConflict', + this.options.onIdentityConflict, + [conflict, turnContext], + strict, + ); + } + + for (const error of errors) { + await this.invokeHook( + 'onIdentityResolutionError', + this.options.onIdentityResolutionError, + [error, turnContext], + strict, + ); + } + + await this.invokeHook( + 'onIdentityResolved', + this.options.onIdentityResolved, + [identity, turnContext], + strict, + ); + + if (strict && errors.length > 0) { + throw errors[0]; + } + + await next(); + }); + } + + private async resolveCallbackValue( + name: string, + callback: ((turnContext: TurnContext) => MaybePromise) | undefined, + turnContext: TurnContext, + errors: Error[], + ): Promise { + if (!callback) { + return undefined; + } + + try { + return await callback(turnContext); + } catch (error) { + const callbackError = toError(error); + errors.push(callbackError); + logger.warn(`[InvocationIdentityMiddleware] ${name} failed: ${callbackError.message}`); + return undefined; + } + } + + private async invokeHook( + name: string, + hook: ((...args: TArgs) => MaybePromise) | undefined, + args: TArgs, + strict: boolean, + ): Promise { + if (!hook) { + return; + } + + try { + await hook(...args); + } catch (error) { + const hookError = toError(error); + logger.warn(`[InvocationIdentityMiddleware] ${name} failed: ${hookError.message}`); + if (strict) { + throw hookError; + } + } + } +} + +export { InvocationIdentityValidationError }; diff --git a/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts b/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts index 5d9eeb32..4dc4745d 100644 --- a/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts +++ b/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts @@ -5,11 +5,20 @@ import { Middleware } from '@microsoft/agents-hosting'; import { logger } from '@microsoft/agents-a365-observability'; import { BaggageMiddleware } from './BaggageMiddleware'; import { OutputLoggingMiddleware } from './OutputLoggingMiddleware'; +import { + InvocationIdentityMiddleware, + InvocationIdentityMiddlewareOptions, +} from './InvocationIdentityMiddleware'; + +const identityConfiguredAdapters = new WeakSet(); /** * Configuration options for the hosting observability layer. */ -export interface ObservabilityHostingOptions { +export interface ObservabilityHostingOptions extends InvocationIdentityMiddlewareOptions { + /** Enable request-local invocation identity enrichment. Defaults to false. */ + enableInvocationIdentity?: boolean; + /** Enable baggage propagation middleware. Defaults to false. */ enableBaggage?: boolean; @@ -44,7 +53,13 @@ export class ObservabilityHostingManager { const enableBaggage = options.enableBaggage === true; const enableOutputLogging = options.enableOutputLogging === true; + const enableInvocationIdentity = options.enableInvocationIdentity === true; + if (enableInvocationIdentity && !identityConfiguredAdapters.has(adapter)) { + adapter.use(new InvocationIdentityMiddleware(options)); + identityConfiguredAdapters.add(adapter); + logger.info('[ObservabilityHostingManager] InvocationIdentityMiddleware registered.'); + } if (enableBaggage) { adapter.use(new BaggageMiddleware()); logger.info('[ObservabilityHostingManager] BaggageMiddleware registered.'); @@ -54,7 +69,9 @@ export class ObservabilityHostingManager { logger.info('[ObservabilityHostingManager] OutputLoggingMiddleware registered.'); } - logger.info(`[ObservabilityHostingManager] Configured. Baggage: ${enableBaggage}, OutputLogging: ${enableOutputLogging}.`); + logger.info( + `[ObservabilityHostingManager] Configured. InvocationIdentity: ${enableInvocationIdentity}, Baggage: ${enableBaggage}, OutputLogging: ${enableOutputLogging}.`, + ); this._configured = true; } } diff --git a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts index 2b0a525d..adfcfcd2 100644 --- a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts +++ b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts @@ -11,6 +11,7 @@ import { SpanDetails, logger, isPerRequestExportEnabled, + getResolvedInvocationIdentity, } from '@microsoft/agents-a365-observability'; import { ScopeUtils } from '../utils/ScopeUtils'; import { AgenticTokenCacheInstance } from '../caching/AgenticTokenCache'; @@ -39,14 +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); - - if (!agentDetails || !agentDetails.tenantId) { - await next(); - return; - } - - const userDetails = ScopeUtils.deriveCallerDetails(context); + const agentDetailsSnapshot = ScopeUtils.deriveAgentDetails(context, authToken); + const userDetailsSnapshot = ScopeUtils.deriveCallerDetails(context); const conversationId = ScopeUtils.deriveConversationId(context); const channel = ScopeUtils.deriveChannelObject(context); @@ -56,7 +51,12 @@ export class OutputLoggingMiddleware implements Middleware { }; context.onSendActivities( - this._createSendHandler(context, agentDetails, userDetails, request) + this._createSendHandler( + context, + agentDetailsSnapshot, + userDetailsSnapshot, + request, + ) ); await next(); @@ -85,8 +85,8 @@ export class OutputLoggingMiddleware implements Middleware { */ private _createSendHandler( turnContext: TurnContext, - agentDetails: AgentDetails, - userDetails?: UserDetails, + agentDetailsSnapshot?: AgentDetails, + userDetailsSnapshot?: UserDetails, request?: Request, ): SendActivitiesHandler { return async (_ctx, activities, sendNext) => { @@ -98,6 +98,18 @@ export class OutputLoggingMiddleware implements Middleware { return await sendNext(); } + const hasResolvedIdentity = getResolvedInvocationIdentity() !== undefined; + const agentDetails = hasResolvedIdentity + ? ScopeUtils.deriveAgentDetails(turnContext, '') + : agentDetailsSnapshot; + const userDetails = hasResolvedIdentity + ? ScopeUtils.deriveCallerDetails(turnContext) + : userDetailsSnapshot; + + if (!agentDetails || !agentDetails.tenantId) { + return await sendNext(); + } + const parentSpanRef: ParentSpanRef | undefined = turnContext.turnState.get(A365_PARENT_SPAN_KEY); if (!parentSpanRef) { logger.warn( diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts index faa3068d..e6c73e55 100644 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts @@ -17,9 +17,33 @@ import { Request, SpanDetails, InvokeAgentScopeDetails, + InvocationRole, + ResolvedInvocationIdentity, + getResolvedInvocationIdentity, } from '@microsoft/agents-a365-observability'; import { resolveEmbodiedAgentIds } from './TurnContextUtils'; +function hasNonBlankValue(value: unknown): boolean { + return typeof value === 'string' + ? value.trim().length > 0 + : value !== null && value !== undefined; +} + +function mergeResolvedAgentDetails( + resolved: AgentDetails | undefined, + explicit: AgentDetails, +): AgentDetails { + const merged = { ...(resolved ?? {}) } as Record; + + for (const [key, value] of Object.entries(explicit)) { + if (hasNonBlankValue(value)) { + merged[key] = value; + } + } + + return merged as unknown as AgentDetails; +} + /** * Unified utilities to populate scope tags from a TurnContext. * Provides common tag population and scope-specific helpers. @@ -49,6 +73,11 @@ export class ScopeUtils { * @returns Agent details built from recipient properties; otherwise undefined. */ public static deriveAgentDetails(turnContext: TurnContext, authToken: string): AgentDetails | undefined { + const resolvedIdentity = getResolvedInvocationIdentity(); + if (resolvedIdentity) { + return ScopeUtils.deriveResolvedAgentDetails(turnContext, resolvedIdentity); + } + const recipient = turnContext?.activity?.recipient; if (!recipient) return undefined; const { agentId, agentBlueprintId } = resolveEmbodiedAgentIds(turnContext, authToken); @@ -70,6 +99,27 @@ export class ScopeUtils { * @returns Agent details built from caller (from) properties; otherwise undefined. */ public static deriveCallerAgent(turnContext: TurnContext): AgentDetails | undefined { + const resolvedIdentity = getResolvedInvocationIdentity(); + if (resolvedIdentity) { + const hasCallerAgent = resolvedIdentity.callerAgentUserOid + || resolvedIdentity.callerAgentBlueprintId + || resolvedIdentity.callerAgentInstanceId; + if (!hasCallerAgent) { + return undefined; + } + + const from = turnContext?.activity?.from; + return { + agentId: resolvedIdentity.callerAgentInstanceId, + agentAUID: resolvedIdentity.callerAgentUserOid, + agentBlueprintId: resolvedIdentity.callerAgentBlueprintId, + agentName: from?.name, + agentDescription: from?.role, + agentEmail: from?.agenticUserId, + tenantId: resolvedIdentity.tenantId, + } as AgentDetails; + } + const from = turnContext?.activity?.from; if (!from) return undefined; return { @@ -90,6 +140,20 @@ export class ScopeUtils { * @returns User details when available; otherwise undefined. */ public static deriveCallerDetails(turnContext: TurnContext): UserDetails | undefined { + const resolvedIdentity = getResolvedInvocationIdentity(); + if (resolvedIdentity) { + if (!resolvedIdentity.humanOid) { + return undefined; + } + + const from = turnContext?.activity?.from; + return { + userId: resolvedIdentity.humanOid, + userName: resolvedIdentity.role === InvocationRole.Human ? from?.name : undefined, + tenantId: resolvedIdentity.tenantId, + }; + } + const from = turnContext?.activity?.from; if (!from) return undefined; return { @@ -202,10 +266,13 @@ export class ScopeUtils { }; // Build caller info with both human caller and caller agent details - const callerDetails: CallerDetails = { - userDetails: caller, - callerAgentDetails: callerAgent, - }; + const callerDetails: CallerDetails | undefined = + getResolvedInvocationIdentity() && !caller && !callerAgent + ? undefined + : { + userDetails: caller, + callerAgentDetails: callerAgent, + }; const spanDetailsObj: SpanDetails | undefined = (startTime || endTime || spanKind) ? { startTime, endTime, spanKind } @@ -230,6 +297,10 @@ export class ScopeUtils { private static buildInvokeAgentDetailsCore(details: AgentDetails, turnContext: TurnContext, authToken: string): AgentDetails { const derivedAgentDetails = ScopeUtils.deriveAgentDetails(turnContext, authToken); + if (getResolvedInvocationIdentity()) { + return mergeResolvedAgentDetails(derivedAgentDetails, details); + } + // Merge derived agent identity into details const mergedAgent: AgentDetails = { ...details, @@ -239,6 +310,31 @@ export class ScopeUtils { return mergedAgent; } + private static deriveResolvedAgentDetails( + turnContext: TurnContext, + identity: ResolvedInvocationIdentity, + ): AgentDetails | undefined { + const recipient = turnContext?.activity?.recipient; + const hasResolvedTarget = identity.targetAgentId + || identity.targetAgentAuid + || identity.targetAgentBlueprintId + || identity.tenantId; + + if (!recipient && !hasResolvedTarget) { + return undefined; + } + + return { + agentId: identity.targetAgentId, + agentName: recipient?.name, + agentAUID: identity.targetAgentAuid, + agentBlueprintId: identity.targetAgentBlueprintId, + agentEmail: recipient?.agenticUserId, + agentDescription: recipient?.role, + tenantId: identity.tenantId, + } as AgentDetails; + } + /** * Create an `ExecuteToolScope` using `details` and values derived from the provided `TurnContext`. * Derives `conversationId` and `channel` (name/link) from context. diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index 7d947eb3..9e1ee4ed 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -29,13 +29,21 @@ The observability package provides OpenTelemetry-based distributed tracing infra ▼ ▼ ▼ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ SpanProcessor │ │ BatchSpanProcessor│ │ Agent365Exporter │ -│ (Baggage to │ │ (OTEL SDK) │ │ (HTTP export) │ -│ attributes) │ │ │ │ │ +│ (Local identity │ │ (OTEL SDK) │ │ (HTTP export) │ +│ + baggage) │ │ │ │ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ ``` ## Key Components +### Request-local invocation identity + +The core package exposes `ResolvedInvocationIdentity` and context create/get/run helpers. Hosting middleware stores one frozen identity in a private OpenTelemetry context key for the duration of a turn. + +`SpanProcessor` stamps the identity onto every span created under that context. Existing nonblank span values win, blank identity values cannot erase resolved values, and identity-related baggage is ignored whenever local resolved identity exists. + +When an explicit remote parent context is supplied to an SDK scope, `OpenTelemetryScope` combines that remote trace parent with the current local identity before starting the span. + ### ObservabilityManager ([ObservabilityManager.ts](../src/ObservabilityManager.ts)) Main entry point using singleton pattern: diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index b1f78544..ca61ed80 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -15,6 +15,15 @@ export { BaggageBuilder, BaggageScope } from './tracing/middleware/BaggageBuilde // Per-request export utilities export { runWithExportToken, updateExportToken, getExportToken } from './tracing/context/token-context'; +// Request-local invocation identity utilities +export { + InvocationIdentityResolutionSource, + ResolvedInvocationIdentity, + createContextWithResolvedInvocationIdentity, + getResolvedInvocationIdentity, + runWithResolvedInvocationIdentity, +} from './tracing/context/invocation-identity-context'; + // Parent span context utilities export { ParentSpanRef, runWithParentSpanRef, createContextWithParentSpanRef } from './tracing/context/parent-span-context'; diff --git a/packages/agents-a365-observability/src/tracing/constants.ts b/packages/agents-a365-observability/src/tracing/constants.ts index bc237438..89101aec 100644 --- a/packages/agents-a365-observability/src/tracing/constants.ts +++ b/packages/agents-a365-observability/src/tracing/constants.ts @@ -34,6 +34,7 @@ export class OpenTelemetryConstants { public static readonly GEN_AI_CLIENT_OPERATION_DURATION_METRIC_NAME = 'gen_ai.client.operation.duration'; public static readonly GEN_AI_CLIENT_TOKEN_USAGE_METRIC_NAME = 'gen_ai.client.token.usage'; public static readonly GEN_AI_OPERATION_NAME_KEY = 'gen_ai.operation.name'; + public static readonly INVOCATION_ROLE_KEY = 'microsoft.a365.invocation.role'; public static readonly GEN_AI_REQUEST_MAX_TOKENS_KEY = 'gen_ai.request.max_tokens'; public static readonly GEN_AI_REQUEST_MODEL_KEY = 'gen_ai.request.model'; public static readonly GEN_AI_REQUEST_TEMPERATURE_KEY = 'gen_ai.request.temperature'; diff --git a/packages/agents-a365-observability/src/tracing/context/invocation-identity-context.ts b/packages/agents-a365-observability/src/tracing/context/invocation-identity-context.ts new file mode 100644 index 00000000..c707f49d --- /dev/null +++ b/packages/agents-a365-observability/src/tracing/context/invocation-identity-context.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Context, context, createContextKey } from '@opentelemetry/api'; +import { InvocationRole } from '../contracts'; + +export enum InvocationIdentityResolutionSource { + Unknown = 'unknown', + Activity = 'activity', + HostingPrincipal = 'hosting_principal', + ValidatedPrincipal = 'validated_principal', + Configuration = 'configuration', + Explicit = 'explicit', + Composite = 'composite', +} + +export interface ResolvedInvocationIdentity { + role: InvocationRole; + humanOid?: string; + callerAgentUserOid?: string; + callerAgentBlueprintId?: string; + callerAgentInstanceId?: string; + targetAgentId?: string; + targetAgentBlueprintId?: string; + targetAgentAuid?: string; + tenantId?: string; + resolutionSource: InvocationIdentityResolutionSource; +} + +const RESOLVED_INVOCATION_IDENTITY_KEY = createContextKey('a365_resolved_invocation_identity'); + +export function createContextWithResolvedInvocationIdentity( + baseContext: Context, + identity: ResolvedInvocationIdentity, +): Context { + const frozenIdentity = Object.freeze({ ...identity }); + return baseContext.setValue(RESOLVED_INVOCATION_IDENTITY_KEY, frozenIdentity); +} + +export function getResolvedInvocationIdentity( + otelContext: Context = context.active(), +): ResolvedInvocationIdentity | undefined { + return otelContext.getValue(RESOLVED_INVOCATION_IDENTITY_KEY) as ResolvedInvocationIdentity | undefined; +} + +export function runWithResolvedInvocationIdentity( + identity: ResolvedInvocationIdentity, + callback: () => T, +): T { + return context.with( + createContextWithResolvedInvocationIdentity(context.active(), identity), + callback, + ); +} diff --git a/packages/agents-a365-observability/src/tracing/diagnostics/invocation-identity-diagnostics.ts b/packages/agents-a365-observability/src/tracing/diagnostics/invocation-identity-diagnostics.ts new file mode 100644 index 00000000..95b1c782 --- /dev/null +++ b/packages/agents-a365-observability/src/tracing/diagnostics/invocation-identity-diagnostics.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { InvocationRole } from '../contracts'; +import { OpenTelemetryConstants } from '../constants'; + +export enum InvocationIdentityDiagnosticCode { + MissingHumanIdentity = 'missing_human_identity', + MissingAgentIdentity = 'missing_agent_identity', + UnknownInvocationRole = 'unknown_invocation_role', + MissingEventExecutionIdentity = 'missing_event_execution_identity', +} + +const MAX_WARNING_KEYS = 1024; +const warningKeys = new Map(); + +function stringAttribute(span: ReadableSpan, key: string): string | undefined { + const value = span.attributes[key]; + return typeof value === 'string' && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function getWarningCode(span: ReadableSpan): InvocationIdentityDiagnosticCode | undefined { + const role = stringAttribute(span, OpenTelemetryConstants.INVOCATION_ROLE_KEY); + + switch (role) { + case InvocationRole.Human: + return stringAttribute(span, OpenTelemetryConstants.USER_ID_KEY) + ? undefined + : InvocationIdentityDiagnosticCode.MissingHumanIdentity; + case InvocationRole.Agent: + return stringAttribute(span, OpenTelemetryConstants.GEN_AI_CALLER_AGENT_USER_ID_KEY) + || stringAttribute(span, OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY) + || stringAttribute(span, OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY) + ? undefined + : InvocationIdentityDiagnosticCode.MissingAgentIdentity; + case InvocationRole.Event: + return stringAttribute(span, OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY) + || stringAttribute(span, OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY) + || stringAttribute(span, OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY) + ? undefined + : InvocationIdentityDiagnosticCode.MissingEventExecutionIdentity; + default: + return InvocationIdentityDiagnosticCode.UnknownInvocationRole; + } +} + +function shouldWarn(key: string): boolean { + if (warningKeys.has(key)) { + return false; + } + + if (warningKeys.size >= MAX_WARNING_KEYS) { + const oldestKey = warningKeys.keys().next().value as string | undefined; + if (oldestKey !== undefined) { + warningKeys.delete(oldestKey); + } + } + + warningKeys.set(key, true); + return true; +} + +export function diagnoseInvocationIdentitySpan(span: ReadableSpan): void { + try { + if ( + stringAttribute(span, OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY) + !== OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME + ) { + return; + } + + const code = getWarningCode(span); + if (!code) { + return; + } + + const tenantId = stringAttribute(span, OpenTelemetryConstants.TENANT_ID_KEY) ?? 'unknown-tenant'; + const targetAgent = stringAttribute(span, OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY) + ?? stringAttribute(span, OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY) + ?? stringAttribute(span, OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY) + ?? 'unknown-target'; + const warningKey = `${tenantId}|${targetAgent}|${code}`; + + if (shouldWarn(warningKey)) { + console.warn( + `[A365Observability][${code}] Invocation identity is incomplete for tenant '${tenantId}' and target '${targetAgent}'.`, + ); + } + } catch { + // Diagnostics must never affect application execution. + } +} diff --git a/packages/agents-a365-observability/src/tracing/invocation-identity-attributes.ts b/packages/agents-a365-observability/src/tracing/invocation-identity-attributes.ts new file mode 100644 index 00000000..b30c4220 --- /dev/null +++ b/packages/agents-a365-observability/src/tracing/invocation-identity-attributes.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ResolvedInvocationIdentity } from './context/invocation-identity-context'; +import { OpenTelemetryConstants } from './constants'; + +export const INVOCATION_IDENTITY_ATTRIBUTE_KEYS: ReadonlySet = new Set([ + OpenTelemetryConstants.INVOCATION_ROLE_KEY, + OpenTelemetryConstants.USER_ID_KEY, + OpenTelemetryConstants.GEN_AI_CALLER_AGENT_USER_ID_KEY, + OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, + OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, + OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, + OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, + OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, + OpenTelemetryConstants.TENANT_ID_KEY, +]); + +export function getInvocationIdentityAttributes( + identity: ResolvedInvocationIdentity, +): ReadonlyArray { + return [ + [OpenTelemetryConstants.INVOCATION_ROLE_KEY, identity.role], + [OpenTelemetryConstants.USER_ID_KEY, identity.humanOid], + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_USER_ID_KEY, identity.callerAgentUserOid], + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, identity.callerAgentBlueprintId], + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, identity.callerAgentInstanceId], + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, identity.targetAgentId], + [OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, identity.targetAgentBlueprintId], + [OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, identity.targetAgentAuid], + [OpenTelemetryConstants.TENANT_ID_KEY, identity.tenantId], + ]; +} + +export function hasNonBlankIdentityAttribute(value: unknown): boolean { + return typeof value === 'string' + ? value.trim().length > 0 + : value !== null && value !== undefined; +} + +export function isBlankIdentityAttributeValue(value: unknown): boolean { + return typeof value === 'string' && value.trim().length === 0; +} diff --git a/packages/agents-a365-observability/src/tracing/processors/SpanProcessor.ts b/packages/agents-a365-observability/src/tracing/processors/SpanProcessor.ts index 843c2cb0..4efaeadc 100644 --- a/packages/agents-a365-observability/src/tracing/processors/SpanProcessor.ts +++ b/packages/agents-a365-observability/src/tracing/processors/SpanProcessor.ts @@ -6,6 +6,24 @@ import { SpanProcessor as BaseSpanProcessor } from '@opentelemetry/sdk-trace-bas import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { OpenTelemetryConstants } from '../constants'; import { GENERIC_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES } from './util'; +import { getResolvedInvocationIdentity } from '../context/invocation-identity-context'; +import { + getInvocationIdentityAttributes, + hasNonBlankIdentityAttribute, + INVOCATION_IDENTITY_ATTRIBUTE_KEYS, +} from '../invocation-identity-attributes'; +import { diagnoseInvocationIdentitySpan } from '../diagnostics/invocation-identity-diagnostics'; + +interface SpanWithAttributes { + attributes?: Record; + _attributes?: Record; + name?: string; +} + +function getSpanAttributes(span: Span): Record { + const spanRecord = span as Span & SpanWithAttributes; + return spanRecord.attributes ?? spanRecord._attributes ?? {}; +} /** * Span processor that propagates baggage key/value pairs to span attributes. @@ -15,6 +33,8 @@ import { GENERIC_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES } from './util'; * For other operations, it applies only generic attributes. */ export class SpanProcessor implements BaseSpanProcessor { + private readonly identityEnrichedSpans = new WeakSet(); + /** * Called when a span is started. * Copies relevant baggage entries to span attributes. @@ -25,16 +45,24 @@ export class SpanProcessor implements BaseSpanProcessor { return; } - // Get existing span attributes - const existingAttrs = new Set(); - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const spanRecord = span as any; - if (spanRecord.attributes) { - Object.keys(spanRecord.attributes).forEach(key => existingAttrs.add(key)); + const spanAttributes = getSpanAttributes(span); + const existingAttrs = new Set(Object.keys(spanAttributes)); + const identity = getResolvedInvocationIdentity(ctx); + + if (identity) { + for (const [key, value] of getInvocationIdentityAttributes(identity)) { + if (value === undefined || hasNonBlankIdentityAttribute(spanAttributes[key])) { + continue; + } + + try { + span.setAttribute(key, value); + } catch { + // Span enrichment must not interrupt application execution. + } } - } catch { - // Ignore errors accessing span attributes + + this.identityEnrichedSpans.add(span as object); } // Get all baggage entries @@ -53,11 +81,9 @@ export class SpanProcessor implements BaseSpanProcessor { // Determine if this is an invoke_agent operation const operationName = baggageMap.get(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY) || - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (span as any).attributes?.[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]; + spanAttributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const spanName = (span as any).name || ''; + const spanName = (span as Span & SpanWithAttributes).name || ''; const isInvokeAgent = operationName === OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME || spanName.startsWith(OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME); @@ -81,6 +107,10 @@ export class SpanProcessor implements BaseSpanProcessor { // Copy baggage to span attributes for (const key of targetKeys) { + if (identity && INVOCATION_IDENTITY_ATTRIBUTE_KEYS.has(key)) { + continue; + } + // Skip if attribute already exists if (existingAttrs.has(key)) { continue; @@ -102,8 +132,10 @@ export class SpanProcessor implements BaseSpanProcessor { /** * Called when a span is ended. */ - onEnd(_span: ReadableSpan): void { - // No-op for this processor + onEnd(span: ReadableSpan): void { + if (this.identityEnrichedSpans.has(span as object)) { + diagnoseInvocationIdentitySpan(span); + } } /** diff --git a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts index aba3062f..00b4fb3f 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts @@ -8,6 +8,14 @@ import { createContextWithParentSpanRef } from '../context/parent-span-context'; import { isParentSpanRef } from '../context/trace-context-propagation'; import { normalizeInputMessages, normalizeOutputMessages, serializeMessages } from '../message-utils'; import logger from '../../utils/logging'; +import { + createContextWithResolvedInvocationIdentity, + getResolvedInvocationIdentity, +} from '../context/invocation-identity-context'; +import { + INVOCATION_IDENTITY_ATTRIBUTE_KEYS, + isBlankIdentityAttributeValue, +} from '../invocation-identity-attributes'; /** * Base class for OpenTelemetry tracing scopes @@ -21,6 +29,7 @@ export abstract class OpenTelemetryScope implements Disposable { private customEndTime?: TimeInput; private errorType?: string; private hasEnded = false; + private readonly hasResolvedInvocationIdentity: boolean; /** * Initializes a new instance of the OpenTelemetryScope class @@ -45,8 +54,12 @@ export abstract class OpenTelemetryScope implements Disposable { const spanLinks = spanDetails?.spanLinks; const kind = spanDetails?.spanKind ?? SpanKind.CLIENT; + const activeContext = context.active(); + const resolvedInvocationIdentity = getResolvedInvocationIdentity(activeContext); + this.hasResolvedInvocationIdentity = resolvedInvocationIdentity !== undefined; + // Determine the context to use for span creation - let currentContext = context.active(); + let currentContext = activeContext; if (parentContext) { if (isParentSpanRef(parentContext)) { // Existing ParentSpanRef path (backward compatible) @@ -58,6 +71,13 @@ export abstract class OpenTelemetryScope implements Disposable { } } + if (resolvedInvocationIdentity) { + currentContext = createContextWithResolvedInvocationIdentity( + currentContext, + resolvedInvocationIdentity, + ); + } + 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 @@ -151,7 +171,7 @@ export abstract class OpenTelemetryScope implements Disposable { if (Array.isArray(attributes)) { for (const [key, value] of attributes as Array<[string, AttributeValue]>) { if (!key || typeof key !== 'string' || !key.trim()) continue; - this.span.setAttribute(key, value); + this.setSpanAttribute(key, value); } } else if ( typeof attributes === 'object' && @@ -161,13 +181,13 @@ export abstract class OpenTelemetryScope implements Disposable { ) { for (const [key, value] of attributes as Iterable<[string, AttributeValue]>) { if (!key || typeof key !== 'string' || !key.trim()) continue; - this.span.setAttribute(key, value); + this.setSpanAttribute(key, value); } } else if ( typeof attributes === 'object') { for (const key of Object.keys(attributes)) { if (!key || typeof key !== 'string' || !key.trim()) continue; - this.span.setAttribute(key, (attributes as Record)[key]); + this.setSpanAttribute(key, (attributes as Record)[key]); } } } @@ -199,10 +219,22 @@ export abstract class OpenTelemetryScope implements Disposable { */ protected setTagMaybe(name: string, value: T | null | undefined): void { if (value != null) { - this.span.setAttributes({ [name]: value as string | number | boolean | string[] | number[] }); + this.setSpanAttribute(name, value); } } + private setSpanAttribute(name: string, value: AttributeValue): void { + if ( + this.hasResolvedInvocationIdentity + && INVOCATION_IDENTITY_ATTRIBUTE_KEYS.has(name) + && isBlankIdentityAttributeValue(value) + ) { + return; + } + + this.span.setAttribute(name, value); + } + /** * Adds an event to the current span. * @param name The event name diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5f69c3a..aba3fd1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -389,6 +389,9 @@ importers: '@microsoft/agents-a365-runtime': specifier: workspace:* version: link:../agents-a365-runtime + '@microsoft/agents-activity': + specifier: 'catalog:' + version: 1.4.2 '@microsoft/agents-hosting': specifier: 'catalog:' version: 1.4.2 diff --git a/tests/observability/core/invocation-identity.test.ts b/tests/observability/core/invocation-identity.test.ts new file mode 100644 index 00000000..f8d112f4 --- /dev/null +++ b/tests/observability/core/invocation-identity.test.ts @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { + context, + propagation, + SpanKind, +} from '@opentelemetry/api'; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { + InvocationIdentityResolutionSource, + InvocationRole, + OpenTelemetryConstants, + ResolvedInvocationIdentity, + createContextWithResolvedInvocationIdentity, + getResolvedInvocationIdentity, + runWithResolvedInvocationIdentity, +} from '@microsoft/agents-a365-observability'; +import { SpanProcessor } from '../../../packages/agents-a365-observability/src/tracing/processors/SpanProcessor'; + +const HUMAN_OID = '11111111-1111-4111-8111-111111111111'; +const CALLER_AGENT_ID = '22222222-2222-4222-8222-222222222222'; +const TARGET_AGENT_ID = '33333333-3333-4333-8333-333333333333'; +const TARGET_BLUEPRINT_ID = '44444444-4444-4444-8444-444444444444'; +const TENANT_ID = '55555555-5555-4555-8555-555555555555'; + +function humanIdentity(overrides: Partial = {}): ResolvedInvocationIdentity { + return { + role: InvocationRole.Human, + humanOid: HUMAN_OID, + targetAgentId: TARGET_AGENT_ID, + targetAgentBlueprintId: TARGET_BLUEPRINT_ID, + tenantId: TENANT_ID, + resolutionSource: InvocationIdentityResolutionSource.Composite, + ...overrides, + }; +} + +describe('invocation identity context', () => { + let contextManager: AsyncLocalStorageContextManager; + + beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + context.setGlobalContextManager(contextManager); + }); + + afterAll(() => { + contextManager.disable(); + context.disable(); + }); + + it('stores a frozen copy without mutating the caller object', () => { + const identity = humanIdentity(); + const identityContext = createContextWithResolvedInvocationIdentity(context.active(), identity); + + identity.humanOid = CALLER_AGENT_ID; + const stored = getResolvedInvocationIdentity(identityContext); + + expect(stored?.humanOid).toBe(HUMAN_OID); + expect(Object.isFrozen(stored)).toBe(true); + }); + + it('makes identity available for the complete async callback', async () => { + await runWithResolvedInvocationIdentity(humanIdentity(), async () => { + await Promise.resolve(); + expect(getResolvedInvocationIdentity()?.humanOid).toBe(HUMAN_OID); + expect(propagation.getBaggage(context.active())).toBeUndefined(); + }); + + expect(getResolvedInvocationIdentity()).toBeUndefined(); + }); +}); + +describe('SpanProcessor invocation identity enrichment', () => { + let contextManager: AsyncLocalStorageContextManager; + let exporter: InMemorySpanExporter; + let provider: BasicTracerProvider; + + beforeEach(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + context.setGlobalContextManager(contextManager); + exporter = new InMemorySpanExporter(); + provider = new BasicTracerProvider({ + spanProcessors: [ + new SpanProcessor(), + new SimpleSpanProcessor(exporter), + ], + }); + }); + + afterEach(async () => { + await provider.shutdown(); + contextManager.disable(); + context.disable(); + jest.restoreAllMocks(); + }); + + it('stamps resolved identity onto custom spans', async () => { + const tracer = provider.getTracer('identity-test'); + + await runWithResolvedInvocationIdentity(humanIdentity(), async () => { + tracer.startSpan('custom-span', { kind: SpanKind.INTERNAL }).end(); + }); + + await provider.forceFlush(); + const attributes = exporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.INVOCATION_ROLE_KEY]).toBe(InvocationRole.Human); + expect(attributes[OpenTelemetryConstants.USER_ID_KEY]).toBe(HUMAN_OID); + expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(TARGET_AGENT_ID); + expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY]).toBe(TARGET_BLUEPRINT_ID); + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(TENANT_ID); + }); + + it('preserves explicit nonblank values and replaces blank values', async () => { + const tracer = provider.getTracer('identity-test'); + + await runWithResolvedInvocationIdentity(humanIdentity(), async () => { + tracer.startSpan('explicit-span', { + attributes: { + [OpenTelemetryConstants.USER_ID_KEY]: CALLER_AGENT_ID, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: ' ', + }, + }).end(); + }); + + await provider.forceFlush(); + const attributes = exporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.USER_ID_KEY]).toBe(CALLER_AGENT_ID); + expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(TARGET_AGENT_ID); + }); + + it('blocks baggage from overwriting or filling identity fields', async () => { + const tracer = provider.getTracer('identity-test'); + let baggage = propagation.createBaggage(); + baggage = baggage.setEntry(OpenTelemetryConstants.USER_ID_KEY, { value: CALLER_AGENT_ID }); + baggage = baggage.setEntry(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, { value: CALLER_AGENT_ID }); + baggage = baggage.setEntry(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, { value: CALLER_AGENT_ID }); + const baggageContext = propagation.setBaggage(context.active(), baggage); + + await context.with(baggageContext, () => + runWithResolvedInvocationIdentity(humanIdentity(), async () => { + tracer.startSpan('baggage-span').end(); + })); + + await provider.forceFlush(); + const attributes = exporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.USER_ID_KEY]).toBe(HUMAN_OID); + expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(TARGET_AGENT_ID); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + }); + + it('preserves legacy baggage enrichment when local identity is absent', async () => { + const tracer = provider.getTracer('identity-test'); + const baggage = propagation.createBaggage({ + [OpenTelemetryConstants.USER_ID_KEY]: { value: HUMAN_OID }, + }); + const baggageContext = propagation.setBaggage(context.active(), baggage); + + context.with(baggageContext, () => { + tracer.startSpan('legacy-span').end(); + }); + + await provider.forceFlush(); + expect( + exporter.getFinishedSpans()[0].attributes[OpenTelemetryConstants.USER_ID_KEY], + ).toBe(HUMAN_OID); + }); + + it('diagnoses enriched invoke-agent spans and deduplicates warnings', async () => { + const tracer = provider.getTracer('identity-test'); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const identity = humanIdentity({ + role: InvocationRole.Unknown, + humanOid: undefined, + }); + + await runWithResolvedInvocationIdentity(identity, async () => { + tracer.startSpan('invoke-one', { + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: + OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, + }, + }).end(); + tracer.startSpan('invoke-two', { + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: + OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, + }, + }).end(); + tracer.startSpan('not-an-invoke', { + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: + OpenTelemetryConstants.CHAT_OPERATION_NAME, + }, + }).end(); + }); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('unknown_invocation_role'); + }); +}); diff --git a/tests/observability/core/trace-context-propagation.test.ts b/tests/observability/core/trace-context-propagation.test.ts index beb6c1b1..21fb38fa 100644 --- a/tests/observability/core/trace-context-propagation.test.ts +++ b/tests/observability/core/trace-context-propagation.test.ts @@ -12,7 +12,12 @@ import { extractContextFromHeaders, runWithExtractedTraceContext, InvokeAgentScope, + InvocationIdentityResolutionSource, + InvocationRole, + OpenTelemetryConstants, + runWithResolvedInvocationIdentity, } from '@microsoft/agents-a365-observability'; +import { SpanProcessor } from '../../../packages/agents-a365-observability/src/tracing/processors/SpanProcessor'; describe('Trace Context Propagation', () => { let provider: BasicTracerProvider; @@ -28,15 +33,17 @@ describe('Trace Context Propagation', () => { propagation.setGlobalPropagator(new W3CTraceContextPropagator()); exporter = new InMemorySpanExporter(); + const identityProcessor = new SpanProcessor(); const processor = new SimpleSpanProcessor(exporter); // eslint-disable-next-line @typescript-eslint/no-explicit-any const globalProvider: any = trace.getTracerProvider(); if (globalProvider && typeof globalProvider.addSpanProcessor === 'function') { + globalProvider.addSpanProcessor(identityProcessor); globalProvider.addSpanProcessor(processor); flushProvider = globalProvider; } else { - provider = new BasicTracerProvider({ spanProcessors: [processor] }); + provider = new BasicTracerProvider({ spanProcessors: [identityProcessor, processor] }); trace.setGlobalTracerProvider(provider); flushProvider = provider; } @@ -153,6 +160,43 @@ describe('Trace Context Propagation', () => { const span = exporter.getFinishedSpans().find(s => s.name.toLowerCase().includes('invoke_agent')); expect(span!.parentSpanContext?.spanId).toBe(spanId); }); + + it('combines an extracted parent with local resolved identity', async () => { + const traceId = '1af7651916cd43dd8448eb211c80319c'; + const spanId = 'c7ad6b7169203331'; + const humanOid = '11111111-1111-4111-8111-111111111111'; + const targetAgentId = '77777777-7777-4777-8777-777777777777'; + const tenantId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const extractedCtx = extractContextFromHeaders({ traceparent: `00-${traceId}-${spanId}-01` }); + + await runWithResolvedInvocationIdentity({ + role: InvocationRole.Human, + humanOid, + targetAgentId, + tenantId, + resolutionSource: InvocationIdentityResolutionSource.Composite, + }, async () => { + const scope = InvokeAgentScope.start( + {}, + {}, + { agentId: ' ', tenantId: ' ' }, + undefined, + { parentContext: extractedCtx }, + ); + scope.dispose(); + }); + + await flushProvider.forceFlush(); + + const span = exporter.getFinishedSpans().find( + candidate => candidate.spanContext().traceId === traceId + && candidate.name.toLowerCase().includes('invoke_agent'), + ); + expect(span?.parentSpanContext?.spanId).toBe(spanId); + expect(span?.attributes[OpenTelemetryConstants.USER_ID_KEY]).toBe(humanOid); + expect(span?.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(targetAgentId); + expect(span?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(tenantId); + }); }); describe('ParentSpanRef with isRemote', () => { diff --git a/tests/observability/extension/hosting/invocation-identity-middleware.test.ts b/tests/observability/extension/hosting/invocation-identity-middleware.test.ts new file mode 100644 index 00000000..571186dc --- /dev/null +++ b/tests/observability/extension/hosting/invocation-identity-middleware.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { context as otelContext } from '@opentelemetry/api'; +import type { TurnContext } from '@microsoft/agents-hosting'; +import { + getResolvedInvocationIdentity, + InvocationRole, +} from '@microsoft/agents-a365-observability'; +import { + InvocationIdentityMiddleware, +} from '../../../../packages/agents-a365-observability-hosting/src/middleware/InvocationIdentityMiddleware'; +import { + InvocationIdentityValidationCode, + InvocationIdentityValidationError, + TurnContextIdentityTrustSource, +} from '../../../../packages/agents-a365-observability-hosting/src/identity/InvocationIdentityResolver'; + +const HUMAN_OID = '11111111-1111-4111-8111-111111111111'; +const TARGET_AGENT_ID = '77777777-7777-4777-8777-777777777777'; +const TENANT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +function makeTurnContext(): TurnContext { + return { + identity: { aud: 'api://target' }, + activity: { + type: 'message', + from: { + role: 'user', + aadObjectId: HUMAN_OID, + }, + isAgenticRequest: () => false, + getAgenticInstanceId: () => undefined, + getAgenticTenantId: () => undefined, + }, + turnState: new Map(), + } as unknown as TurnContext; +} + +describe('InvocationIdentityMiddleware', () => { + let contextManager: AsyncLocalStorageContextManager; + + beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); + }); + + afterAll(() => { + contextManager.disable(); + otelContext.disable(); + }); + + it('invokes callbacks once, runs every hook in resolved context, and continues non-strict failures', async () => { + const turnContext = makeTurnContext(); + const callbackError = new Error('validated principal lookup failed'); + const order: string[] = []; + const resolveValidatedPrincipal = jest.fn(async () => { + throw callbackError; + }); + const invocationRole = jest.fn(async () => InvocationRole.Event); + const targetIdentity = jest.fn(async () => ({ + agentId: TARGET_AGENT_ID, + tenantId: TENANT_ID, + })); + const assertResolvedContext = () => { + expect(getResolvedInvocationIdentity()).toMatchObject({ + role: InvocationRole.Event, + targetAgentId: TARGET_AGENT_ID, + tenantId: TENANT_ID, + }); + }; + const onIdentityConflict = jest.fn(async () => { + order.push('conflict'); + assertResolvedContext(); + }); + const onIdentityResolutionError = jest.fn(async (error: Error) => { + order.push('error'); + expect(error).toBe(callbackError); + assertResolvedContext(); + }); + const onIdentityResolved = jest.fn(async (identity) => { + order.push('resolved'); + expect(getResolvedInvocationIdentity()).toEqual(identity); + }); + const next = jest.fn(async () => { + order.push('next'); + assertResolvedContext(); + }); + + const middleware = new InvocationIdentityMiddleware({ + resolveValidatedPrincipal, + invocationRole, + targetIdentity, + onIdentityConflict, + onIdentityResolutionError, + onIdentityResolved, + }); + + await expect(middleware.onTurn(turnContext, next)).resolves.toBeUndefined(); + + expect(resolveValidatedPrincipal).toHaveBeenCalledTimes(1); + expect(resolveValidatedPrincipal).toHaveBeenCalledWith(turnContext); + expect(invocationRole).toHaveBeenCalledTimes(1); + expect(invocationRole).toHaveBeenCalledWith(turnContext); + expect(targetIdentity).toHaveBeenCalledTimes(1); + expect(targetIdentity).toHaveBeenCalledWith(turnContext); + expect(onIdentityConflict).toHaveBeenCalledTimes(1); + expect(onIdentityResolutionError).toHaveBeenCalledTimes(1); + expect(onIdentityResolved).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledTimes(1); + expect(order).toEqual(['conflict', 'error', 'resolved', 'next']); + expect(getResolvedInvocationIdentity()).toBeUndefined(); + }); + + it('throws a callback failure in strict mode after hooks and does not call next', async () => { + const turnContext = makeTurnContext(); + const callbackError = new Error('validated principal lookup failed'); + const onIdentityResolutionError = jest.fn(() => { + expect(getResolvedInvocationIdentity()?.role).toBe(InvocationRole.Event); + }); + const onIdentityResolved = jest.fn(() => { + expect(getResolvedInvocationIdentity()?.targetAgentId).toBe(TARGET_AGENT_ID); + }); + const next = jest.fn(); + const middleware = new InvocationIdentityMiddleware({ + strictIdentityValidation: true, + resolveValidatedPrincipal: async () => { + throw callbackError; + }, + invocationRole: InvocationRole.Event, + targetIdentity: { + agentId: TARGET_AGENT_ID, + tenantId: TENANT_ID, + }, + onIdentityResolutionError, + onIdentityResolved, + }); + + await expect(middleware.onTurn(turnContext, next)).rejects.toBe(callbackError); + + expect(onIdentityResolutionError).toHaveBeenCalledTimes(1); + expect(onIdentityResolved).toHaveBeenCalledTimes(1); + expect(next).not.toHaveBeenCalled(); + expect(getResolvedInvocationIdentity()).toBeUndefined(); + }); + + it('throws strict validation errors for an unresolved identity', async () => { + const turnContext = makeTurnContext(); + const onIdentityResolutionError = jest.fn(); + const next = jest.fn(); + const middleware = new InvocationIdentityMiddleware({ + strictIdentityValidation: true, + turnContextIdentityTrustSource: TurnContextIdentityTrustSource.None, + onIdentityResolutionError, + }); + + await expect(middleware.onTurn(turnContext, next)).rejects.toMatchObject({ + name: InvocationIdentityValidationError.name, + code: InvocationIdentityValidationCode.UnknownInvocationRole, + }); + + expect(onIdentityResolutionError).toHaveBeenCalledWith( + expect.objectContaining({ + code: InvocationIdentityValidationCode.UnknownInvocationRole, + }), + turnContext, + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('continues non-strict turns when a callback rejects with a non-serializable value', async () => { + const turnContext = makeTurnContext(); + const next = jest.fn(); + const circularValue: Record = {}; + circularValue.self = circularValue; + const middleware = new InvocationIdentityMiddleware({ + resolveValidatedPrincipal: async () => { + throw circularValue; + }, + }); + + await expect(middleware.onTurn(turnContext, next)).resolves.toBeUndefined(); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/observability/extension/hosting/invocation-identity-resolver.test.ts b/tests/observability/extension/hosting/invocation-identity-resolver.test.ts new file mode 100644 index 00000000..756ab31b --- /dev/null +++ b/tests/observability/extension/hosting/invocation-identity-resolver.test.ts @@ -0,0 +1,577 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ActivityEventNames, ActivityTypes } from '@microsoft/agents-activity'; +import type { TurnContext } from '@microsoft/agents-hosting'; +import { + InvocationIdentityResolutionSource, + InvocationRole, + type ResolvedInvocationIdentity, +} from '@microsoft/agents-a365-observability'; +import { + InvocationIdentityValidationCode, + InvocationIdentityValidationError, + TurnContextIdentityTrustSource, + resolveInvocationIdentityFromTurnContext, + resolveValidatedPrincipalFromTurnContext, + validateResolvedInvocationIdentity, +} from '../../../../packages/agents-a365-observability-hosting/src/identity/InvocationIdentityResolver'; + +const HUMAN_OID = '11111111-1111-4111-8111-111111111111'; +const HOSTING_HUMAN_OID = '22222222-2222-4222-8222-222222222222'; +const ACTIVITY_HUMAN_OID = '33333333-3333-4333-8333-333333333333'; +const CALLER_AGENT_USER_OID = '44444444-4444-4444-8444-444444444444'; +const CALLER_AGENT_BLUEPRINT_ID = '55555555-5555-4555-8555-555555555555'; +const CALLER_AGENT_INSTANCE_ID = '66666666-6666-4666-8666-666666666666'; +const TARGET_AGENT_ID = '77777777-7777-4777-8777-777777777777'; +const TARGET_AGENT_BLUEPRINT_ID = '88888888-8888-4888-8888-888888888888'; +const TARGET_AGENT_AUID = '99999999-9999-4999-8999-999999999999'; +const TENANT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const CONFIGURED_TARGET_AGENT_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const CONFIGURED_TARGET_BLUEPRINT_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const CONFIGURED_TARGET_AUID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const CONFIGURED_TENANT_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; +const NIL_UUID = '00000000-0000-0000-0000-000000000000'; + +interface MockTurnContextOptions { + identity?: unknown; + activity?: { + type?: string; + name?: string; + channelId?: string; + from?: Record; + recipient?: Record; + isAgenticRequest?: boolean; + targetAgentId?: string; + tenantId?: string; + }; +} + +function makeTurnContext(options: MockTurnContextOptions = {}): TurnContext { + const activity = options.activity ?? {}; + return { + identity: options.identity, + activity: { + type: activity.type ?? 'message', + name: activity.name, + channelId: activity.channelId, + from: activity.from, + recipient: activity.recipient, + isAgenticRequest: () => activity.isAgenticRequest === true, + getAgenticInstanceId: () => activity.targetAgentId, + getAgenticTenantId: () => activity.tenantId, + }, + turnState: new Map(), + } as unknown as TurnContext; +} + +const trustedOptions = { + turnContextIdentityTrustSource: TurnContextIdentityTrustSource.StandardAuthorizeJwt, +}; + +describe('InvocationIdentityResolver', () => { + describe('trusted principal classification', () => { + it('resolves an ordinary delegated user as a human', () => { + const context = makeTurnContext({ + identity: { + idtyp: 'user', + oid: HUMAN_OID.toUpperCase(), + scp: 'access_as_user', + }, + }); + + expect(resolveValidatedPrincipalFromTurnContext( + context, + TurnContextIdentityTrustSource.StandardAuthorizeJwt, + )).toEqual({ + role: InvocationRole.Human, + humanOid: HUMAN_OID, + }); + + expect(resolveInvocationIdentityFromTurnContext(context, trustedOptions)).toMatchObject({ + role: InvocationRole.Human, + humanOid: HUMAN_OID, + resolutionSource: InvocationIdentityResolutionSource.HostingPrincipal, + }); + }); + + it('resolves xms_sub_fct=13 as an agent user', () => { + const context = makeTurnContext({ + identity: { + idtyp: 'user', + oid: CALLER_AGENT_USER_OID.toUpperCase(), + scp: ['Agent.Read'], + xms_sub_fct: '7 13', + xms_par_app_azp: CALLER_AGENT_BLUEPRINT_ID.toUpperCase(), + }, + }); + + const principal = resolveValidatedPrincipalFromTurnContext( + context, + TurnContextIdentityTrustSource.StandardAuthorizeJwt, + ); + + expect(principal).toEqual({ + role: InvocationRole.Agent, + callerAgentUserOid: CALLER_AGENT_USER_OID, + callerAgentBlueprintId: CALLER_AGENT_BLUEPRINT_ID, + }); + expect(resolveInvocationIdentityFromTurnContext(context, trustedOptions)).toMatchObject({ + role: InvocationRole.Agent, + callerAgentUserOid: CALLER_AGENT_USER_OID, + callerAgentBlueprintId: CALLER_AGENT_BLUEPRINT_ID, + }); + }); + + it('resolves OBO actor-facet and parent-app claims while retaining the human', () => { + const context = makeTurnContext({ + identity: { + idtyp: 'user', + oid: HUMAN_OID, + scp: 'access_as_user', + xms_act_fct: ['11'], + xms_par_app_azp: CALLER_AGENT_BLUEPRINT_ID.toUpperCase(), + }, + }); + + expect(resolveInvocationIdentityFromTurnContext(context, trustedOptions)).toMatchObject({ + role: InvocationRole.Agent, + humanOid: HUMAN_OID, + callerAgentBlueprintId: CALLER_AGENT_BLUEPRINT_ID, + resolutionSource: InvocationIdentityResolutionSource.HostingPrincipal, + }); + }); + + it('treats a parent application claim as agent evidence for an OBO user token', () => { + const context = makeTurnContext({ + identity: { + idtyp: 'user', + oid: HUMAN_OID, + scp: 'access_as_user', + xms_par_app_azp: CALLER_AGENT_BLUEPRINT_ID, + }, + }); + + expect(resolveValidatedPrincipalFromTurnContext( + context, + TurnContextIdentityTrustSource.StandardAuthorizeJwt, + )).toEqual({ + role: InvocationRole.Agent, + humanOid: HUMAN_OID, + callerAgentBlueprintId: CALLER_AGENT_BLUEPRINT_ID, + }); + }); + + it('resolves an app-only agent principal', () => { + const context = makeTurnContext({ + identity: { + idtyp: 'app', + roles: ['Agent.Invoke'], + xms_sub_fct: '11', + xms_par_app_azp: CALLER_AGENT_BLUEPRINT_ID, + }, + }); + + expect(resolveInvocationIdentityFromTurnContext(context, trustedOptions)).toMatchObject({ + role: InvocationRole.Agent, + callerAgentBlueprintId: CALLER_AGENT_BLUEPRINT_ID, + resolutionSource: InvocationIdentityResolutionSource.HostingPrincipal, + }); + }); + + it('leaves an ordinary app principal Unknown', () => { + const context = makeTurnContext({ + identity: { + idtyp: 'app', + oid: CALLER_AGENT_INSTANCE_ID, + roles: ['Agent.Invoke'], + appid: CALLER_AGENT_BLUEPRINT_ID, + }, + }); + + expect(resolveValidatedPrincipalFromTurnContext( + context, + TurnContextIdentityTrustSource.StandardAuthorizeJwt, + )).toBeUndefined(); + expect(resolveInvocationIdentityFromTurnContext(context, trustedOptions)).toMatchObject({ + role: InvocationRole.Unknown, + resolutionSource: InvocationIdentityResolutionSource.Unknown, + }); + }); + + it('does not treat a parent-app claim alone as an app Agent ID marker', () => { + const context = makeTurnContext({ + identity: { + idtyp: 'app', + roles: ['Agent.Invoke'], + xms_par_app_azp: CALLER_AGENT_BLUEPRINT_ID, + }, + }); + + expect(resolveValidatedPrincipalFromTurnContext( + context, + TurnContextIdentityTrustSource.StandardAuthorizeJwt, + )).toBeUndefined(); + }); + }); + + describe('Activity evidence', () => { + it.each([ + ['Event', ActivityTypes.Event, 'custom-event'], + ['ContinueConversation', ActivityTypes.Event, ActivityEventNames.ContinueConversation], + ])('leaves %s activities Unknown unless the role is explicit', (_label, type, name) => { + const context = makeTurnContext({ + identity: { aud: 'api://target' }, + activity: { + type, + name, + from: { role: 'user', aadObjectId: HUMAN_OID }, + }, + }); + + expect(resolveInvocationIdentityFromTurnContext(context, trustedOptions).role) + .toBe(InvocationRole.Unknown); + + expect(resolveInvocationIdentityFromTurnContext(context, { + ...trustedOptions, + invocationRole: InvocationRole.Event, + targetIdentity: { agentId: TARGET_AGENT_ID }, + })).toMatchObject({ + role: InvocationRole.Event, + targetAgentId: TARGET_AGENT_ID, + }); + }); + + it('ignores a raw string identity instead of trusting Activity identity', () => { + const context = makeTurnContext({ + identity: JSON.stringify({ + idtyp: 'user', + oid: HUMAN_OID, + scp: 'access_as_user', + }), + activity: { + from: { role: 'user', aadObjectId: HUMAN_OID }, + }, + }); + + expect(resolveInvocationIdentityFromTurnContext(context, trustedOptions)).toMatchObject({ + role: InvocationRole.Unknown, + resolutionSource: InvocationIdentityResolutionSource.Unknown, + }); + }); + + it('ignores sub, email, UPN, channel, conversation, and generic IDs', () => { + const context = makeTurnContext({ + identity: { + idtyp: 'user', + scp: 'access_as_user', + sub: HUMAN_OID, + email: 'human@contoso.com', + upn: 'human@contoso.com', + }, + activity: { + channelId: TARGET_AGENT_ID, + from: { + role: 'user', + id: HUMAN_OID, + email: 'human@contoso.com', + userPrincipalName: 'human@contoso.com', + }, + recipient: { + id: TARGET_AGENT_AUID, + }, + }, + }); + + const identity = resolveInvocationIdentityFromTurnContext(context, trustedOptions); + expect(identity.role).toBe(InvocationRole.Human); + expect(identity.humanOid).toBeUndefined(); + expect(identity.callerAgentUserOid).toBeUndefined(); + expect(identity.callerAgentBlueprintId).toBeUndefined(); + expect(identity.callerAgentInstanceId).toBeUndefined(); + expect(identity.targetAgentId).toBeUndefined(); + expect(identity.targetAgentAuid).toBeUndefined(); + expect(identity.targetAgentBlueprintId).toBeUndefined(); + expect(identity.tenantId).toBeUndefined(); + }); + + it.each([ + [' USER ', InvocationRole.Human, { aadObjectId: HUMAN_OID }, 'humanOid', HUMAN_OID], + [ + 'aGeNt', + InvocationRole.Agent, + { agenticAppBlueprintId: CALLER_AGENT_BLUEPRINT_ID }, + 'callerAgentBlueprintId', + CALLER_AGENT_BLUEPRINT_ID, + ], + [ + 'agentic-app-instance', + InvocationRole.Agent, + { agenticAppId: CALLER_AGENT_INSTANCE_ID }, + 'callerAgentInstanceId', + CALLER_AGENT_INSTANCE_ID, + ], + [ + 'Agentic_User', + InvocationRole.Agent, + { + aadObjectId: CALLER_AGENT_USER_OID, + agenticAppId: CALLER_AGENT_INSTANCE_ID, + }, + 'callerAgentUserOid', + CALLER_AGENT_USER_OID, + ], + [ + 'SKILL', + InvocationRole.Agent, + { agenticAppBlueprintId: CALLER_AGENT_BLUEPRINT_ID }, + 'callerAgentBlueprintId', + CALLER_AGENT_BLUEPRINT_ID, + ], + ])('normalizes Activity role %s', (role, expectedRole, from, field, expectedValue) => { + const context = makeTurnContext({ + identity: { aud: 'api://target' }, + activity: { from: { role, ...from } }, + }); + + const identity = resolveInvocationIdentityFromTurnContext(context, trustedOptions); + expect(identity.role).toBe(expectedRole); + expect(identity[field as keyof ResolvedInvocationIdentity]).toBe(expectedValue); + expect(identity.resolutionSource).toBe(InvocationIdentityResolutionSource.Activity); + }); + }); + + describe('normalization, precedence, and composition', () => { + it('rejects invalid and nil UUIDs while lowercasing accepted UUIDs', () => { + const identity = resolveInvocationIdentityFromTurnContext(makeTurnContext(), { + invocationRole: InvocationRole.Agent, + validatedPrincipal: { + humanOid: HUMAN_OID.toUpperCase(), + callerAgentUserOid: NIL_UUID, + callerAgentBlueprintId: 'not-a-uuid', + callerAgentInstanceId: CALLER_AGENT_INSTANCE_ID.toUpperCase(), + }, + targetIdentity: { + agentId: TARGET_AGENT_ID.toUpperCase(), + agentAuid: NIL_UUID, + agentBlueprintId: 'invalid', + tenantId: TENANT_ID.toUpperCase(), + }, + }); + + expect(identity).toMatchObject({ + role: InvocationRole.Agent, + humanOid: HUMAN_OID, + callerAgentInstanceId: CALLER_AGENT_INSTANCE_ID, + targetAgentId: TARGET_AGENT_ID, + tenantId: TENANT_ID, + resolutionSource: InvocationIdentityResolutionSource.Composite, + }); + expect(identity.callerAgentUserOid).toBeUndefined(); + expect(identity.callerAgentBlueprintId).toBeUndefined(); + expect(identity.targetAgentAuid).toBeUndefined(); + expect(identity.targetAgentBlueprintId).toBeUndefined(); + expect(() => validateResolvedInvocationIdentity(identity)).not.toThrow(); + }); + + it('uses exact source precedence and reports normalized conflict payloads', () => { + const conflicts: unknown[] = []; + const context = makeTurnContext({ + identity: { + idtyp: 'user', + oid: HOSTING_HUMAN_OID.toUpperCase(), + scp: 'access_as_user', + }, + activity: { + from: { + role: 'user', + aadObjectId: ACTIVITY_HUMAN_OID.toUpperCase(), + }, + }, + }); + + const identity = resolveInvocationIdentityFromTurnContext(context, { + ...trustedOptions, + invocationRole: InvocationRole.Agent, + validatedPrincipal: { + role: InvocationRole.Human, + humanOid: HUMAN_OID.toUpperCase(), + callerAgentBlueprintId: CALLER_AGENT_BLUEPRINT_ID.toUpperCase(), + }, + onConflict: conflict => conflicts.push(conflict), + }); + + expect(identity).toMatchObject({ + role: InvocationRole.Agent, + humanOid: HUMAN_OID, + callerAgentBlueprintId: CALLER_AGENT_BLUEPRINT_ID, + resolutionSource: InvocationIdentityResolutionSource.Composite, + }); + expect(conflicts).toEqual([ + { + field: 'role', + winningSource: InvocationIdentityResolutionSource.Explicit, + losingSource: InvocationIdentityResolutionSource.ValidatedPrincipal, + winningValue: InvocationRole.Agent, + losingValue: InvocationRole.Human, + }, + { + field: 'role', + winningSource: InvocationIdentityResolutionSource.Explicit, + losingSource: InvocationIdentityResolutionSource.HostingPrincipal, + winningValue: InvocationRole.Agent, + losingValue: InvocationRole.Human, + }, + { + field: 'role', + winningSource: InvocationIdentityResolutionSource.Explicit, + losingSource: InvocationIdentityResolutionSource.Activity, + winningValue: InvocationRole.Agent, + losingValue: InvocationRole.Human, + }, + { + field: 'humanOid', + winningSource: InvocationIdentityResolutionSource.ValidatedPrincipal, + losingSource: InvocationIdentityResolutionSource.HostingPrincipal, + winningValue: HUMAN_OID, + losingValue: HOSTING_HUMAN_OID, + }, + { + field: 'humanOid', + winningSource: InvocationIdentityResolutionSource.ValidatedPrincipal, + losingSource: InvocationIdentityResolutionSource.Activity, + winningValue: HUMAN_OID, + losingValue: ACTIVITY_HUMAN_OID, + }, + ]); + }); + + it('prefers target identity from Activity over configured target identity', () => { + const conflicts: unknown[] = []; + const context = makeTurnContext({ + identity: { aud: 'api://target' }, + activity: { + isAgenticRequest: true, + targetAgentId: TARGET_AGENT_ID, + tenantId: TENANT_ID, + recipient: { + aadObjectId: TARGET_AGENT_AUID, + agenticAppBlueprintId: TARGET_AGENT_BLUEPRINT_ID, + }, + }, + }); + + const identity = resolveInvocationIdentityFromTurnContext(context, { + ...trustedOptions, + invocationRole: InvocationRole.Event, + targetIdentity: { + agentId: CONFIGURED_TARGET_AGENT_ID, + agentAuid: CONFIGURED_TARGET_AUID, + agentBlueprintId: CONFIGURED_TARGET_BLUEPRINT_ID, + tenantId: CONFIGURED_TENANT_ID, + }, + onConflict: conflict => conflicts.push(conflict), + }); + + expect(identity).toMatchObject({ + role: InvocationRole.Event, + targetAgentId: TARGET_AGENT_ID, + targetAgentAuid: TARGET_AGENT_AUID, + targetAgentBlueprintId: TARGET_AGENT_BLUEPRINT_ID, + tenantId: TENANT_ID, + resolutionSource: InvocationIdentityResolutionSource.Composite, + }); + expect(conflicts).toEqual([ + { + field: 'targetAgentId', + winningSource: InvocationIdentityResolutionSource.Activity, + losingSource: InvocationIdentityResolutionSource.Configuration, + winningValue: TARGET_AGENT_ID, + losingValue: CONFIGURED_TARGET_AGENT_ID, + }, + { + field: 'targetAgentBlueprintId', + winningSource: InvocationIdentityResolutionSource.Activity, + losingSource: InvocationIdentityResolutionSource.Configuration, + winningValue: TARGET_AGENT_BLUEPRINT_ID, + losingValue: CONFIGURED_TARGET_BLUEPRINT_ID, + }, + { + field: 'targetAgentAuid', + winningSource: InvocationIdentityResolutionSource.Activity, + losingSource: InvocationIdentityResolutionSource.Configuration, + winningValue: TARGET_AGENT_AUID, + losingValue: CONFIGURED_TARGET_AUID, + }, + { + field: 'tenantId', + winningSource: InvocationIdentityResolutionSource.Activity, + losingSource: InvocationIdentityResolutionSource.Configuration, + winningValue: TENANT_ID, + losingValue: CONFIGURED_TENANT_ID, + }, + ]); + }); + + it('retains both a delegated human and the immediate A2A caller agent', () => { + const identity = resolveInvocationIdentityFromTurnContext(makeTurnContext(), { + validatedPrincipal: { + role: InvocationRole.Agent, + humanOid: HUMAN_OID, + callerAgentUserOid: CALLER_AGENT_USER_OID, + callerAgentBlueprintId: CALLER_AGENT_BLUEPRINT_ID, + callerAgentInstanceId: CALLER_AGENT_INSTANCE_ID, + }, + }); + + expect(identity).toMatchObject({ + role: InvocationRole.Agent, + humanOid: HUMAN_OID, + callerAgentUserOid: CALLER_AGENT_USER_OID, + callerAgentBlueprintId: CALLER_AGENT_BLUEPRINT_ID, + callerAgentInstanceId: CALLER_AGENT_INSTANCE_ID, + resolutionSource: InvocationIdentityResolutionSource.ValidatedPrincipal, + }); + }); + }); + + describe('validation', () => { + it.each([ + [ + { + role: InvocationRole.Human, + resolutionSource: InvocationIdentityResolutionSource.Explicit, + }, + InvocationIdentityValidationCode.MissingHumanIdentity, + ], + [ + { + role: InvocationRole.Agent, + humanOid: HUMAN_OID, + resolutionSource: InvocationIdentityResolutionSource.Explicit, + }, + InvocationIdentityValidationCode.MissingAgentIdentity, + ], + [ + { + role: InvocationRole.Event, + resolutionSource: InvocationIdentityResolutionSource.Explicit, + }, + InvocationIdentityValidationCode.MissingEventExecutionIdentity, + ], + [ + { + role: InvocationRole.Unknown, + resolutionSource: InvocationIdentityResolutionSource.Unknown, + }, + InvocationIdentityValidationCode.UnknownInvocationRole, + ], + ])('rejects incomplete identity %# with the expected code', (identity, code) => { + expect(() => validateResolvedInvocationIdentity(identity as ResolvedInvocationIdentity)) + .toThrow(expect.objectContaining({ + name: InvocationIdentityValidationError.name, + code, + })); + }); + }); +}); diff --git a/tests/observability/extension/hosting/observability-hosting-manager.test.ts b/tests/observability/extension/hosting/observability-hosting-manager.test.ts index 7b2d5db7..f6eeb989 100644 --- a/tests/observability/extension/hosting/observability-hosting-manager.test.ts +++ b/tests/observability/extension/hosting/observability-hosting-manager.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect } from '@jest/globals'; import { ObservabilityHostingManager } from '../../../../packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager'; import { BaggageMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware'; import { OutputLoggingMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware'; +import { InvocationIdentityMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/InvocationIdentityMiddleware'; function mockAdapter() { const registered: any[] = []; @@ -12,9 +13,17 @@ function mockAdapter() { } describe('ObservabilityHostingManager', () => { - it('does not register BaggageMiddleware by default', () => { + it.each([ + ['the flag is omitted', {}], + ['the flag is false', { enableInvocationIdentity: false }], + ['only identity options are supplied', { + strictIdentityValidation: true, + resolveValidatedPrincipal: () => undefined, + targetIdentity: { agentId: '77777777-7777-4777-8777-777777777777' }, + }], + ])('does not register InvocationIdentityMiddleware when %s', (_label, options) => { const adapter = mockAdapter(); - new ObservabilityHostingManager().configure(adapter, {}); + new ObservabilityHostingManager().configure(adapter, options); expect(adapter.registered).toHaveLength(0); }); @@ -33,6 +42,20 @@ describe('ObservabilityHostingManager', () => { expect(adapter.registered[1]).toBeInstanceOf(OutputLoggingMiddleware); }); + it('registers middleware in Identity-Baggage-Output order', () => { + const adapter = mockAdapter(); + new ObservabilityHostingManager().configure(adapter, { + enableInvocationIdentity: true, + enableBaggage: true, + enableOutputLogging: true, + }); + + expect(adapter.registered).toHaveLength(3); + expect(adapter.registered[0]).toBeInstanceOf(InvocationIdentityMiddleware); + expect(adapter.registered[1]).toBeInstanceOf(BaggageMiddleware); + expect(adapter.registered[2]).toBeInstanceOf(OutputLoggingMiddleware); + }); + it('registers only OutputLoggingMiddleware when enableOutputLogging is true and enableBaggage is omitted', () => { const adapter = mockAdapter(); new ObservabilityHostingManager().configure(adapter, { enableOutputLogging: true }); @@ -47,4 +70,14 @@ describe('ObservabilityHostingManager', () => { manager.configure(adapter, { enableBaggage: true, enableOutputLogging: true }); expect(adapter.registered).toHaveLength(2); }); + + it('deduplicates identity middleware for the same adapter across manager instances', () => { + const adapter = mockAdapter(); + + new ObservabilityHostingManager().configure(adapter, { enableInvocationIdentity: true }); + new ObservabilityHostingManager().configure(adapter, { enableInvocationIdentity: true }); + + expect(adapter.registered).toHaveLength(1); + expect(adapter.registered[0]).toBeInstanceOf(InvocationIdentityMiddleware); + }); }); diff --git a/tests/observability/extension/hosting/output-logging-middleware.test.ts b/tests/observability/extension/hosting/output-logging-middleware.test.ts index 6ef92e7d..ff7ff3c1 100644 --- a/tests/observability/extension/hosting/output-logging-middleware.test.ts +++ b/tests/observability/extension/hosting/output-logging-middleware.test.ts @@ -24,10 +24,17 @@ import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from ' import { trace, context as otelContext } from '@opentelemetry/api'; import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import { RoleTypes, ActivityTypes, ActivityEventNames } from '@microsoft/agents-activity'; -import type { TurnContext, SendActivitiesHandler } from '@microsoft/agents-hosting'; +import type { Middleware, TurnContext, SendActivitiesHandler } from '@microsoft/agents-hosting'; import { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY, A365_AUTH_TOKEN_KEY } from '../../../../packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware'; -import { OpenTelemetryConstants, ParentSpanRef } from '@microsoft/agents-a365-observability'; +import { InvocationIdentityMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/InvocationIdentityMiddleware'; +import { InvocationRole, OpenTelemetryConstants, ParentSpanRef } from '@microsoft/agents-a365-observability'; + +const HUMAN_OID = '11111111-1111-4111-8111-111111111111'; +const TARGET_AGENT_ID = '77777777-7777-4777-8777-777777777777'; +const TARGET_AGENT_BLUEPRINT_ID = '88888888-8888-4888-8888-888888888888'; +const TARGET_AGENT_AUID = '99999999-9999-4999-8999-999999999999'; +const TENANT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; function makeMockTurnContext(options?: { text?: string; @@ -92,6 +99,24 @@ function makeMockTurnContext(options?: { return ctx; } +async function runMiddlewarePipeline( + middlewares: Middleware[], + context: TurnContext, + terminal: () => Promise, +): Promise { + let index = 0; + const next = async (): Promise => { + const middleware = middlewares[index++]; + if (middleware) { + await middleware.onTurn(context, next); + return; + } + await terminal(); + }; + + await next(); +} + describe('OutputLoggingMiddleware', () => { let exporter: InMemorySpanExporter; let provider: BasicTracerProvider; @@ -206,6 +231,52 @@ describe('OutputLoggingMiddleware', () => { expect(outputSpan!.attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe('teams'); }); + it.each([ + ['before', true], + ['after', false], + ])('uses resolved identity when identity middleware is registered %s output logging', async ( + _label, + identityFirst, + ) => { + const outputMiddleware = new OutputLoggingMiddleware(); + const identityMiddleware = new InvocationIdentityMiddleware({ + resolveValidatedPrincipal: () => ({ + role: InvocationRole.Human, + humanOid: HUMAN_OID, + }), + targetIdentity: { + agentId: TARGET_AGENT_ID, + agentAuid: TARGET_AGENT_AUID, + agentBlueprintId: TARGET_AGENT_BLUEPRINT_ID, + tenantId: TENANT_ID, + }, + }); + const ctx = makeMockTurnContext({ text: 'Hello' }); + const middlewares = identityFirst + ? [identityMiddleware, outputMiddleware] + : [outputMiddleware, identityMiddleware]; + + await runMiddlewarePipeline(middlewares, ctx, async () => { + ctx.turnState.set(A365_PARENT_SPAN_KEY, { + traceId: '0af7651916cd43dd8448eb211c80319c', + spanId: 'b7ad6b7169203331', + traceFlags: 1, + }); + await ctx.simulateSend([{ type: 'message', text: 'Identity-aware reply' }]); + }); + + await flushProvider.forceFlush(); + const outputSpan = exporter.getFinishedSpans().find(s => s.name.includes('output_messages')); + expect(outputSpan).toBeDefined(); + expect(outputSpan!.attributes[OpenTelemetryConstants.USER_ID_KEY]).toBe(HUMAN_OID); + expect(outputSpan!.attributes[OpenTelemetryConstants.USER_EMAIL_KEY]).toBeUndefined(); + expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(TARGET_AGENT_ID); + expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY]).toBe(TARGET_AGENT_AUID); + expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY]) + .toBe(TARGET_AGENT_BLUEPRINT_ID); + expect(outputSpan!.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(TENANT_ID); + }); + it('should link OutputScope to parent when parentSpanRef is set in turnState', async () => { const middleware = new OutputLoggingMiddleware(); const ctx = makeMockTurnContext({ text: 'Hello' }); diff --git a/tests/observability/extension/hosting/scope-utils.test.ts b/tests/observability/extension/hosting/scope-utils.test.ts index ffe42c52..1185133d 100644 --- a/tests/observability/extension/hosting/scope-utils.test.ts +++ b/tests/observability/extension/hosting/scope-utils.test.ts @@ -16,12 +16,32 @@ jest.mock('@microsoft/agents-a365-runtime', () => { }); import { ScopeUtils } from '../../../../packages/agents-a365-observability-hosting/src/utils/ScopeUtils'; -import { InferenceScope, InvokeAgentScope, ExecuteToolScope, OpenTelemetryConstants, OpenTelemetryScope } from '@microsoft/agents-a365-observability'; -import { SpanKind } from '@opentelemetry/api'; +import { + ExecuteToolScope, + InferenceScope, + InvocationIdentityResolutionSource, + InvocationRole, + InvokeAgentScope, + OpenTelemetryConstants, + OpenTelemetryScope, + runWithResolvedInvocationIdentity, +} from '@microsoft/agents-a365-observability'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { context as otelContext, SpanKind } from '@opentelemetry/api'; import { RoleTypes } from '@microsoft/agents-activity'; import type { TurnContext } from '@microsoft/agents-hosting'; const testAuthToken = 'mock-auth-token'; +const HUMAN_OID = '11111111-1111-4111-8111-111111111111'; +const CALLER_AGENT_USER_OID = '44444444-4444-4444-8444-444444444444'; +const CALLER_AGENT_BLUEPRINT_ID = '55555555-5555-4555-8555-555555555555'; +const CALLER_AGENT_INSTANCE_ID = '66666666-6666-4666-8666-666666666666'; +const TARGET_AGENT_ID = '77777777-7777-4777-8777-777777777777'; +const TARGET_AGENT_BLUEPRINT_ID = '88888888-8888-4888-8888-888888888888'; +const TARGET_AGENT_AUID = '99999999-9999-4999-8999-999999999999'; +const TENANT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const EXPLICIT_TARGET_AGENT_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const EXPLICIT_TARGET_BLUEPRINT_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; function makeTurnContext( text?: string, @@ -317,3 +337,121 @@ describe('ScopeUtils spanKind forwarding', () => { spy.mockRestore(); }); }); + +describe('ScopeUtils resolved invocation identity', () => { + let contextManager: AsyncLocalStorageContextManager; + + beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); + }); + + afterAll(() => { + contextManager.disable(); + otelContext.disable(); + }); + + test('uses resolved identity and retains both delegated human and immediate agent', async () => { + const ctx = makeTurnContext('invoke message', 'teams', 'https://teams', 'conv-resolved'); + ctx.activity.from!.role = RoleTypes.AgenticUser; + + await runWithResolvedInvocationIdentity({ + role: InvocationRole.Agent, + humanOid: HUMAN_OID, + callerAgentUserOid: CALLER_AGENT_USER_OID, + callerAgentBlueprintId: CALLER_AGENT_BLUEPRINT_ID, + callerAgentInstanceId: CALLER_AGENT_INSTANCE_ID, + targetAgentId: TARGET_AGENT_ID, + targetAgentBlueprintId: TARGET_AGENT_BLUEPRINT_ID, + targetAgentAuid: TARGET_AGENT_AUID, + tenantId: TENANT_ID, + resolutionSource: InvocationIdentityResolutionSource.Composite, + }, async () => { + expect(ScopeUtils.deriveAgentDetails(ctx, testAuthToken)).toEqual({ + agentId: TARGET_AGENT_ID, + agentName: 'Agent One', + agentAUID: TARGET_AGENT_AUID, + agentBlueprintId: TARGET_AGENT_BLUEPRINT_ID, + agentEmail: 'agent-upn@contoso.com', + agentDescription: 'assistant', + tenantId: TENANT_ID, + }); + expect(ScopeUtils.deriveCallerAgent(ctx)).toEqual({ + agentId: CALLER_AGENT_INSTANCE_ID, + agentAUID: CALLER_AGENT_USER_OID, + agentBlueprintId: CALLER_AGENT_BLUEPRINT_ID, + agentName: 'Test User', + agentDescription: RoleTypes.AgenticUser, + agentEmail: 'user@contoso.com', + tenantId: TENANT_ID, + }); + + const userDetails = ScopeUtils.deriveCallerDetails(ctx); + expect(userDetails).toEqual({ + userId: HUMAN_OID, + userName: undefined, + tenantId: TENANT_ID, + }); + expect(userDetails).not.toHaveProperty('userEmail'); + + const start = jest.spyOn(InvokeAgentScope, 'start'); + const scope = ScopeUtils.populateInvokeAgentScopeFromTurnContext( + { agentId: TARGET_AGENT_ID }, + {}, + ctx, + testAuthToken, + ); + expect(start).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + { + userDetails: { + userId: HUMAN_OID, + userName: undefined, + tenantId: TENANT_ID, + }, + callerAgentDetails: expect.objectContaining({ + agentId: CALLER_AGENT_INSTANCE_ID, + agentAUID: CALLER_AGENT_USER_OID, + agentBlueprintId: CALLER_AGENT_BLUEPRINT_ID, + }), + }, + undefined, + ); + scope.dispose(); + start.mockRestore(); + }); + }); + + test('lets explicit nonblank target details win and fills blanks from resolved identity', async () => { + const ctx = makeTurnContext(); + + await runWithResolvedInvocationIdentity({ + role: InvocationRole.Human, + humanOid: HUMAN_OID, + targetAgentId: TARGET_AGENT_ID, + targetAgentBlueprintId: TARGET_AGENT_BLUEPRINT_ID, + targetAgentAuid: TARGET_AGENT_AUID, + tenantId: TENANT_ID, + resolutionSource: InvocationIdentityResolutionSource.Composite, + }, async () => { + const result = ScopeUtils.buildInvokeAgentDetails({ + agentId: EXPLICIT_TARGET_AGENT_ID, + agentName: 'Explicit Agent', + agentAUID: ' ', + agentBlueprintId: EXPLICIT_TARGET_BLUEPRINT_ID, + tenantId: '', + }, ctx, testAuthToken); + + expect(result).toMatchObject({ + agentId: EXPLICIT_TARGET_AGENT_ID, + agentName: 'Explicit Agent', + agentAUID: TARGET_AGENT_AUID, + agentBlueprintId: EXPLICIT_TARGET_BLUEPRINT_ID, + tenantId: TENANT_ID, + }); + }); + }); +}); diff --git a/tests/observability/extension/invocation-identity-extensions.test.ts b/tests/observability/extension/invocation-identity-extensions.test.ts new file mode 100644 index 00000000..eb83fa14 --- /dev/null +++ b/tests/observability/extension/invocation-identity-extensions.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { context } from '@opentelemetry/api'; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { + InvocationIdentityResolutionSource, + InvocationRole, + OpenTelemetryConstants, + runWithResolvedInvocationIdentity, +} from '@microsoft/agents-a365-observability'; +import { SpanProcessor } from '../../../packages/agents-a365-observability/src/tracing/processors/SpanProcessor'; +import { LangChainTracer } from '../../../packages/agents-a365-observability-extensions-langchain/src/tracer'; +import { OpenAIAgentsTraceProcessor } from '../../../packages/agents-a365-observability-extensions-openai/src/OpenAIAgentsTraceProcessor'; + +const HUMAN_OID = '11111111-1111-4111-8111-111111111111'; +const TARGET_AGENT_ID = '77777777-7777-4777-8777-777777777777'; +const TENANT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +describe('extension span invocation identity', () => { + let contextManager: AsyncLocalStorageContextManager; + let exporter: InMemorySpanExporter; + let provider: BasicTracerProvider; + + beforeEach(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + context.setGlobalContextManager(contextManager); + exporter = new InMemorySpanExporter(); + provider = new BasicTracerProvider({ + spanProcessors: [ + new SpanProcessor(), + new SimpleSpanProcessor(exporter), + ], + }); + }); + + afterEach(async () => { + await provider.shutdown(); + contextManager.disable(); + context.disable(); + }); + + async function runWithHumanIdentity(callback: () => Promise): Promise { + await runWithResolvedInvocationIdentity({ + role: InvocationRole.Human, + humanOid: HUMAN_OID, + targetAgentId: TARGET_AGENT_ID, + tenantId: TENANT_ID, + resolutionSource: InvocationIdentityResolutionSource.Composite, + }, callback); + } + + it('enriches LangChain spans', async () => { + const tracer = new LangChainTracer(provider.getTracer('langchain-identity-test')); + const run = { + id: 'langchain-run-1', + name: 'ChatModel', + run_type: 'llm', + start_time: Date.now(), + inputs: { messages: [] }, + outputs: {}, + serialized: {}, + tags: [], + extra: {}, + } as any; + + await runWithHumanIdentity(async () => { + await tracer.onRunCreate(run); + }); + await (tracer as any)._endTrace({ ...run, end_time: Date.now() }); + await provider.forceFlush(); + + const span = exporter.getFinishedSpans().find( + candidate => candidate.attributes[OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY] === 'langchain', + ); + expect(span?.attributes[OpenTelemetryConstants.USER_ID_KEY]).toBe(HUMAN_OID); + expect(span?.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(TARGET_AGENT_ID); + expect(span?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(TENANT_ID); + }); + + it('enriches OpenAI Agents spans', async () => { + const processor = new OpenAIAgentsTraceProcessor( + provider.getTracer('openai-identity-test'), + ); + const span = { + spanId: 'openai-span-1', + traceId: 'openai-trace-1', + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + spanData: { + type: 'generation', + name: 'Generate', + model: 'gpt-4o', + }, + } as any; + + await runWithHumanIdentity(async () => { + await processor.onSpanStart(span); + }); + await processor.onSpanEnd(span); + await provider.forceFlush(); + + const exported = exporter.getFinishedSpans().find( + candidate => candidate.attributes[OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY] === 'openai', + ); + expect(exported?.attributes[OpenTelemetryConstants.USER_ID_KEY]).toBe(HUMAN_OID); + expect(exported?.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(TARGET_AGENT_ID); + expect(exported?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(TENANT_ID); + + await processor.shutdown(); + }); +});