From 93f19345b37febef3fe4a88421d8c13a1af89c89 Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:55:34 +0000 Subject: [PATCH 1/4] fix(observability): use S2S export and app-only token resolvers Always route OBS to observabilityService, retain the legacy endpoint option as ignored compatibility state, and replace delegated hosting-cache exchange with an explicit app-only resolver. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 + .../docs/design.md | 22 +- .../src/caching/AgenticTokenCache.ts | 73 ++-- .../src/index.ts | 1 + packages/agents-a365-observability/README.md | 20 + .../agents-a365-observability/src/index.ts | 1 + .../src/tracing/exporter/Agent365Exporter.ts | 8 +- .../exporter/Agent365ExporterOptions.ts | 9 +- .../core/agent365-exporter.test.ts | 46 ++- .../hosting/agentic-token-cache.test.ts | 377 ++++++++---------- 10 files changed, 297 insertions(+), 268 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e22443d..1726d175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,14 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. ### Breaking Changes (`@microsoft/agents-a365-observability`) +- **OBS exports always use `/observabilityService`** - The `useS2SEndpoint` option is + deprecated and ignored, even when `false`. Batch and per-request exports no longer + select or fall back to `/observability`. Provide an app-only OBS token independently + of your agent's workload auth; the S2S service rejects delegated `scp` tokens. +- **Hosting OBS token cache requires an app-only resolver** - + `RefreshObservabilityToken(agentId, tenantId, tokenResolver)` replaces the + `TurnContext`/`Authorization` overload, which now throws without exchanging a + user token. Token acquisition failures propagate instead of appearing successful. - **`InvokeAgentDetails` renamed to `InvokeAgentScopeDetails`** — Now contains only scope-level config (`endpoint`). Agent identity (`AgentDetails`) is a separate parameter. `sessionId` moved to `Request`. - **`InvokeAgentScope.start()` — new signature.** `start(request, invokeScopeDetails, agentDetails, callerDetails?, spanDetails?)`. Tenant ID is derived from `agentDetails.tenantId` (required). `userDetails` and `callerAgentDetails` are wrapped in `CallerDetails`. Span options grouped in `SpanDetails`. - **`InferenceScope.start()` — new signature.** `start(request, details, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). diff --git a/packages/agents-a365-observability-hosting/docs/design.md b/packages/agents-a365-observability-hosting/docs/design.md index c422f227..2d4fa308 100644 --- a/packages/agents-a365-observability-hosting/docs/design.md +++ b/packages/agents-a365-observability-hosting/docs/design.md @@ -107,18 +107,30 @@ const agentPairs = getTargetAgentBaggagePairs(turnContext); ### AgenticTokenCacheInstance ([AgenticTokenCache.ts](../src/caching/AgenticTokenCache.ts)) -Token caching for improved performance: +Cache app-only OBS tokens independently of the workload's AI Teammate or OBO +authorization. The former overload accepting `TurnContext` and `Authorization` +now throws rather than acquiring a delegated token incompatible with S2S. ```typescript import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-hosting'; -// Cache token with key -AgenticTokenCacheInstance.set('cache-key', 'token-value', ttlMs); +// acquireAppOnlyObsToken is your app-only token acquisition callback. +// It receives (agentId, tenantId, scopes) and returns the final OBS access token. +await AgenticTokenCacheInstance.RefreshObservabilityToken( + agentId, tenantId, acquireAppOnlyObsToken +); -// Retrieve cached token -const token = AgenticTokenCacheInstance.get('cache-key'); +const token = AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId); ``` +For a blueprint-backed agent, acquire a blueprint exchange assertion with +`fmi_path=agentId`, then use it as `client_assertion` in an instance +`client_credentials` request for the OBS `/.default` scope. Do not send the +intermediate assertion, a blueprint token, or a `user_fic`/OBO token to OBS. +The final token's application identity must match `agentId`; it needs the +`Agent365.Observability.OtelWrite` application permission. Acquisition failures +propagate to the caller and never trigger delegated authentication. + ## Tenant ID Resolution The package extracts tenant ID from multiple sources: diff --git a/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts b/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts index 62a4b3d7..7626b49a 100644 --- a/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts +++ b/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts @@ -4,9 +4,16 @@ // ------------------------------------------------------------------------------ import { TurnContext, Authorization } from '@microsoft/agents-hosting'; -import { logger, formatError, ObservabilityConfiguration, defaultObservabilityConfigurationProvider } from '@microsoft/agents-a365-observability'; +import { logger, formatError, ObservabilityConfiguration, defaultObservabilityConfigurationProvider, TokenResolver } from '@microsoft/agents-a365-observability'; import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; +/** Acquires an app-only OBS token; must not perform user_fic or OBO authentication. */ +export type ObservabilityTokenResolver = ( + agentId: string, + tenantId: string, + scopes: readonly string[] +) => ReturnType; + interface CacheEntry { scopes: string[]; token?: string; @@ -64,28 +71,48 @@ export class AgenticTokenCache { return entry.token; } + /** + * Refreshes an app-only OBS token independently of the current user's authorization. + * The resolver receives the configured OBS scopes and must acquire a token for + * the exporting agent identity, not its blueprint or the workload's user. + */ + public async RefreshObservabilityToken( + agentId: string, + tenantId: string, + tokenResolver: ObservabilityTokenResolver + ): Promise; + + /** @deprecated User token exchange cannot authenticate S2S OBS. Pass an app-only token resolver instead. */ public async RefreshObservabilityToken( agentId: string, tenantId: string, turnContext: TurnContext, authorization: Authorization, scopes: string[], - authHandlerName: string = 'agentic' + authHandlerName?: string + ): Promise; + + public async RefreshObservabilityToken( + agentId: string, + tenantId: string, + resolverOrContext: ObservabilityTokenResolver | TurnContext, + _authorization?: Authorization, + _scopes?: string[], + _authHandlerName?: string ): Promise { - const key = AgenticTokenCache.makeKey(agentId, tenantId); - if (!authorization) { - throw new Error('[AgenticTokenCache] Authorization not set'); + if (typeof resolverOrContext !== 'function') { + throw new Error('[AgenticTokenCache] S2S OBS requires an app-only token resolver. Use RefreshObservabilityToken(agentId, tenantId, tokenResolver); delegated user token exchange is no longer supported.'); } - if (!turnContext) { - throw new Error('[AgenticTokenCache] TurnContext not set'); + if (!agentId?.trim() || !tenantId?.trim()) { + throw new Error('[AgenticTokenCache] Agent and tenant IDs are required'); } + const key = AgenticTokenCache.makeKey(agentId, tenantId); return this.withKeyLock(key, async () => { let entry = this._map.get(key); if (!entry) { - const effectiveScopes = (scopes && scopes.length > 0) ? scopes : [...this._configProvider.getConfiguration().observabilityAuthenticationScopes]; + const effectiveScopes = [...this._configProvider.getConfiguration().observabilityAuthenticationScopes]; if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) { - logger.error('[AgenticTokenCache] No valid scopes'); - return; + throw new Error('[AgenticTokenCache] No valid scopes'); } entry = { scopes: effectiveScopes }; if (this._map.size >= this._maxCacheSize) { @@ -97,8 +124,7 @@ export class AgenticTokenCache { this._map.set(key, entry); } if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) { - logger.error('[AgenticTokenCache] Entry has invalid scopes'); - return; + throw new Error('[AgenticTokenCache] Entry has invalid scopes'); } if (entry.token && !this.isExpired(entry)) { @@ -107,21 +133,19 @@ export class AgenticTokenCache { const maxRetries = 2; for (let attempt = 0; attempt <= maxRetries; attempt++) { - logger.info(`[AgenticTokenCache] Exchanging token attempt ${attempt + 1}/${maxRetries + 1}`); + logger.info(`[AgenticTokenCache] Acquiring app-only token attempt ${attempt + 1}/${maxRetries + 1}`); try { - const tokenResponse = await authorization.exchangeToken(turnContext, authHandlerName, { scopes: entry.scopes }); - if (!tokenResponse?.token) { - logger.error('[AgenticTokenCache] Undefined token returned'); - entry.token = undefined; - entry.expiresOn = undefined; - break; + const token = await resolverOrContext(agentId, tenantId, [...entry.scopes]); + if (!token?.trim()) { + throw new Error('[AgenticTokenCache] App-only token resolver returned no token'); } - entry.token = tokenResponse.token; + entry.token = token; entry.acquiredOn = Date.now(); - const oboExp = this.decodeExp(entry.token); - if (oboExp) { - entry.expiresOn = oboExp * 1000; + const exp = this.decodeExp(token); + if (exp) { + entry.expiresOn = exp * 1000; } else { + entry.expiresOn = undefined; logger.warn('[AgenticTokenCache] No exp claim, fallback TTL'); } logger.info('[AgenticTokenCache] Token cached'); @@ -136,7 +160,8 @@ export class AgenticTokenCache { logger.error('[AgenticTokenCache] Non-retriable failure', formatError(e)); entry.token = undefined; entry.expiresOn = undefined; - break; + entry.acquiredOn = undefined; + throw e; } } }); diff --git a/packages/agents-a365-observability-hosting/src/index.ts b/packages/agents-a365-observability-hosting/src/index.ts index ed0173a3..76943828 100644 --- a/packages/agents-a365-observability-hosting/src/index.ts +++ b/packages/agents-a365-observability-hosting/src/index.ts @@ -7,6 +7,7 @@ export * from './utils/BaggageBuilderUtils'; export * from './utils/ScopeUtils'; export * from './utils/TurnContextUtils'; export { AgenticTokenCache, AgenticTokenCacheInstance } from './caching/AgenticTokenCache'; +export type { ObservabilityTokenResolver } from './caching/AgenticTokenCache'; export { BaggageMiddleware } from './middleware/BaggageMiddleware'; export { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY, A365_AUTH_TOKEN_KEY } from './middleware/OutputLoggingMiddleware'; export { ObservabilityHostingManager } from './middleware/ObservabilityHostingManager'; diff --git a/packages/agents-a365-observability/README.md b/packages/agents-a365-observability/README.md index 5ddb1410..1dc6a065 100644 --- a/packages/agents-a365-observability/README.md +++ b/packages/agents-a365-observability/README.md @@ -15,6 +15,26 @@ npm install @microsoft/agents-a365-observability For detailed usage examples and implementation guidance, see the [Microsoft Agent 365 Observability Documentation](https://learn.microsoft.com/microsoft-agent-365/developer/observability?tabs=nodejs). +### OBS endpoint + +All exports use `/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1`, +including batch and per-request exports from AI Teammate and OBO workloads. The exporter +never falls back to `/observability`. The `useS2SEndpoint` option is deprecated and ignored, +including when set to `false`; domain overrides change the host, not this route. + +Endpoint selection does not acquire or convert tokens. Supply an **app-only** OBS token +for the exporting tenant and agent identity with `Agent365.Observability.OtelWrite` +application permission. The S2S service rejects delegated (`scp`) tokens, including +AI Teammate user tokens. Keep workload authentication +(such as OBO for MCP or Microsoft Graph) separate from OBS authentication. An authorization +failure is not a reason to retry telemetry on the OBO route. + +When using the hosting token cache, call +`RefreshObservabilityToken(agentId, tenantId, appOnlyTokenResolver)`. The old +`TurnContext`/`Authorization` overload throws rather than acquiring a delegated OBS token. +S2S ingestion may remove unverified user attribution; routing a workload through S2S +does not establish that its caller identity is trusted. + ## Support For issues, questions, or feedback: diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index b1f78544..ad00731d 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -5,6 +5,7 @@ export { ObservabilityManager } from './ObservabilityManager'; export { ObservabilityBuilder as Builder, BuilderOptions } from './ObservabilityBuilder'; export { Agent365ExporterOptions } from './tracing/exporter/Agent365ExporterOptions'; +export type { TokenResolver } from './tracing/exporter/Agent365ExporterOptions'; // Tracing constants export { OpenTelemetryConstants } from './tracing/constants'; export { ExporterEventNames } from './tracing/exporter/ExporterEventNames'; diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index fffc5648..acd78f01 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -90,8 +90,7 @@ interface MappedSpan { * Observability span exporter for Agent365: * - Partitions spans by (tenantId, agentId) * - Builds OTLP-like JSON: resourceSpans -> scopeSpans -> spans - * - POSTs per group to https://{endpoint}/observability/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1 - * or, when useS2SEndpoint is true, https://{endpoint}/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1 + * - POSTs per group to https://{endpoint}/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1 * - Adds Bearer token via token_resolver(agentId, tenantId) */ export class Agent365Exporter implements SpanExporter { @@ -189,9 +188,8 @@ export class Agent365Exporter implements SpanExporter { logger.info(`[Agent365Exporter] Split ${spans.length} spans into ${chunks.length} chunks for tenantId: ${tenantId}, agentId: ${agentId}`); } - // Select endpoint path based on S2S flag (includes tenantId in path) - const servicePrefix = this.options.useS2SEndpoint ? '/observabilityService' : '/observability'; - const endpointRelativePath = `${servicePrefix}/tenants/${encodeURIComponent(tenantId)}/otlp/agents/${encodeURIComponent(agentId)}/traces`; + // OBS routing is independent of the agent's workload authentication flow. + const endpointRelativePath = `/observabilityService/tenants/${encodeURIComponent(tenantId)}/otlp/agents/${encodeURIComponent(agentId)}/traces`; let url: string; const domainOverride = getAgent365ObservabilityDomainOverride(this.configProvider); diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index 340f462a..71b20e60 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -6,7 +6,8 @@ import { ClusterCategory } from '@microsoft/agents-a365-runtime'; /** - * A function that resolves and returns an authentication token for the given agent and tenant. + * A function that resolves an app-only OBS token for the given agent and tenant. + * Delegated (scp) tokens are not accepted by the S2S service. * Implementations may perform synchronous lookup (e.g., in-memory cache) or asynchronous network calls. * Return null if a token cannot be provided; exporter will log and proceed without an authorization header. */ @@ -21,7 +22,7 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null * @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. ClusterCategory.preprod, ClusterCategory.prod, default to ClusterCategory.prod). * @property {TokenResolver} [tokenResolver] Optional delegate to obtain an auth token. If omitted the exporter will * fall back to reading the cached token (AgenticTokenCacheInstance.getObservabilityToken). - * @property {boolean} [useS2SEndpoint] When true, exporter will POST to the S2S path (/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces). + * @property {boolean} [useS2SEndpoint] Deprecated compatibility option. Export always uses the S2S path, even when false. * @property {number} maxQueueSize Maximum span queue size before drops occur (passed to BatchSpanProcessor). * @property {number} scheduledDelayMilliseconds Delay between automatic batch flush attempts. * @property {number} exporterTimeoutMilliseconds Maximum time (ms) the BatchSpanProcessor waits for the entire export() call to complete before giving up. Covers partitioning, token resolution, and all HTTP retries. @@ -35,8 +36,8 @@ export class Agent365ExporterOptions { /** Optional delegate to resolve auth token used by exporter */ public tokenResolver?: TokenResolver; // Optional if ENABLE_A365_OBSERVABILITY_EXPORTER is false - /** When true, use S2S endpoint path for export. */ - public useS2SEndpoint: boolean = false; + /** @deprecated Export always uses /observabilityService. This option is ignored. */ + public useS2SEndpoint: boolean = true; /** Maximum span queue size before new spans are dropped. */ public maxQueueSize: number = 2048; diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index 724eaccc..045dd812 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -138,7 +138,7 @@ describe('Agent365Exporter', () => { expect(fetchCalls.length).toBe(1); const urlArg = fetchCalls[0][0]; const headersArg = fetchCalls[0][1].headers; - expect(urlArg).toBe(`${expectedUrl}/observability/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + expect(urlArg).toBe(`${expectedUrl}/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); expect(headersArg['x-ms-tenant-id']).toBe(tenantId); expect(headersArg['authorization']).toBe(`Bearer ${token}`); }); @@ -193,7 +193,7 @@ describe('Agent365Exporter', () => { const urlArg = fetchCalls[0][0] as string; const headersArg = fetchCalls[0][1].headers as Record; - expect(urlArg).toBe(`${expectedBaseUrl}/observability/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + expect(urlArg).toBe(`${expectedBaseUrl}/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); expect(headersArg['x-ms-tenant-id']).toBe(tenantId); expect(headersArg['authorization']).toBe(`Bearer ${token}`); }); @@ -218,7 +218,7 @@ describe('Agent365Exporter', () => { expect(fetchCalls.length).toBe(1); const urlArg = fetchCalls[0][0]; const headersArg = fetchCalls[0][1].headers; - expect(urlArg).toBe(`https://agent365.svc.cloud.microsoft/observability/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + expect(urlArg).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); expect(headersArg['x-ms-tenant-id']).toBe(tenantId); expect(headersArg['authorization']).toBe(`Bearer ${token}`); }); @@ -229,13 +229,19 @@ describe('Agent365Exporter', () => { // Intentionally omit tokenResolver expect(() => new Agent365Exporter(opts)).toThrow(/tokenResolver must be provided/); }); - it('uses S2S endpoint path when useS2SEndpoint is true', async () => { + it('defaults the legacy endpoint option to S2S', () => { + expect(new Agent365ExporterOptions().useS2SEndpoint).toBe(true); + }); + + it.each([undefined, false, true])('always uses S2S when useS2SEndpoint is %s', async (useS2SEndpoint) => { mockFetchSequence([200]); const token = 'tok-s2s'; const opts = new Agent365ExporterOptions(); opts.clusterCategory = 'prod'; opts.tokenResolver = () => token; - opts.useS2SEndpoint = true; + if (useS2SEndpoint !== undefined) { + opts.useS2SEndpoint = useS2SEndpoint; + } const exporter = new Agent365Exporter(opts); const spans = [ @@ -259,14 +265,14 @@ describe('Agent365Exporter', () => { expect(headersArg['x-ms-tenant-id']).toBe(tenantId); }); - it('uses S2S endpoint path with domain override and sets x-ms-tenant-id', async () => { + it.each([false, true])('uses S2S with a domain override and legacy option %s', async (useS2SEndpoint) => { mockFetchSequence([200]); process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://custom.domain'; const token = 'tok-s2s-custom'; const opts = new Agent365ExporterOptions(); opts.clusterCategory = 'prod'; opts.tokenResolver = () => token; - opts.useS2SEndpoint = true; + opts.useS2SEndpoint = useS2SEndpoint; const exporter = new Agent365Exporter(opts); const spans = [ @@ -289,6 +295,25 @@ describe('Agent365Exporter', () => { }); + it.each([401, 403, 404])('does not fall back to OBO when S2S returns %s', async (status) => { + mockFetchSequence([status]); + const opts = new Agent365ExporterOptions(); + opts.tokenResolver = () => 'test-obs-token'; + opts.useS2SEndpoint = false; + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback); + + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); + const calls = getFetchCalls(); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + }); + it('passes httpRequestTimeoutMilliseconds to fetch AbortSignal.timeout', async () => { const customTimeout = 12345; mockFetchSequence([200]); @@ -1028,12 +1053,13 @@ describe('Agent365Exporter', () => { contextManager = undefined; }); - it('acquires export token from OTel Context when per-request export is enabled', async () => { + it.each([false, true])('uses S2S with a per-request context token and legacy option %s', async (useS2SEndpoint) => { mockFetchSequence([200]); process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; const opts = new Agent365ExporterOptions(); opts.clusterCategory = 'local'; + opts.useS2SEndpoint = useS2SEndpoint; const exporter = new Agent365Exporter(opts); const spans = [ @@ -1049,9 +1075,9 @@ describe('Agent365Exporter', () => { expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - // Verify export was attempted (should be greater than 0 when enabled) const fetchCalls = getFetchCalls(); - expect(fetchCalls.length).toBeGreaterThan(0); + expect(fetchCalls).toHaveLength(1); + expect(fetchCalls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); // Verify token came from OTel Context (per-request mode) const headersArg = fetchCalls[0][1].headers as Record; diff --git a/tests/observability/extension/hosting/agentic-token-cache.test.ts b/tests/observability/extension/hosting/agentic-token-cache.test.ts index bacdcb6e..4c02cc7a 100644 --- a/tests/observability/extension/hosting/agentic-token-cache.test.ts +++ b/tests/observability/extension/hosting/agentic-token-cache.test.ts @@ -1,265 +1,202 @@ -// ------------------------------------------------------------------------------ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// ------------------------------------------------------------------------------ -import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-hosting'; +import { AgenticTokenCache, ObservabilityTokenResolver } from '@microsoft/agents-a365-observability-hosting'; +import { ObservabilityConfiguration } from '@microsoft/agents-a365-observability'; +import type { Authorization, TurnContext } from '@microsoft/agents-hosting'; -interface TurnContextStub { activity: { id: string } } -interface AuthorizationStub { - exchangeToken: (...args: any[]) => Promise<{ token: string | undefined }> - getToken: (...args: any[]) => Promise<{ token: string }> - signOut: () => Promise | void - onSignInSuccess: () => void - onSignInFailure: () => void -} -interface SequenceStep { token?: string; error?: unknown } - -const makeTurnContext = (): TurnContextStub => ({ activity: { id: 'a1' } }); - -// Helper to cast our minimal stub to the SDK TurnContext type expected by the cache -const asTurnContext = (stub: TurnContextStub): import('@microsoft/agents-hosting').TurnContext => { - return stub as unknown as import('@microsoft/agents-hosting').TurnContext; -}; +const obsScopes = ['api://9b975845-388f-4429-889e-eab1ef63949c/.default']; function makeJwtWithExp(expSecondsFromNow: number): string { const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); - const exp = Math.floor(Date.now() / 1000) + expSecondsFromNow; - const payload = Buffer.from(JSON.stringify({ exp })).toString('base64url'); - return `${header}.${payload}.sig`; + const payload = Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + roles: ['Agent365.Observability.OtelWrite'], + })).toString('base64url'); + return `${header}.${payload}.test-signature`; } -function makeAuthorizationMock(sequence: SequenceStep[]): AuthorizationStub { - let call = 0; - const authLike: AuthorizationStub = { - exchangeToken: async () => { - const current = sequence[Math.min(call, sequence.length - 1)]; - call++; - if (current.error) throw current.error; - return { token: current.token || '' }; - }, - getToken: async () => ({ token: 'unused' }), - signOut: async () => {}, - onSignInSuccess: () => {}, - onSignInFailure: () => {} - }; - return authLike; -} +describe('AgenticTokenCache app-only OBS authentication', () => { + let cache: AgenticTokenCache; -describe('AgenticTokenCacheInstance', () => { beforeEach(() => { - AgenticTokenCacheInstance.invalidateAll(); + cache = new AgenticTokenCache(); jest.useFakeTimers(); }); + afterEach(() => { jest.useRealTimers(); }); it('returns null when no entry exists', () => { - const token = AgenticTokenCacheInstance.getObservabilityToken('agentX', 'tenantY'); - expect(token).toBeNull(); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); }); - it('exchanges and caches token on first call', async () => { + it('passes exporting identity and configured OBS scopes to an app-only resolver', async () => { const token = makeJwtWithExp(300); - const auth = makeAuthorizationMock([{ token }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentA', - 'tenantA', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - const tokenReturned = AgenticTokenCacheInstance.getObservabilityToken('agentA', 'tenantA'); - expect(tokenReturned).not.toBeNull(); - expect(tokenReturned).toBe(token); + const resolver = jest.fn, Parameters>(() => token); + + await cache.RefreshObservabilityToken('agent', 'tenant', resolver); + + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith('agent', 'tenant', obsScopes); + expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); }); - it('retries on retriable error then succeeds', async () => { - const token = makeJwtWithExp(300); - const retriableErr = { status: 500, message: 'server error' }; - const sequence: SequenceStep[] = [ - { error: retriableErr }, - { token } - ]; - let call = 0; - const exchangeFn = jest.fn(async () => { - const current = sequence[Math.min(call, sequence.length - 1)]; - call++; - if (current.error) throw current.error; - return { token: current.token }; + it('uses a configuration provider for app-only scopes', async () => { + const scopes = ['api://custom-obs/.default']; + cache = new AgenticTokenCache({ + getConfiguration: () => new ObservabilityConfiguration({ + observabilityAuthenticationScopes: () => scopes, + }), + }); + const resolver = jest.fn(() => makeJwtWithExp(300)); + + await cache.RefreshObservabilityToken('agent', 'tenant', resolver); + + expect(resolver).toHaveBeenCalledWith('agent', 'tenant', scopes); + }); + + it('fails for an empty scope configuration without requesting a token', async () => { + cache = new AgenticTokenCache({ + getConfiguration: () => new ObservabilityConfiguration({ + observabilityAuthenticationScopes: () => [], + }), }); - const auth: AuthorizationStub = { - exchangeToken: exchangeFn, - getToken: async () => ({ token: 'unused' }), - signOut: async () => {}, - onSignInSuccess: () => {}, - onSignInFailure: () => {} + const resolver = jest.fn(() => makeJwtWithExp(300)); + await expect(cache.RefreshObservabilityToken('agent', 'tenant', resolver)).rejects.toThrow('No valid scopes'); + expect(resolver).not.toHaveBeenCalled(); + }); + + it.each(['agentic', 'obo'])('rejects legacy %s user authorization without exchanging a token', async (handler) => { + const exchangeToken = jest.fn(); + const authorization: Authorization = { + exchangeToken, + getToken: jest.fn(), + signOut: jest.fn(), + onSignInSuccess: jest.fn(), + onSignInFailure: jest.fn(), }; - const p = AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentB', - 'tenantB', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - await (jest as any).advanceTimersByTimeAsync?.(1000) || jest.advanceTimersByTime(1000); - await p; - const tokenReturned = AgenticTokenCacheInstance.getObservabilityToken('agentB', 'tenantB'); - expect(tokenReturned).not.toBeNull(); - expect(tokenReturned).toBe(token); - expect(exchangeFn).toHaveBeenCalledTimes(2); + const context = {} as TurnContext; + + await expect(cache.RefreshObservabilityToken( + 'agent', 'tenant', context, authorization, obsScopes, handler, + )).rejects.toThrow('S2S OBS requires an app-only token resolver'); + + expect(exchangeToken).not.toHaveBeenCalled(); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); }); - it('stops on non-retriable error and leaves token null', async () => { - const nonRetriableErr = { status: 400, message: 'bad request' }; - const auth = makeAuthorizationMock([ - { error: nonRetriableErr }, - { token: makeJwtWithExp(300) } // should not be used - ]); - await AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentC', - 'tenantC', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - const token = AgenticTokenCacheInstance.getObservabilityToken('agentC', 'tenantC'); - expect(token).toBeNull(); + it.each([['', 'tenant'], ['agent', ' ']])('rejects empty identity (%s, %s)', async (agent, tenant) => { + const resolver = jest.fn(() => makeJwtWithExp(300)); + await expect(cache.RefreshObservabilityToken(agent, tenant, resolver)).rejects.toThrow('Agent and tenant IDs'); + expect(resolver).not.toHaveBeenCalled(); }); - it('treats near-expiry token as expired (skew refresh)', async () => { - const auth = makeAuthorizationMock([{ token: makeJwtWithExp(30) }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentD', - 'tenantD', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - const token = AgenticTokenCacheInstance.getObservabilityToken('agentD', 'tenantD'); - expect(token).toBeNull(); + it('retries a transient acquisition failure then caches the app-only token', async () => { + const token = makeJwtWithExp(300); + const resolver = jest.fn() + .mockRejectedValueOnce({ status: 500, message: 'service unavailable' }) + .mockResolvedValueOnce(token); + + const pending = cache.RefreshObservabilityToken('agent', 'tenant', resolver); + await jest.advanceTimersByTimeAsync(1000); + await pending; + + expect(resolver).toHaveBeenCalledTimes(2); + expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); }); - it('returns cached token before expiry then invalid after advancing time', async () => { - const auth = makeAuthorizationMock([{ token: makeJwtWithExp(120) }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentE', - 'tenantE', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - const tokenBefore = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE'); - expect(tokenBefore).not.toBeNull(); + it('surfaces a permanent acquisition failure and clears stale tokens', async () => { + await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(120)); jest.advanceTimersByTime(61_000); - const tokenAfter = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE'); - expect(tokenAfter).toBeNull(); + const error = new Error('permission denied'); + const resolver = jest.fn().mockRejectedValue(error); + + await expect(cache.RefreshObservabilityToken('agent', 'tenant', resolver)).rejects.toBe(error); + + expect(resolver).toHaveBeenCalledTimes(1); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); }); - it('evicts oldest entry when cache exceeds max size', async () => { - const { AgenticTokenCache } = require('@microsoft/agents-a365-observability-hosting'); - const cache = new AgenticTokenCache(); - const map = (cache as any)._map as Map; + it('surfaces exhausted transient failures without caching a token', async () => { + const error = { status: 503, message: 'service unavailable' }; + const resolver = jest.fn().mockRejectedValue(error); + const pending = expect(cache.RefreshObservabilityToken('agent', 'tenant', resolver)).rejects.toBe(error); + await jest.advanceTimersByTimeAsync(1000); + await pending; + expect(resolver).toHaveBeenCalledTimes(3); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); + }); - // Pre-fill the map to capacity - const MAX = (cache as any)._maxCacheSize as number; - for (let i = 0; i < MAX; i++) { - map.set(`agent-${i}:tenant-${i}`, { scopes: ['s'], token: `t-${i}`, acquiredOn: Date.now() }); - } - expect(map.size).toBe(MAX); + it.each([null, '', ' '])('surfaces an empty resolver result (%s)', async (token) => { + await expect(cache.RefreshObservabilityToken('agent', 'tenant', () => token)).rejects.toThrow('returned no token'); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); + }); - // Insert one more via RefreshObservabilityToken - const token = makeJwtWithExp(300); - const auth = makeAuthorizationMock([{ token }]); - await cache.RefreshObservabilityToken( - 'agent-new', - 'tenant-new', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - - // Size should still be at MAX (oldest evicted, new one added) - expect(map.size).toBe(MAX); - // First entry should have been evicted - expect(map.has('agent-0:tenant-0')).toBe(false); - // New entry should exist - expect(map.has('agent-new:tenant-new')).toBe(true); + it('treats a near-expiry token as expired', async () => { + await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(30)); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); }); - it('passes authHandlerName to exchangeToken when provided', async () => { - const token = makeJwtWithExp(300); - const exchangeFn = jest.fn(async (..._args: any[]) => ({ token })); - const auth: AuthorizationStub = { - exchangeToken: exchangeFn, - getToken: async () => ({ token: 'unused' }), - signOut: async () => {}, - onSignInSuccess: () => {}, - onSignInFailure: () => {} - }; - await AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentHandler', - 'tenantHandler', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'], - 'custom-handler' - ); - expect(exchangeFn).toHaveBeenCalledTimes(1); - expect(exchangeFn.mock.calls[0][1]).toBe('custom-handler'); + it('reuses a cached token and refreshes after expiry skew', async () => { + const token = makeJwtWithExp(120); + const resolver = jest.fn(() => token); + await cache.RefreshObservabilityToken('agent', 'tenant', resolver); + await cache.RefreshObservabilityToken('agent', 'tenant', resolver); + expect(resolver).toHaveBeenCalledTimes(1); + expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); + jest.advanceTimersByTime(61_000); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); + resolver.mockReturnValue(makeJwtWithExp(300)); + await cache.RefreshObservabilityToken('agent', 'tenant', resolver); + expect(resolver).toHaveBeenCalledTimes(2); }); - it('defaults authHandlerName to "agentic" when not provided', async () => { + it('isolates tokens by agent and tenant', async () => { + const resolver = (agent: string, tenant: string) => `${agent}-${tenant}`; + await cache.RefreshObservabilityToken('one', 'tenant-a', resolver); + await cache.RefreshObservabilityToken('two', 'tenant-a', resolver); + await cache.RefreshObservabilityToken('one', 'tenant-b', resolver); + expect(cache.getObservabilityToken('one', 'tenant-a')).toBe('one-tenant-a'); + expect(cache.getObservabilityToken('two', 'tenant-a')).toBe('two-tenant-a'); + expect(cache.getObservabilityToken('one', 'tenant-b')).toBe('one-tenant-b'); + }); + + it('uses a fresh fallback TTL when an opaque token replaces an expired JWT', async () => { + await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(120)); + jest.advanceTimersByTime(61_000); + await cache.RefreshObservabilityToken('agent', 'tenant', () => 'opaque-app-only-token'); + expect(cache.getObservabilityToken('agent', 'tenant')).toBe('opaque-app-only-token'); + jest.advanceTimersByTime(3_600_000); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); + }); + + it('evicts the oldest token when the cache reaches capacity', async () => { const token = makeJwtWithExp(300); - const exchangeFn = jest.fn(async (..._args: any[]) => ({ token })); - const auth: AuthorizationStub = { - exchangeToken: exchangeFn, - getToken: async () => ({ token: 'unused' }), - signOut: async () => {}, - onSignInSuccess: () => {}, - onSignInFailure: () => {} - }; - await AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentDefault', - 'tenantDefault', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - expect(exchangeFn).toHaveBeenCalledTimes(1); - expect(exchangeFn.mock.calls[0][1]).toBe('agentic'); + for (let i = 0; i <= 10_000; i++) { + await cache.RefreshObservabilityToken(`agent-${i}`, 'tenant', () => token); + } + expect(cache.getObservabilityToken('agent-0', 'tenant')).toBeNull(); + expect(cache.getObservabilityToken('agent-1', 'tenant')).toBe(token); + expect(cache.getObservabilityToken('agent-10000', 'tenant')).toBe(token); + }); + + it('caps token lifetime to 24 hours', async () => { + const token = makeJwtWithExp(48 * 60 * 60); + await cache.RefreshObservabilityToken('agent', 'tenant', () => token); + jest.advanceTimersByTime(24 * 60 * 60 * 1000); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); }); - it('caps JWT exp claim to 24 hours', async () => { - const { AgenticTokenCache } = require('@microsoft/agents-a365-observability-hosting'); - const cache = new AgenticTokenCache(); - - // Create JWT with exp 48 hours from now - const farFutureExp = Math.floor(Date.now() / 1000) + (48 * 60 * 60); - const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); - const payload = Buffer.from(JSON.stringify({ exp: farFutureExp })).toString('base64url'); - const farFutureToken = `${header}.${payload}.sig`; - - const auth = makeAuthorizationMock([{ token: farFutureToken }]); - await cache.RefreshObservabilityToken( - 'agent-exp', - 'tenant-exp', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - - const map = (cache as any)._map as Map; - const entry = map.get('agent-exp:tenant-exp'); - expect(entry).toBeDefined(); - expect(entry.expiresOn).toBeDefined(); - - // The expiresOn should be capped to ~24 hours from now (not 48 hours) - const maxAllowed = Date.now() + (24 * 60 * 60 * 1000) + 5000; // 24h + small tolerance - expect(entry.expiresOn).toBeLessThanOrEqual(maxAllowed); - // And should be well below the 48-hour uncapped value - const uncapped = farFutureExp * 1000; - expect(entry.expiresOn).toBeLessThan(uncapped); + it('invalidates one token independently, then all tokens', async () => { + const token = makeJwtWithExp(300); + await cache.RefreshObservabilityToken('one', 'tenant', () => token); + await cache.RefreshObservabilityToken('two', 'tenant', () => token); + cache.invalidateToken('one', 'tenant'); + expect(cache.getObservabilityToken('one', 'tenant')).toBeNull(); + expect(cache.getObservabilityToken('two', 'tenant')).toBe(token); + cache.invalidateAll(); + expect(cache.getObservabilityToken('two', 'tenant')).toBeNull(); }); }); From 78deb5d21c1a2cc2df2e34b10175ba40066c4025 Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:04:49 +0100 Subject: [PATCH 2/4] fix(observability): enforce app-only resolvers in every export mode Use the configured OBS resolver for batch and per-request export without ambient-token or OBO-route fallback. Fail missing-token exports explicitly, add delegated-context counterfactuals and builder integration coverage, declare the tooling axios dependency, and update migration guidance and cache tests. Review response amendments: - Startup error now names the fix ("Per-request export now requires withTokenResolver(...)") and points at AgenticTokenCache for caching. - README and CHANGELOG document that resolvers must cache; the exporter invokes the resolver on every batch and per identity group. - Defensive resolver guard in exportGroup now comments that it only catches post-construction mutation; the constructor is the primary check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cbf5f6b-cc40-4b7e-a591-65848db73a12 --- CHANGELOG.md | 8 + .../docs/design.md | 35 +++- .../src/caching/AgenticTokenCache.ts | 9 +- packages/agents-a365-observability/README.md | 45 ++++- .../agents-a365-observability/docs/design.md | 8 +- .../src/ObservabilityBuilder.ts | 26 ++- .../src/tracing/PerRequestSpanProcessor.ts | 12 +- .../src/tracing/context/token-context.ts | 2 + .../src/tracing/exporter/Agent365Exporter.ts | 44 ++--- .../exporter/Agent365ExporterOptions.ts | 11 +- .../src/tracing/exporter/utils.ts | 2 +- packages/agents-a365-tooling/package.json | 1 + pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 3 + tests/jest.config.cjs | 1 + .../core/agent365-exporter.test.ts | 178 +++++++++++++++++- ...ervabilityBuilder-per-request-auth.test.ts | 138 ++++++++++++++ .../hosting/agentic-token-cache.test.ts | 35 +++- tests/tooling/package-dependencies.test.ts | 15 ++ 19 files changed, 502 insertions(+), 77 deletions(-) create mode 100644 tests/observability/core/observabilityBuilder-per-request-auth.test.ts create mode 100644 tests/tooling/package-dependencies.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1726d175..dcd18204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,14 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. deprecated and ignored, even when `false`. Batch and per-request exports no longer select or fall back to `/observability`. Provide an app-only OBS token independently of your agent's workload auth; the S2S service rejects delegated `scp` tokens. +- **Per-request OBS requires the configured app-only resolver** - Both export modes + use `withTokenResolver(...)` or `exporterOptions.tokenResolver`, with the builder + method taking precedence. `Agent365Exporter` no longer reads tokens from + `runWithExportToken`/`updateExportToken`. Missing resolvers fail configuration; + empty tokens or acquisition failures fail export without delegated fallback. + The exporter invokes the resolver on every export batch, so resolvers must + cache the acquired token and refresh only near expiry. + Workload OBO and custom-exporter context helpers are otherwise unchanged. - **Hosting OBS token cache requires an app-only resolver** - `RefreshObservabilityToken(agentId, tenantId, tokenResolver)` replaces the `TurnContext`/`Authorization` overload, which now throws without exchanging a diff --git a/packages/agents-a365-observability-hosting/docs/design.md b/packages/agents-a365-observability-hosting/docs/design.md index 2d4fa308..b501d8f2 100644 --- a/packages/agents-a365-observability-hosting/docs/design.md +++ b/packages/agents-a365-observability-hosting/docs/design.md @@ -127,9 +127,38 @@ For a blueprint-backed agent, acquire a blueprint exchange assertion with `fmi_path=agentId`, then use it as `client_assertion` in an instance `client_credentials` request for the OBS `/.default` scope. Do not send the intermediate assertion, a blueprint token, or a `user_fic`/OBO token to OBS. -The final token's application identity must match `agentId`; it needs the -`Agent365.Observability.OtelWrite` application permission. Acquisition failures -propagate to the caller and never trigger delegated authentication. +The final token's application identity must match `agentId`, its tenant must +match `tenantId`, and its audience must be OBS. An eligible Agent 365-registered +instance can use a roleless app token when service policy permits; an +`Agent365.Observability.OtelWrite` grant is not a universal prerequisite. Entra +identity creation alone does not establish instance registration or service access. +The resolver must validate app-only identity (explicit `idtyp=app` for a roleless +token), reject delegated `scp` tokens, and check audience and lifetime before +returning a token. The cache does not perform token authentication or authorization. +Acquisition failures propagate to the caller and never trigger delegated authentication. + +When migrating, replace only the OBS refresh call, not workload MCP/Graph/OBO +authorization. Configure an OBS resolver in both batch and per-request modes. +It should refresh the app-only cache at export time before returning its token, +so long-running requests do not depend on a token acquired at turn start: + +```typescript +builder.withTokenResolver(async (agentId, tenantId) => { + await AgenticTokenCacheInstance.RefreshObservabilityToken( + agentId, tenantId, acquireAppOnlyObsToken + ); + return AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId); +}); +``` + +`Agent365Exporter` ignores tokens in `runWithExportToken`; those context helpers +remain available for custom exporters, not as an OBS authentication fallback. +An enabled exporter without an explicit resolver fails configuration. +Both modes use the S2S OTLP +route even if the deprecated `useS2SEndpoint` option is false. Missing tokens and +failed acquisition report export failure without sending a request; HTTP +401/403/404 never select an OBO fallback. Check instance registration and service +policy rather than adding OBS permissions automatically. ## Tenant ID Resolution diff --git a/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts b/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts index 7626b49a..0d518b7d 100644 --- a/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts +++ b/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts @@ -3,9 +3,12 @@ // Licensed under the MIT License. // ------------------------------------------------------------------------------ -import { TurnContext, Authorization } from '@microsoft/agents-hosting'; -import { logger, formatError, ObservabilityConfiguration, defaultObservabilityConfigurationProvider, TokenResolver } from '@microsoft/agents-a365-observability'; -import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import type { TurnContext, Authorization } from '@microsoft/agents-hosting'; +import { + logger, formatError, defaultObservabilityConfigurationProvider, + type ObservabilityConfiguration, type TokenResolver, +} from '@microsoft/agents-a365-observability'; +import type { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; /** Acquires an app-only OBS token; must not perform user_fic or OBO authentication. */ export type ObservabilityTokenResolver = ( diff --git a/packages/agents-a365-observability/README.md b/packages/agents-a365-observability/README.md index 1dc6a065..869ca0ca 100644 --- a/packages/agents-a365-observability/README.md +++ b/packages/agents-a365-observability/README.md @@ -22,9 +22,11 @@ including batch and per-request exports from AI Teammate and OBO workloads. The never falls back to `/observability`. The `useS2SEndpoint` option is deprecated and ignored, including when set to `false`; domain overrides change the host, not this route. -Endpoint selection does not acquire or convert tokens. Supply an **app-only** OBS token -for the exporting tenant and agent identity with `Agent365.Observability.OtelWrite` -application permission. The S2S service rejects delegated (`scp`) tokens, including +Endpoint selection does not acquire or convert tokens. Supply an **app-only** OBS +resolver for the exporting tenant and agent identity. Eligible Agent 365-registered +instances can use roleless app tokens when service policy permits; an +`Agent365.Observability.OtelWrite` grant is not a universal prerequisite. +The S2S service rejects delegated (`scp`) tokens, including AI Teammate user tokens. Keep workload authentication (such as OBO for MCP or Microsoft Graph) separate from OBS authentication. An authorization failure is not a reason to retry telemetry on the OBO route. @@ -35,6 +37,43 @@ When using the hosting token cache, call S2S ingestion may remove unverified user attribution; routing a workload through S2S does not establish that its caller identity is trusted. +### Migrating per-request authentication + +Batch and per-request exports both call the configured `tokenResolver` with the +exporting agent and tenant IDs. Configure it with `withTokenResolver(...)` or +`exporterOptions.tokenResolver`; the explicit builder method takes precedence. +The callback must acquire or refresh an app-only OBS token independently of +workload authentication. + +```typescript +import { ObservabilityManager, type TokenResolver } from '@microsoft/agents-a365-observability'; + +function startObservability(resolveAppOnlyObsToken: TokenResolver): void { + ObservabilityManager.configure(builder => { + builder.withService('my-agent').withTokenResolver(resolveAppOnlyObsToken); + }).start(); +} +``` + +Enabling `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` changes span buffering, +not credential selection. `runWithExportToken`, `updateExportToken`, and +`getExportToken` remain available for custom export integrations, but +`Agent365Exporter` never uses their context token, even when it looks app-only. +Existing callers must provide the OBS resolver instead of relying on a workload +token in context. + +**Resolvers must cache.** The exporter invokes the resolver on every export +batch, and once per identity group when spans partition across tenants or +agents. In per-request mode that is roughly one call per request. Resolvers +should cache the acquired app-only token and refresh only as it approaches +expiry. `AgenticTokenCache` in `@microsoft/agents-a365-observability-hosting` +implements this pattern; the sample `observability-token-service.ts` files show +a minimal single-identity variant. + +An enabled Agent 365 exporter without a resolver fails configuration. A resolver +failure or empty token fails export without an HTTP request or delegated fallback. +Console-only configuration does not require an OBS resolver. + ## Support For issues, questions, or feedback: diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index 7d947eb3..53d30bf6 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -47,7 +47,7 @@ import { ObservabilityManager } from '@microsoft/agents-a365-observability'; ObservabilityManager.start({ serviceName: 'my-agent', serviceVersion: '1.0.0', - tokenResolver: async (agentId, tenantId) => getAuthToken(), + tokenResolver: async (agentId, tenantId) => getAppOnlyObsToken(agentId, tenantId), clusterCategory: 'prod' }); @@ -66,6 +66,12 @@ const instance = ObservabilityManager.getInstance(); await ObservabilityManager.shutdown(); ``` +The configured app-only OBS resolver is required in both batch and per-request +modes. The builder merges resolver options consistently, with `withTokenResolver` +taking precedence over `exporterOptions.tokenResolver`. Request context is retained +for tracing, but its token is not consumed by `Agent365Exporter`. See the +[per-request migration guide](../README.md#migrating-per-request-authentication). + ### ObservabilityBuilder ([ObservabilityBuilder.ts](../src/ObservabilityBuilder.ts)) Fluent API for configuring telemetry: diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index d09484d2..9150b338 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -150,12 +150,7 @@ export class ObservabilityBuilder { return this; } - private createBatchProcessor(): BatchSpanProcessor { - if (!isAgent365ExporterEnabled(this.options.configProvider)) { - logger.info('[ObservabilityBuilder] Agent 365 exporter not enabled. Using ConsoleSpanExporter for BatchSpanProcessor.'); - return new BatchSpanProcessor(new ConsoleSpanExporter()); - } - + private createExporterOptions(): Agent365ExporterOptions { const opts = new Agent365ExporterOptions(); if (this.options.exporterOptions) { Object.assign(opts, this.options.exporterOptions); @@ -164,6 +159,16 @@ export class ObservabilityBuilder { if (this.options.tokenResolver) { opts.tokenResolver = this.options.tokenResolver; } + return opts; + } + + private createBatchProcessor(): BatchSpanProcessor { + if (!isAgent365ExporterEnabled(this.options.configProvider)) { + logger.info('[ObservabilityBuilder] Agent 365 exporter not enabled. Using ConsoleSpanExporter for BatchSpanProcessor.'); + return new BatchSpanProcessor(new ConsoleSpanExporter()); + } + + const opts = this.createExporterOptions(); return new BatchSpanProcessor(new Agent365Exporter(opts, this.options.configProvider), { maxQueueSize: opts.maxQueueSize, scheduledDelayMillis: opts.scheduledDelayMilliseconds, @@ -178,14 +183,7 @@ export class ObservabilityBuilder { return new PerRequestSpanProcessor(new ConsoleSpanExporter()); } - const opts = new Agent365ExporterOptions(); - if (this.options.exporterOptions) { - Object.assign(opts, this.options.exporterOptions); - } - opts.clusterCategory = this.options.clusterCategory || opts.clusterCategory || ClusterCategory.prod; - - // For per-request export, token is retrieved from OTel Context by Agent365Exporter - // using getExportToken(), so no tokenResolver is needed here + const opts = this.createExporterOptions(); return new PerRequestSpanProcessor(new Agent365Exporter(opts, this.options.configProvider)); } diff --git a/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts b/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts index 6e5d005f..2ef12777 100644 --- a/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts +++ b/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts @@ -19,7 +19,7 @@ type TraceBuffer = { spans: ReadableSpan[]; openCount: number; rootEnded: boolean; - rootCtx?: Context; // holds the request Context (with token in ALS) + rootCtx?: Context; // preserves request-local baggage and custom exporter state startedAtMs: number; rootEndedAtMs?: number; droppedSpans: number; @@ -29,8 +29,8 @@ type FlushReason = 'trace_completed' | 'root_ended_grace' | 'max_trace_age' | 'f /** * Buffers spans per trace and exports once the request completes. - * Token is not stored; we export under the saved request Context so that getExportToken() - * can read the token from the active OpenTelemetry Context at export time. + * Exports under the saved request Context to preserve request-local state. + * Agent365Exporter acquires credentials through its own app-only resolver, not this Context. */ export class PerRequestSpanProcessor implements SpanProcessor { private traces = new Map(); @@ -101,7 +101,7 @@ export class PerRequestSpanProcessor implements SpanProcessor { // Capture a context to export under. // - Use the first seen context as a fallback. - // - If/when the root span starts, prefer its context (contains token via ALS). + // - If/when the root span starts, prefer its request-local context. if (isRootSpan(span)) { buf.rootCtx = ctx; } else { @@ -231,7 +231,7 @@ export class PerRequestSpanProcessor implements SpanProcessor { `[PerRequestSpanProcessor] Flushing trace traceId=${traceId} reason=${reason} spans=${spans.length} rootEnded=${trace.rootEnded}` ); - // Must have captured the root context to access the token + // Restore the original request context for baggage and custom exporters. if (!trace.rootCtx) { logger.error(`[PerRequestSpanProcessor] Missing rootCtx for trace ${traceId}, cannot export spans`); return; @@ -240,7 +240,7 @@ export class PerRequestSpanProcessor implements SpanProcessor { await this.acquireExportSlot(); try { - // Export under the original request Context so exporter can read the token from context.active() + // Credentials are selected by the exporter, independently of this request context. await new Promise((resolve) => { try { context.with(trace.rootCtx as Context, () => { diff --git a/packages/agents-a365-observability/src/tracing/context/token-context.ts b/packages/agents-a365-observability/src/tracing/context/token-context.ts index f8521f6e..cdcf7e7b 100644 --- a/packages/agents-a365-observability/src/tracing/context/token-context.ts +++ b/packages/agents-a365-observability/src/tracing/context/token-context.ts @@ -19,6 +19,8 @@ interface TokenHolder { /** * Run a function within a Context that carries the per-request export token. * This keeps the token only in OTel Context (ALS), never in any registry. + * These helpers remain available for custom export integrations. Agent365Exporter + * does not consume this token; configure its app-only tokenResolver in every mode. * * The token can be updated later via `updateExportToken()` before the trace * is flushed — useful when the callback is long-running and the original diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index acd78f01..7b58c557 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -17,12 +17,10 @@ import { statusName, resolveAgent365Endpoint, getAgent365ObservabilityDomainOverride, - isPerRequestExportEnabled, truncateSpan, estimateSpanBytes, chunkBySize, } from './utils'; -import { getExportToken } from '../context/token-context'; import logger, { formatError } from '../../utils/logging'; import { Agent365ExporterOptions } from './Agent365ExporterOptions'; import { ExporterEventNames } from './ExporterEventNames'; @@ -109,8 +107,14 @@ export class Agent365Exporter implements SpanExporter { throw new Error('Agent365ExporterOptions must be provided (was null/undefined)'); } - if (!isPerRequestExportEnabled() && !options.tokenResolver) { - throw new Error('Agent365Exporter tokenResolver must be provided for batch export'); + if (typeof options.tokenResolver !== 'function') { + throw new Error( + 'Agent365Exporter requires an app-only OBS tokenResolver. ' + + 'Per-request export now requires withTokenResolver(...) or ' + + 'Agent365ExporterOptions.tokenResolver; it no longer reads tokens from ' + + 'runWithExportToken. Resolvers should cache the acquired token; see ' + + 'AgenticTokenCache in @microsoft/agents-a365-observability-hosting.', + ); } this.options = options; this.configProvider = configProvider; @@ -205,36 +209,20 @@ export class Agent365Exporter implements SpanExporter { 'content-type': 'application/json' }; - let token: string | null = null; - let tokenNotResolvedReason: string | null = null; - if (isPerRequestExportEnabled()) { - // For per-request export, get token from OTel Context - token = getExportToken() ?? null; - if (!token) { - tokenNotResolvedReason = 'No token available in OTel Context for per-request export'; - } - } else { - // For batch export, use tokenResolver - if (!this.options.tokenResolver) { - tokenNotResolvedReason = 'tokenResolver is undefined'; - } else { - const tokenResult = this.options.tokenResolver(agentId, tenantId); - token = tokenResult instanceof Promise ? await tokenResult : tokenResult; - if (token) { - logger.info('[Agent365Exporter] Token resolved successfully via tokenResolver'); - } else { - tokenNotResolvedReason = 'No token resolved via tokenResolver'; - } - } + if (typeof this.options.tokenResolver !== 'function') { + // Defensive: constructor already rejects a missing resolver; this catches mutation of options after construction. + throw new Error('Agent365Exporter tokenResolver was cleared after construction'); } + const token = await this.options.tokenResolver(agentId, tenantId); - if (token) { + if (token?.trim()) { + logger.info('[Agent365Exporter] Token resolved successfully via app-only tokenResolver'); headers['authorization'] = `Bearer ${token}`; } else { - const skipReason = tokenNotResolvedReason || 'Token not resolved for export request'; + const skipReason = 'No token resolved via app-only tokenResolver'; logger.event(ExporterEventNames.EXPORT_GROUP, false, 0, `skip exporting: ${skipReason}`, { tenantId, agentId }); - return; + throw new Error(skipReason); } // Always include tenant id header diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index 71b20e60..9b1e7c97 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -9,7 +9,8 @@ import { ClusterCategory } from '@microsoft/agents-a365-runtime'; * A function that resolves an app-only OBS token for the given agent and tenant. * Delegated (scp) tokens are not accepted by the S2S service. * Implementations may perform synchronous lookup (e.g., in-memory cache) or asynchronous network calls. - * Return null if a token cannot be provided; exporter will log and proceed without an authorization header. + * Used in both batch and per-request modes, independently of workload token context. + * Return null if a token cannot be provided; export fails without sending an HTTP request. */ export type TokenResolver = (agentId: string, tenantId: string) => string | null | Promise; @@ -20,8 +21,8 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null * defaults so callers can usually construct without arguments and override selectively. * * @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. ClusterCategory.preprod, ClusterCategory.prod, default to ClusterCategory.prod). - * @property {TokenResolver} [tokenResolver] Optional delegate to obtain an auth token. If omitted the exporter will - * fall back to reading the cached token (AgenticTokenCacheInstance.getObservabilityToken). + * @property {TokenResolver} [tokenResolver] App-only OBS token resolver required when constructing Agent365Exporter. + * There is no implicit cache lookup or request-context token fallback. * @property {boolean} [useS2SEndpoint] Deprecated compatibility option. Export always uses the S2S path, even when false. * @property {number} maxQueueSize Maximum span queue size before drops occur (passed to BatchSpanProcessor). * @property {number} scheduledDelayMilliseconds Delay between automatic batch flush attempts. @@ -33,8 +34,8 @@ export class Agent365ExporterOptions { /** Environment / cluster category (e.g. ClusterCategory.preprod, ClusterCategory.prod). */ public clusterCategory: ClusterCategory | string = ClusterCategory.prod; - /** Optional delegate to resolve auth token used by exporter */ - public tokenResolver?: TokenResolver; // Optional if ENABLE_A365_OBSERVABILITY_EXPORTER is false + /** Required by Agent365Exporter in every mode; a console-only builder may omit it. */ + public tokenResolver?: TokenResolver; /** @deprecated Export always uses /observabilityService. This option is ignored. */ public useS2SEndpoint: boolean = true; diff --git a/packages/agents-a365-observability/src/tracing/exporter/utils.ts b/packages/agents-a365-observability/src/tracing/exporter/utils.ts index 857a8ab8..e13eaca5 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/utils.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/utils.ts @@ -166,7 +166,7 @@ export function isAgent365ExporterEnabled( * Check if per-request export is enabled. * Precedence: internal overrides > configuration provider > environment variable. * When enabled, the PerRequestSpanProcessor is used instead of BatchSpanProcessor. - * The token is passed via OTel Context (async local storage) at export time. + * Credential selection is unchanged: Agent365Exporter uses its app-only token resolver. * @param configProvider Optional configuration provider. Defaults to defaultPerRequestSpanProcessorConfigurationProvider if not specified. */ export function isPerRequestExportEnabled( diff --git a/packages/agents-a365-tooling/package.json b/packages/agents-a365-tooling/package.json index 092d4bc3..7d4364c8 100644 --- a/packages/agents-a365-tooling/package.json +++ b/packages/agents-a365-tooling/package.json @@ -36,6 +36,7 @@ "@microsoft/agents-a365-runtime": "workspace:*", "@microsoft/agents-hosting": "catalog:", "@modelcontextprotocol/sdk": "catalog:", + "axios": "catalog:", "express": "catalog:", "hono": "catalog:" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5f69c3a..f82401ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ catalogs: '@typescript-eslint/parser': specifier: ^8.47.0 version: 8.59.1 + axios: + specifier: ^1.15.2 + version: 1.15.2 cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -490,6 +493,9 @@ importers: '@modelcontextprotocol/sdk': specifier: 'catalog:' version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + axios: + specifier: 'catalog:' + version: 1.15.2 express: specifier: 'catalog:' version: 5.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 96179d0f..c1602bfb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,9 @@ catalog: # Azure packages "@azure/identity": "^4.12.1" + # HTTP client + "axios": "^1.15.2" + # Express framework "express": "^5.2.0" diff --git a/tests/jest.config.cjs b/tests/jest.config.cjs index 3627b5ff..8511a6d0 100644 --- a/tests/jest.config.cjs +++ b/tests/jest.config.cjs @@ -69,6 +69,7 @@ module.exports = { moduleNameMapper: { '^@microsoft/agents-a365-runtime$': '/packages/agents-a365-runtime/src', '^@microsoft/agents-a365-observability$': '/packages/agents-a365-observability/src', + '^@microsoft/agents-a365-observability-hosting$': '/packages/agents-a365-observability-hosting/src', '^@microsoft/agents-a365-observability-extensions-langchain$': '/packages/agents-a365-observability-extensions-langchain/src', '^@microsoft/agents-a365-observability-extensions-openai$': '/packages/agents-a365-observability-extensions-openai/src', '^@microsoft/agents-a365-observability-tokencache$': '/packages/agents-a365-observability-tokencache/src', diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index 045dd812..8c3027ee 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -14,6 +14,7 @@ import { truncateSpan } from '@microsoft/agents-a365-observability/src/tracing/e import { runWithExportToken } from '@microsoft/agents-a365-observability/src/tracing/context/token-context'; import { context as otelContext } from '@opentelemetry/api'; import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { AgenticTokenCache } from '@microsoft/agents-a365-observability-hosting'; // Minimal mock span factory function makeSpan(attrs: Record, name = 'test'): ReadableSpan { @@ -42,6 +43,10 @@ function makeSpan(attrs: Record, name = 'test'): ReadableSpan { const tenantId = 'tenant-11111111-1111-1111-1111-111111111111'; const agentId = 'agent-22222222-2222-2222-2222-222222222222'; +function makeToken(claims: Record): string { + return `${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.offline-signature`; +} + // Patch global fetch const originalFetch = global.fetch; @@ -227,8 +232,83 @@ describe('Agent365Exporter', () => { const opts = new Agent365ExporterOptions(); opts.clusterCategory = 'local'; // Intentionally omit tokenResolver - expect(() => new Agent365Exporter(opts)).toThrow(/tokenResolver must be provided/); + expect(() => new Agent365Exporter(opts)) + .toThrow(/requires an app-only OBS tokenResolver[\s\S]*withTokenResolver/); + }); + + it.each([null, '', ' '])('reports failed export without sending an empty token (%j)', async (token) => { + mockFetchSequence([200]); + const opts = new Agent365ExporterOptions(); + opts.tokenResolver = () => token; + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); + expect(getFetchCalls()).toHaveLength(0); + }); + + it('reports resolver acquisition failure without sending a request or falling back', async () => { + mockFetchSequence([200]); + const opts = new Agent365ExporterOptions(); + const resolver = jest.fn(async (_agentId: string, _tenantId: string) => { + throw new Error('app-only token acquisition failed'); + }); + opts.tokenResolver = resolver; + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback); + + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith(agentId, tenantId); + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); + expect(getFetchCalls()).toHaveLength(0); + }); + + it.each([ + { description: 'absent', roles: undefined }, + { description: 'empty', roles: [] }, + ])('exports an app token with $description roles from the hosting resolver', async ({ roles }) => { + mockFetchSequence([200]); + const claims = { + idtyp: 'app', tid: tenantId, azp: agentId, roles, + aud: '9b975845-388f-4429-889e-eab1ef63949c', + exp: Math.floor(Date.now() / 1000) + 300, + }; + const token = makeToken(claims); + const cache = new AgenticTokenCache(); + const resolver = jest.fn(async (_agentId: string, _tenantId: string, _scopes: readonly string[]) => token); + await cache.RefreshObservabilityToken(agentId, tenantId, resolver); + const opts = new Agent365ExporterOptions(); + opts.useS2SEndpoint = false; + opts.tokenResolver = (agent, tenant) => cache.getObservabilityToken(agent, tenant); + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback); + + expect(resolver).toHaveBeenCalledWith(agentId, tenantId, [ + 'api://9b975845-388f-4429-889e-eab1ef63949c/.default', + ]); + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + const calls = getFetchCalls(); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + expect(calls[0][1].headers['authorization']).toBe(`Bearer ${token}`); }); + it('defaults the legacy endpoint option to S2S', () => { expect(new Agent365ExporterOptions().useS2SEndpoint).toBe(true); }); @@ -1037,7 +1117,7 @@ describe('Agent365Exporter', () => { }); }); - describe('per-request export (token from OTel Context)', () => { + describe('per-request app-only export', () => { let contextManager: AsyncLocalStorageContextManager | undefined; beforeEach(() => { @@ -1053,13 +1133,23 @@ describe('Agent365Exporter', () => { contextManager = undefined; }); - it.each([false, true])('uses S2S with a per-request context token and legacy option %s', async (useS2SEndpoint) => { + it.each([ + { useS2SEndpoint: false, roles: undefined }, + { useS2SEndpoint: true, roles: [] }, + ])('uses its app-only resolver with legacy option $useS2SEndpoint and ignores delegated context', async ({ useS2SEndpoint, roles }) => { mockFetchSequence([200]); process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; const opts = new Agent365ExporterOptions(); opts.clusterCategory = 'local'; opts.useS2SEndpoint = useS2SEndpoint; + const exportToken = makeToken({ + idtyp: 'app', tid: tenantId, azp: agentId, roles, + aud: '9b975845-388f-4429-889e-eab1ef63949c', + exp: Math.floor(Date.now() / 1000) + 300, + }); + const resolver = jest.fn(async (_agentId: string, _tenantId: string) => exportToken); + opts.tokenResolver = resolver; const exporter = new Agent365Exporter(opts); const spans = [ @@ -1070,18 +1160,94 @@ describe('Agent365Exporter', () => { ]; const callback = jest.fn(); - const exportToken = 'tok-from-context'; - await runWithExportToken(exportToken, async () => exporter.export(spans, callback)); + const delegatedToken = makeToken({ scp: 'User.Read', idtyp: 'user', tid: tenantId }); + await runWithExportToken(delegatedToken, async () => exporter.export(spans, callback)); expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith(agentId, tenantId); const fetchCalls = getFetchCalls(); expect(fetchCalls).toHaveLength(1); expect(fetchCalls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - // Verify token came from OTel Context (per-request mode) + // The workload's delegated context token must not become an OBS credential. const headersArg = fetchCalls[0][1].headers as Record; expect(headersArg['authorization']).toBe(`Bearer ${exportToken}`); }); + + it.each([401, 403, 404])('does not fall back from per-request S2S after HTTP %s', async (status) => { + mockFetchSequence([status]); + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; + const opts = new Agent365ExporterOptions(); + opts.useS2SEndpoint = false; + const resolver = jest.fn(async () => makeToken({ idtyp: 'app', tid: tenantId, azp: agentId })); + opts.tokenResolver = resolver; + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await runWithExportToken(makeToken({ scp: 'User.Read', idtyp: 'user' }), async () => exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback)); + + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); + expect(resolver).toHaveBeenCalledTimes(1); + const calls = getFetchCalls(); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + }); + + it('exports with the app-only resolver when no context token exists', async () => { + mockFetchSequence([200]); + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; + const opts = new Agent365ExporterOptions(); + const resolver = jest.fn(() => makeToken({ idtyp: 'app', tid: tenantId, azp: agentId })); + opts.tokenResolver = resolver; + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback); + + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + expect(resolver).toHaveBeenCalledTimes(1); + expect(getFetchCalls()).toHaveLength(1); + }); + + it('requires an app-only resolver even when a context token is present', () => { + mockFetchSequence([200]); + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; + + runWithExportToken(makeToken({ scp: 'User.Read' }), () => { + expect(() => new Agent365Exporter(new Agent365ExporterOptions())) + .toThrow(/requires an app-only OBS tokenResolver[\s\S]*withTokenResolver/); + }); + expect(getFetchCalls()).toHaveLength(0); + }); + + it.each(['missing', 'failed'])('does not use a delegated context token after %s app-only acquisition', async (failure) => { + mockFetchSequence([200]); + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; + const opts = new Agent365ExporterOptions(); + const resolver = jest.fn(async () => { + if (failure === 'failed') throw new Error('App-only acquisition failed'); + return null; + }); + opts.tokenResolver = resolver; + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await runWithExportToken(makeToken({ scp: 'User.Read' }), async () => exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback)); + + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); + expect(resolver).toHaveBeenCalledTimes(1); + expect(getFetchCalls()).toHaveLength(0); + }); }); }); diff --git a/tests/observability/core/observabilityBuilder-per-request-auth.test.ts b/tests/observability/core/observabilityBuilder-per-request-auth.test.ts new file mode 100644 index 00000000..04022b8d --- /dev/null +++ b/tests/observability/core/observabilityBuilder-per-request-auth.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { context, trace } from '@opentelemetry/api'; +import { ObservabilityBuilder } from '@microsoft/agents-a365-observability/src/ObservabilityBuilder'; +import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability/src/tracing/constants'; +import { runWithExportToken } from '@microsoft/agents-a365-observability/src/tracing/context/token-context'; +import type { TokenResolver } from '@microsoft/agents-a365-observability'; + +const tenantId = '11111111-1111-1111-1111-111111111111'; +const agentId = '22222222-2222-2222-2222-222222222222'; +const originalFetch = global.fetch; + +function token(claims: Record): string { + return `${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.offline-signature`; +} + +function appToken(agent = agentId, tenant = tenantId): string { + return token({ + idtyp: 'app', azp: agent, tid: tenant, + aud: '9b975845-388f-4429-889e-eab1ef63949c', + exp: Math.floor(Date.now() / 1000) + 300, + }); +} + +describe('ObservabilityBuilder per-request OBS authentication', () => { + let originalEnv: NodeJS.ProcessEnv; + let builder: ObservabilityBuilder; + let requests: Request[]; + + beforeEach(() => { + originalEnv = { ...process.env }; + process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; + delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; + requests = []; + global.fetch = jest.fn(async (input, init) => { + requests.push(new Request(input, init)); + return new Response('{}', { status: 200 }); + }); + builder = new ObservabilityBuilder().withService('per-request-auth-regression'); + }); + + afterEach(async () => { + await builder.shutdown(); + trace.disable(); + context.disable(); + process.env = originalEnv; + global.fetch = originalFetch; + }); + + async function invoke(agent = agentId, tenant = tenantId, contextToken?: string): Promise { + const emit = () => { + trace.getTracer('per-request-auth-regression').startSpan('invoke_agent offline', { + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: 'invoke_agent', + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agent, + [OpenTelemetryConstants.TENANT_ID_KEY]: tenant, + }, + }).end(); + }; + if (contextToken === undefined) emit(); + else runWithExportToken(contextToken, emit); + // Span completion schedules asynchronous token resolution and export. + await new Promise(resolve => setImmediate(resolve)); + } + + it.each(['builder', 'exporterOptions', 'builderOverride'])('uses the %s app resolver through the real processor and exporter', async (source) => { + const expectedToken = appToken(); + const resolver = jest.fn(async () => expectedToken); + const overridden = jest.fn(async () => 'must-not-be-used'); + builder.withExporterOptions({ useS2SEndpoint: false }); + if (source !== 'builder') { + builder.withExporterOptions({ + useS2SEndpoint: false, + tokenResolver: source === 'exporterOptions' ? resolver : overridden, + }); + } + if (source !== 'exporterOptions') builder.withTokenResolver(resolver); + builder.start(); + + await invoke(agentId, tenantId, token({ idtyp: 'user', scp: 'User.Read', tid: tenantId })); + + expect(resolver).toHaveBeenCalledWith(agentId, tenantId); + expect(overridden).not.toHaveBeenCalled(); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + expect(requests[0].headers.get('authorization')).toBe(`Bearer ${expectedToken}`); + }); + + it('does not require a request-context credential', async () => { + const resolver = jest.fn(async () => appToken()); + builder.withTokenResolver(resolver).start(); + + await invoke(); + + expect(resolver).toHaveBeenCalledWith(agentId, tenantId); + expect(requests).toHaveLength(1); + }); + + it('resolves separate app identities for concurrent workload contexts', async () => { + const otherAgent = '33333333-3333-3333-3333-333333333333'; + const otherTenant = '44444444-4444-4444-4444-444444444444'; + const tokens = new Map([ + [`${agentId}:${tenantId}`, appToken()], + [`${otherAgent}:${otherTenant}`, appToken(otherAgent, otherTenant)], + ]); + const resolver = jest.fn(async (agent, tenant) => tokens.get(`${agent}:${tenant}`) ?? null); + builder.withTokenResolver(resolver).start(); + + await Promise.all([ + invoke(agentId, tenantId, token({ scp: 'User.Read', oid: 'first-user' })), + invoke(otherAgent, otherTenant, token({ scp: 'User.Read', oid: 'second-user' })), + ]); + + expect(resolver).toHaveBeenCalledTimes(2); + expect(resolver).toHaveBeenCalledWith(agentId, tenantId); + expect(resolver).toHaveBeenCalledWith(otherAgent, otherTenant); + expect(requests).toHaveLength(2); + for (const [agent, tenant] of [[agentId, tenantId], [otherAgent, otherTenant]]) { + const request = requests.find(candidate => candidate.headers.get('x-ms-tenant-id') === tenant); + expect(request?.headers.get('authorization')).toBe(`Bearer ${tokens.get(`${agent}:${tenant}`)}`); + } + }); + + it('fails configuration without an app resolver when OBS export is enabled', () => { + expect(() => builder.start()) + .toThrow(/requires an app-only OBS tokenResolver[\s\S]*withTokenResolver/); + expect(requests).toHaveLength(0); + }); + + it('keeps console-only configuration usable without an app resolver', () => { + process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'false'; + expect(() => builder.start()).not.toThrow(); + expect(requests).toHaveLength(0); + }); +}); diff --git a/tests/observability/extension/hosting/agentic-token-cache.test.ts b/tests/observability/extension/hosting/agentic-token-cache.test.ts index 4c02cc7a..9b5d6d37 100644 --- a/tests/observability/extension/hosting/agentic-token-cache.test.ts +++ b/tests/observability/extension/hosting/agentic-token-cache.test.ts @@ -7,11 +7,12 @@ import type { Authorization, TurnContext } from '@microsoft/agents-hosting'; const obsScopes = ['api://9b975845-388f-4429-889e-eab1ef63949c/.default']; -function makeJwtWithExp(expSecondsFromNow: number): string { +function makeJwtWithExp(expSecondsFromNow: number, claims: Record = {}): string { const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, - roles: ['Agent365.Observability.OtelWrite'], + idtyp: 'app', + ...claims, })).toString('base64url'); return `${header}.${payload}.test-signature`; } @@ -57,6 +58,24 @@ describe('AgenticTokenCache app-only OBS authentication', () => { expect(resolver).toHaveBeenCalledWith('agent', 'tenant', scopes); }); + it.each([ + { description: 'absent', roles: undefined }, + { description: 'empty', roles: [] }, + ])('caches an explicitly app-only token with $description roles', async ({ roles }) => { + const token = makeJwtWithExp(300, { roles }); + await cache.RefreshObservabilityToken('agent', 'tenant', () => token); + expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); + }); + + it('deduplicates concurrent roleless-token acquisitions for the same identity', async () => { + const token = makeJwtWithExp(300); + const resolver = jest.fn(async () => token); + await Promise.all(Array.from({ length: 8 }, () => + cache.RefreshObservabilityToken('agent', 'tenant', resolver))); + expect(resolver).toHaveBeenCalledTimes(1); + expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); + }); + it('fails for an empty scope configuration without requesting a token', async () => { cache = new AgenticTokenCache({ getConfiguration: () => new ObservabilityConfiguration({ @@ -134,8 +153,8 @@ describe('AgenticTokenCache app-only OBS authentication', () => { expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); }); - it('treats a near-expiry token as expired', async () => { - await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(30)); + it.each([-30, 0, 30])('does not return a token expiring in %s seconds', async (seconds) => { + await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(seconds)); expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); }); @@ -174,12 +193,14 @@ describe('AgenticTokenCache app-only OBS authentication', () => { it('evicts the oldest token when the cache reaches capacity', async () => { const token = makeJwtWithExp(300); - for (let i = 0; i <= 10_000; i++) { - await cache.RefreshObservabilityToken(`agent-${i}`, 'tenant', () => token); + const capacity = cache['_maxCacheSize']; + const resolver = () => token; + for (let i = 0; i <= capacity; i++) { + await cache.RefreshObservabilityToken(`agent-${i}`, 'tenant', resolver); } expect(cache.getObservabilityToken('agent-0', 'tenant')).toBeNull(); expect(cache.getObservabilityToken('agent-1', 'tenant')).toBe(token); - expect(cache.getObservabilityToken('agent-10000', 'tenant')).toBe(token); + expect(cache.getObservabilityToken(`agent-${capacity}`, 'tenant')).toBe(token); }); it('caps token lifetime to 24 hours', async () => { diff --git a/tests/tooling/package-dependencies.test.ts b/tests/tooling/package-dependencies.test.ts new file mode 100644 index 00000000..3ceb8873 --- /dev/null +++ b/tests/tooling/package-dependencies.test.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +describe('tooling runtime dependencies', () => { + it('declares axios directly rather than relying on workspace hoisting', () => { + const manifest = JSON.parse(readFileSync( + join(__dirname, '../../packages/agents-a365-tooling/package.json'), 'utf8', + )); + + expect(manifest.dependencies).toHaveProperty('axios', 'catalog:'); + }); +}); From 6464933ef503c7903e86b676fca1f5c1d0897e4d Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:00:41 +0100 Subject: [PATCH 3/4] Forward exporterOptions from ObservabilityManager.start ObservabilityManager.start(options) accepted BuilderOptions.exporterOptions but never passed it to the builder, so the documented `exporterOptions.tokenResolver` migration path failed at startup with the now-required app-only resolver. Forward it through withExporterOptions; a top-level tokenResolver still takes precedence. Regression tests exercise the public start() path through the real processor and exporter and fail without the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cbf5f6b-cc40-4b7e-a591-65848db73a12 --- .../src/ObservabilityManager.ts | 4 + ...ervabilityManager-exporter-options.test.ts | 92 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/observability/core/observabilityManager-exporter-options.test.ts diff --git a/packages/agents-a365-observability/src/ObservabilityManager.ts b/packages/agents-a365-observability/src/ObservabilityManager.ts index 7b96c44d..225a29f6 100644 --- a/packages/agents-a365-observability/src/ObservabilityManager.ts +++ b/packages/agents-a365-observability/src/ObservabilityManager.ts @@ -43,6 +43,10 @@ export class ObservabilityManager { builder.withTokenResolver(options.tokenResolver); } + if (options?.exporterOptions) { + builder.withExporterOptions(options.exporterOptions); + } + if (options?.clusterCategory) { builder.withClusterCategory(options.clusterCategory); } diff --git a/tests/observability/core/observabilityManager-exporter-options.test.ts b/tests/observability/core/observabilityManager-exporter-options.test.ts new file mode 100644 index 00000000..9cd2d9da --- /dev/null +++ b/tests/observability/core/observabilityManager-exporter-options.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { context, trace } from '@opentelemetry/api'; +import { ObservabilityManager } from '@microsoft/agents-a365-observability/src/ObservabilityManager'; +import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability/src/tracing/constants'; +import type { TokenResolver } from '@microsoft/agents-a365-observability'; + +const tenantId = '11111111-1111-1111-1111-111111111111'; +const agentId = '22222222-2222-2222-2222-222222222222'; +const originalFetch = global.fetch; + +function appToken(): string { + const claims = { + idtyp: 'app', azp: agentId, tid: tenantId, + aud: '9b975845-388f-4429-889e-eab1ef63949c', + exp: Math.floor(Date.now() / 1000) + 300, + }; + return `${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.offline-signature`; +} + +describe('ObservabilityManager.start exporter options', () => { + let originalEnv: NodeJS.ProcessEnv; + let requests: Request[]; + + beforeEach(() => { + originalEnv = { ...process.env }; + process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; + delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; + requests = []; + global.fetch = jest.fn(async (input, init) => { + requests.push(new Request(input, init)); + return new Response('{}', { status: 200 }); + }); + }); + + afterEach(async () => { + await ObservabilityManager.shutdown(); + trace.disable(); + context.disable(); + process.env = originalEnv; + global.fetch = originalFetch; + }); + + async function emitSpan(): Promise { + trace.getTracer('manager-exporter-options').startSpan('invoke_agent offline', { + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: 'invoke_agent', + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + }, + }).end(); + // Span completion schedules asynchronous token resolution and export. + await new Promise(resolve => setImmediate(resolve)); + } + + it('forwards exporterOptions.tokenResolver so the documented migration path starts and exports', async () => { + const expectedToken = appToken(); + const resolver = jest.fn(async () => expectedToken); + + // Without forwarding, start() throws because the exporter requires an app-only resolver. + expect(() => ObservabilityManager.start({ + serviceName: 'manager-exporter-options', + exporterOptions: { tokenResolver: resolver }, + })).not.toThrow(); + await emitSpan(); + + expect(resolver).toHaveBeenCalledWith(agentId, tenantId); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + expect(requests[0].headers.get('authorization')).toBe(`Bearer ${expectedToken}`); + }); + + it('keeps the top-level tokenResolver ahead of exporterOptions.tokenResolver', async () => { + const expectedToken = appToken(); + const resolver = jest.fn(async () => expectedToken); + const overridden = jest.fn(async () => 'must-not-be-used'); + + ObservabilityManager.start({ + serviceName: 'manager-exporter-options', + tokenResolver: resolver, + exporterOptions: { tokenResolver: overridden }, + }); + await emitSpan(); + + expect(resolver).toHaveBeenCalledWith(agentId, tenantId); + expect(overridden).not.toHaveBeenCalled(); + expect(requests[0].headers.get('authorization')).toBe(`Bearer ${expectedToken}`); + }); +}); From dd4a8be09021f66f7af561820432b337bc1583ee Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:07:03 +0100 Subject: [PATCH 4/4] Document ObservabilityManager.start exporterOptions forwarding State in the README, design guide, and changelog that ObservabilityManager.start(options) forwards exporterOptions (including exporterOptions.tokenResolver), and that tokenResolver/withTokenResolver takes precedence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cbf5f6b-cc40-4b7e-a591-65848db73a12 --- CHANGELOG.md | 2 ++ packages/agents-a365-observability/README.md | 3 ++- packages/agents-a365-observability/docs/design.md | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd18204..7a27a7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,8 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. empty tokens or acquisition failures fail export without delegated fallback. The exporter invokes the resolver on every export batch, so resolvers must cache the acquired token and refresh only near expiry. + `ObservabilityManager.start(options)` now forwards `options.exporterOptions`, + which it previously ignored. Workload OBO and custom-exporter context helpers are otherwise unchanged. - **Hosting OBS token cache requires an app-only resolver** - `RefreshObservabilityToken(agentId, tenantId, tokenResolver)` replaces the diff --git a/packages/agents-a365-observability/README.md b/packages/agents-a365-observability/README.md index 869ca0ca..07d9d012 100644 --- a/packages/agents-a365-observability/README.md +++ b/packages/agents-a365-observability/README.md @@ -41,7 +41,8 @@ does not establish that its caller identity is trusted. Batch and per-request exports both call the configured `tokenResolver` with the exporting agent and tenant IDs. Configure it with `withTokenResolver(...)` or -`exporterOptions.tokenResolver`; the explicit builder method takes precedence. +`exporterOptions.tokenResolver`; both also work through `ObservabilityManager.start(options)`, +and `withTokenResolver`/`tokenResolver` takes precedence. The callback must acquire or refresh an app-only OBS token independently of workload authentication. diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index 53d30bf6..de2519ef 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -68,7 +68,8 @@ await ObservabilityManager.shutdown(); The configured app-only OBS resolver is required in both batch and per-request modes. The builder merges resolver options consistently, with `withTokenResolver` -taking precedence over `exporterOptions.tokenResolver`. Request context is retained +taking precedence over `exporterOptions.tokenResolver`. `ObservabilityManager.start(options)` +forwards both `tokenResolver` and `exporterOptions` to the builder. Request context is retained for tracing, but its token is not consumed by `Agent365Exporter`. See the [per-request migration guide](../README.md#migrating-per-request-authentication).