From 996b5e7dd8d4050f4b89e48ad52dca346e1dd062 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Mon, 10 Nov 2025 14:38:52 -0800 Subject: [PATCH 01/26] use Agent365ExporterOptions for exporter and create AgenticTokenCacheOptions --- .../src/ObservabilityBuilder.ts | 3 - .../src/tracing/exporter/Agent365Exporter.ts | 52 +++++++++----- .../exporter/Agent365ExporterOptions.ts | 68 +++++++++++++++++++ .../src/utils/AgenticTokenCache.ts | 65 ++++++++++++++++++ 4 files changed, 167 insertions(+), 21 deletions(-) create mode 100644 packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts create mode 100644 packages/agents-a365-observability/src/utils/AgenticTokenCache.ts diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index becc1004..3ab93a9c 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -69,9 +69,6 @@ export class ObservabilityBuilder { private getTraceExporter() { if (isAgent365ExporterEnabled()){ - if (!this.options.tokenResolver) { - throw new Error('tokenResolver must be provided when Agent365 exporter is enabled'); - } return new Agent365Exporter( this.options.tokenResolver, this.options.clusterCategory || 'prod' diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index 7f06d048..a60e2c53 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -3,7 +3,9 @@ import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; import { PowerPlatformApiDiscovery, ClusterCategory } from '@microsoft/agents-a365-runtime'; import { partitionByIdentity, parseIdentityKey, hexTraceId, hexSpanId, kindName, statusName } from './utils'; -import logger, {formatError} from '../../utils/logging'; +import logger, { formatError } from '../../utils/logging'; +import { AgenticTokenCacheInstance } from '../../utils/AgenticTokenCache'; +import { Agent365ExporterOptions } from './Agent365ExporterOptions'; const DEFAULT_HTTP_TIMEOUT_SECONDS = 30000; // 30 seconds in ms const DEFAULT_MAX_RETRIES = 3; @@ -14,7 +16,7 @@ interface OTLPExportRequest { interface ResourceSpan { resource: { - attributes: Record | null; + attributes: Record | null; }; scopeSpans: ScopeSpan[]; } @@ -35,7 +37,7 @@ interface OTLPSpan { kind: string; startTimeUnixNano: number; endTimeUnixNano: number; - attributes: Record | null; + attributes: Record | null; events?: OTLPEvent[] | null; links?: OTLPLink[] | null; status: OTLPStatus; @@ -44,13 +46,13 @@ interface OTLPSpan { interface OTLPEvent { timeUnixNano: number; name: string; - attributes?: Record | null; + attributes?: Record | null; } interface OTLPLink { traceId: string; spanId: string; - attributes?: Record | null; + attributes?: Record | null; } interface OTLPStatus { @@ -71,20 +73,34 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null * - Adds Bearer token via token_resolver(agentId, tenantId) */ export class Agent365Exporter implements SpanExporter { - private readonly tokenResolver: TokenResolver; - private readonly clusterCategory: ClusterCategory; private closed = false; + private readonly options: Agent365ExporterOptions; - constructor( - tokenResolver: TokenResolver, - clusterCategory: ClusterCategory = 'prod' - ) { - if (!tokenResolver) { - logger.error('[Agent365Exporter] token_resolver is not provided'); - throw new Error('token_resolver must be provided.'); + /** + * Initializes a new instance of the Agent365Exporter class. + * @param tokenResolver The token resolver function. + * @param clusterCategory The cluster category (optional, defaults to 'prod'). + */ + constructor(tokenResolver?: TokenResolver, clusterCategory: ClusterCategory = 'prod') { + this.options = new Agent365ExporterOptions(); + this.options.clusterCategory = clusterCategory; + + if (tokenResolver) { + this.options.tokenResolver = tokenResolver; + logger.info('Agent365Exporter initialized with custom tokenResolver', `clusterCategory=${this.options.clusterCategory}`); + } else { + this.options.tokenResolver = async (agentId: string, tenantId: string): Promise => { + const key = AgenticTokenCacheInstance.createCacheKey(agentId, tenantId); + const cached = AgenticTokenCacheInstance.get(key); + if (!cached) { + logger.warn('Token cache miss', { agentId, tenantId }); + } else { + logger.info('Token cache hit', { agentId, tenantId }); + } + return cached; + }; + logger.info('Agent365Exporter initialized with cache-backed tokenResolver', `clusterCategory=${this.options.clusterCategory}`); } - this.tokenResolver = tokenResolver; - this.clusterCategory = clusterCategory; } /** @@ -140,7 +156,7 @@ export class Agent365Exporter implements SpanExporter { const body = JSON.stringify(payload); // Resolve endpoint + token - const discovery = new PowerPlatformApiDiscovery(this.clusterCategory); + const discovery = new PowerPlatformApiDiscovery(this.options.clusterCategory as ClusterCategory); const endpoint = discovery.getTenantIslandClusterEndpoint(tenantId); const url = `https://${endpoint}/maven/agent365/agents/${agentId}/traces?api-version=1`; logger.info(`[Agent365Exporter] Resolved endpoint: ${endpoint}`); @@ -149,7 +165,7 @@ export class Agent365Exporter implements SpanExporter { 'content-type': 'application/json' }; - const tokenResult = this.tokenResolver(agentId, tenantId); + const tokenResult = this.options.tokenResolver!(agentId, tenantId); const token = tokenResult instanceof Promise ? await tokenResult : tokenResult; if (token) { headers['authorization'] = `Bearer ${token}`; diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts new file mode 100644 index 00000000..d4020913 --- /dev/null +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -0,0 +1,68 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------------------------ + +import { ClusterCategory } from '@microsoft/agents-a365-runtime'; +import { TokenResolver } from './Agent365Exporter'; + +/** + * Async token resolver used to obtain an auth token for a specific agent + tenant. + * Must be fast and non-blocking (use internal caching elsewhere). + * Return null/undefined/empty to omit Authorization header. + */ +export type AsyncAuthTokenResolver = TokenResolver; + +/** + * Configuration for Agent365Exporter. + * Only ClusterCategory and TokenResolver are required for core operation. + */ +export class Agent365ExporterOptions { + /** + * Environment / cluster category + */ + public clusterCategory: ClusterCategory | string = 'preprod'; + + /** + * Async delegate used to resolve the auth token. REQUIRED. + */ + public tokenResolver?: AsyncAuthTokenResolver; + + /** + * When true, uses the service-to-service (S2S) endpoint path: /maven/agent365/service/agents/{agentId}/traces + * When false (default), uses the standard endpoint path: /maven/agent365/agents/{agentId}/traces + */ + public useS2SEndpoint: boolean = false; + + /** + * Maximum queue size for the batch processor. + */ + public maxQueueSize: number = 2048; + + /** + * Delay in milliseconds between export batches. + */ + public scheduledDelayMilliseconds: number = 5000; + + /** + * Timeout in milliseconds for the export operation. + */ + public exporterTimeoutMilliseconds: number = 30000; + + /** + * Maximum batch size for export operations. + */ + public maxExportBatchSize: number = 512; +} + +/** + * Helper to build the relative traces path. + */ +export function buildTracesPath(useS2S: boolean, agentId: string): string { + if (!agentId) { + throw new Error('agentId is required to build traces path.'); + } + return useS2S + ? `/maven/agent365/service/agents/${encodeURIComponent(agentId)}/traces` + : `/maven/agent365/agents/${encodeURIComponent(agentId)}/traces`; +} diff --git a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts new file mode 100644 index 00000000..4bfb2ebd --- /dev/null +++ b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts @@ -0,0 +1,65 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------------ + +/** + * Options for configuring the AgenticTokenCache. + */ +export interface AgenticTokenCacheOptions { + /** Default time-to-live in milliseconds applied when set() is called without an explicit ttl. */ + defaultTtlMs?: number; +} + +interface CacheEntry { + token: string; + /** Expiration timestamp in epoch milliseconds. */ + expiresAt: number; + /** Creation timestamp. */ + createdAt: number; +} + +/** + * Lightweight in-memory TTL cache for agent tokens. + * Minimal parity with C# version: set/get with expiration and cache key helper. + */ +export class AgenticTokenCache { + private readonly options: Required; + private readonly store: Map = new Map(); + + constructor(options?: AgenticTokenCacheOptions) { + this.options = { + defaultTtlMs: options?.defaultTtlMs ?? 50 * 60 * 1000 + }; + } + + /** Create a cache key from agent/tenant identifiers. */ + createCacheKey(agentId: string, tenantId?: string): string { + return tenantId ? `${agentId}:${tenantId}` : agentId; + } + + /** Set a token value with optional TTL override. */ + set(key: string, token: string, ttlMs?: number): void { + const now = Date.now(); + const ttl = ttlMs ?? this.options.defaultTtlMs; + const expiresAt = now + Math.max(0, ttl); + + this.store.set(key, { token, expiresAt, createdAt: now }); + } + + /** Retrieve a token if present and not expired; otherwise returns null. */ + get(key: string): string | null { + const entry = this.store.get(key); + if (!entry) { + return null; + } + if (entry.expiresAt <= Date.now()) { + this.store.delete(key); + return null; + } + return entry.token; + } +} + +export const AgenticTokenCacheInstance = new AgenticTokenCache(); + + From 9c50c7249a05d476d5816bfbfe8d4f361cc0c904 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Mon, 10 Nov 2025 16:06:08 -0800 Subject: [PATCH 02/26] tests --- .../exporter/Agent365ExporterOptions.ts | 23 +-- .../core/agent365-exporter.test.ts | 143 ++++++++++++++++++ 2 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 tests/observability/core/agent365-exporter.test.ts diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index d4020913..23a8c495 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -6,13 +6,6 @@ import { ClusterCategory } from '@microsoft/agents-a365-runtime'; import { TokenResolver } from './Agent365Exporter'; -/** - * Async token resolver used to obtain an auth token for a specific agent + tenant. - * Must be fast and non-blocking (use internal caching elsewhere). - * Return null/undefined/empty to omit Authorization header. - */ -export type AsyncAuthTokenResolver = TokenResolver; - /** * Configuration for Agent365Exporter. * Only ClusterCategory and TokenResolver are required for core operation. @@ -24,9 +17,9 @@ export class Agent365ExporterOptions { public clusterCategory: ClusterCategory | string = 'preprod'; /** - * Async delegate used to resolve the auth token. REQUIRED. + * delegate used to resolve the auth token. REQUIRED. */ - public tokenResolver?: AsyncAuthTokenResolver; + public tokenResolver?: TokenResolver; /** * When true, uses the service-to-service (S2S) endpoint path: /maven/agent365/service/agents/{agentId}/traces @@ -54,15 +47,3 @@ export class Agent365ExporterOptions { */ public maxExportBatchSize: number = 512; } - -/** - * Helper to build the relative traces path. - */ -export function buildTracesPath(useS2S: boolean, agentId: string): string { - if (!agentId) { - throw new Error('agentId is required to build traces path.'); - } - return useS2S - ? `/maven/agent365/service/agents/${encodeURIComponent(agentId)}/traces` - : `/maven/agent365/agents/${encodeURIComponent(agentId)}/traces`; -} diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts new file mode 100644 index 00000000..e9ecc366 --- /dev/null +++ b/tests/observability/core/agent365-exporter.test.ts @@ -0,0 +1,143 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------------------------ + +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { Agent365Exporter } from '@microsoft/agents-a365-observability/src/tracing/exporter/Agent365Exporter'; +import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability/src/utils/AgenticTokenCache'; +// Using standard import instead of 'import type' to avoid Babel/Jest transform issues in this workspace +import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { ExportResultCode } from '@opentelemetry/core'; +import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability/src/tracing/constants'; + +// Minimal mock span factory +function makeSpan(attrs: Record, name = 'test'): ReadableSpan { + return { + name, + kind: 0, + spanContext: () => ({ traceId: '1', spanId: '2', traceFlags: 1 }), + parentSpanId: undefined, + parentSpanContext: undefined, + startTime: [Math.floor(Date.now() / 1000), 0], + endTime: [Math.floor(Date.now() / 1000) + 1, 0], + status: { code: 0 }, + attributes: attrs, + events: [], + links: [], + duration: [1, 0], + resource: { attributes: {} }, + instrumentationScope: { name: 'tests', version: '1.0.0' } + } as unknown as ReadableSpan; +} + +// Helpers +const tenantId = 'tenant-11111111-1111-1111-1111-111111111111'; +const agentId = 'agent-22222222-2222-2222-2222-222222222222'; + +// Patch global fetch +const originalFetch = global.fetch; + +function mockFetchSequence(statuses: number[]): void { + let call = 0; + global.fetch = jest.fn(async () => ({ + status: statuses[Math.min(call++, statuses.length - 1)], + headers: { get: () => 'cid' } + })) as unknown as typeof fetch; +} + +describe('Agent365Exporter', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + global.fetch = originalFetch; + }); + + it('returns success immediately with no spans', async () => { + const exporter = new Agent365Exporter(() => null, 'local'); + const callback = jest.fn(); + await exporter.export([], callback); + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + }); + + it('uses provided token resolver and sets authorization header', async () => { + const token = 'abc123'; + mockFetchSequence([200]); + const exporter = new Agent365Exporter(() => token, 'local'); + + const spans = [ + makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId + }) + ]; + + const callback = jest.fn(); + await exporter.export(spans, callback); + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + // Ensure fetch saw auth header + const fetchCalls = (global.fetch as unknown as { mock: { calls: any[] } }).mock.calls; + expect(fetchCalls.length).toBe(1); + const headersArg = fetchCalls[0][1].headers; + expect(headersArg['authorization']).toBe(`Bearer ${token}`); + // Validate attributes in exported payload + const bodyStr = fetchCalls[0][1].body as string; + const bodyJson = JSON.parse(bodyStr); + const exportedSpan = bodyJson.resourceSpans[0].scopeSpans[0].spans[0]; + expect(exportedSpan.attributes).toBeDefined(); + expect(exportedSpan.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(tenantId); + expect(exportedSpan.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(agentId); + }); + + it('falls back to AgenticTokenCache when no custom resolver provided', async () => { + mockFetchSequence([200]); + // Preload cache + const tenant = tenantId; + const agent = agentId; + const key = AgenticTokenCacheInstance.createCacheKey(agent, tenant); + AgenticTokenCacheInstance.set(key, 'cached-token'); + + const exporter = new Agent365Exporter(undefined, 'local'); // no resolver provided + const spans = [ + makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenant, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agent + }) + ]; + const callback = jest.fn(); + await exporter.export(spans, callback); + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + const fetchCalls = (global.fetch as unknown as { mock: { calls: any[] } }).mock.calls; + expect(fetchCalls.length).toBe(1); + const headersArg = fetchCalls[0][1].headers; + expect(headersArg['authorization']).toBe('Bearer cached-token'); + // Validate payload structure + const bodyStr = fetchCalls[0][1].body as string; + expect(typeof bodyStr).toBe('string'); + const bodyJson = JSON.parse(bodyStr); + expect(Array.isArray(bodyJson.resourceSpans)).toBe(true); + expect(bodyJson.resourceSpans.length).toBe(1); + // Debug output of resourceSpans for inspection + // eslint-disable-next-line no-console + console.log('[test] resourceSpans:', JSON.stringify(bodyJson.resourceSpans, null, 2)); + const rs = bodyJson.resourceSpans[0]; + expect(Array.isArray(rs.scopeSpans)).toBe(true); + expect(rs.scopeSpans.length).toBe(1); + const scopeSpan = rs.scopeSpans[0]; + expect(Array.isArray(scopeSpan.spans)).toBe(true); + expect(scopeSpan.spans.length).toBe(1); + const span = scopeSpan.spans[0]; + expect(span.name).toBe('test'); + expect(span.traceId).toEqual('00000000000000000000000000000001'); + expect(span.spanId).toEqual('0000000000000002'); + expect(span.attributes).toBeDefined(); + expect(span.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(tenant); + expect(span.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(agent); + // Validate local cluster endpoint format + const urlArg = fetchCalls[0][0] as string; + expect(urlArg).toContain('localhost'); + }); +}); From 51b4d86d8a966c46653157834877d48153631d0c Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 11 Nov 2025 09:58:18 -0800 Subject: [PATCH 03/26] copilot comment --- .../exporter/Agent365ExporterOptions.ts | 2 +- .../src/utils/AgenticTokenCache.ts | 17 ++++++---- .../basic-agent-sdk-sample/src/index.ts | 33 ++++++++++++++++--- .../core/agent365-exporter.test.ts | 14 ++++---- 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index 23a8c495..4eabaad4 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -17,7 +17,7 @@ export class Agent365ExporterOptions { public clusterCategory: ClusterCategory | string = 'preprod'; /** - * delegate used to resolve the auth token. REQUIRED. + * Resolver used to resolve the auth token. Optional - falls back to AgenticTokenCache when not provided. */ public tokenResolver?: TokenResolver; diff --git a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts index 4bfb2ebd..a8308b38 100644 --- a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts +++ b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts @@ -11,11 +11,11 @@ export interface AgenticTokenCacheOptions { } interface CacheEntry { - token: string; - /** Expiration timestamp in epoch milliseconds. */ - expiresAt: number; - /** Creation timestamp. */ - createdAt: number; + token: string; + /** Expiration timestamp in epoch milliseconds. */ + expiresAt: number; + /** Creation timestamp. */ + createdAt: number; } /** @@ -58,8 +58,11 @@ export class AgenticTokenCache { } return entry.token; } + + /** Clear all cache entries (primarily for test isolation). */ + clear(): void { + this.store.clear(); + } } export const AgenticTokenCacheInstance = new AgenticTokenCache(); - - diff --git a/tests-agent/basic-agent-sdk-sample/src/index.ts b/tests-agent/basic-agent-sdk-sample/src/index.ts index f445680a..975b8787 100644 --- a/tests-agent/basic-agent-sdk-sample/src/index.ts +++ b/tests-agent/basic-agent-sdk-sample/src/index.ts @@ -18,6 +18,7 @@ a365Observability.start(); // Mock authentication middleware for development // This is only required when running from agents playground +try { app.use((req: Request, res: Response, next: NextFunction) => { // Create a mock identity when JWT is disabled req.user = { @@ -27,12 +28,36 @@ app.use((req: Request, res: Response, next: NextFunction) => { } next() }) +} catch (err) { + console.warn('Skipping mock authentication middleware:', err); +} app.post('/api/messages', async (req: Request, res: Response) => { - await adapter.process(req, res, async (context) => { - const app = agentApplication; - await app.run(context); - }); + try { + await adapter.process(req, res, async (context) => { + const app = agentApplication; + await app.run(context); + }); + } catch (err) { + // Enhanced diagnostic logging for token acquisition / adapter failures + const anyErr = err as any; + const status = anyErr?.status || anyErr?.response?.status; + const data = anyErr?.response?.data; + const message = anyErr?.message || 'Unknown error'; + // Axios style nested config + const aadError = data?.error || data?.error_description || data; + console.error('[diagnostic] adapter.process failed', { + message, + status, + aadError, + url: anyErr?.config?.url, + scope: anyErr?.config?.data, + }); + // Surface minimal info to caller while keeping internals in log + if (!res.headersSent) { + res.status(500).json({ error: 'internal_error', detail: status ? `upstream status ${status}` : message }); + } + } }); const port = process.env.PORT || 3978; diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index e9ecc366..43b7ce6a 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -53,12 +53,13 @@ describe('Agent365Exporter', () => { afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); - global.fetch = originalFetch; + global.fetch = originalFetch; + AgenticTokenCacheInstance.clear(); }); it('returns success immediately with no spans', async () => { const exporter = new Agent365Exporter(() => null, 'local'); - const callback = jest.fn(); + const callback = jest.fn(); await exporter.export([], callback); expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); }); @@ -75,7 +76,7 @@ describe('Agent365Exporter', () => { }) ]; - const callback = jest.fn(); + const callback = jest.fn(); await exporter.export(spans, callback); expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); // Ensure fetch saw auth header @@ -120,9 +121,8 @@ describe('Agent365Exporter', () => { const bodyJson = JSON.parse(bodyStr); expect(Array.isArray(bodyJson.resourceSpans)).toBe(true); expect(bodyJson.resourceSpans.length).toBe(1); - // Debug output of resourceSpans for inspection - // eslint-disable-next-line no-console - console.log('[test] resourceSpans:', JSON.stringify(bodyJson.resourceSpans, null, 2)); + // eslint-disable-next-line no-console + console.log('[test] resourceSpans:', JSON.stringify(bodyJson.resourceSpans, null, 2)); const rs = bodyJson.resourceSpans[0]; expect(Array.isArray(rs.scopeSpans)).toBe(true); expect(rs.scopeSpans.length).toBe(1); @@ -137,7 +137,7 @@ describe('Agent365Exporter', () => { expect(span.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(tenant); expect(span.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(agent); // Validate local cluster endpoint format - const urlArg = fetchCalls[0][0] as string; + const urlArg = fetchCalls[0][0] as string; expect(urlArg).toContain('localhost'); }); }); From a0de07344ff879d62b1bf3a110e2716b3a8127a3 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 11 Nov 2025 11:18:11 -0800 Subject: [PATCH 04/26] configure batch span processor --- .../src/ObservabilityBuilder.ts | 26 ++++++++++++------- .../src/tracing/exporter/Agent365Exporter.ts | 22 +++++++--------- .../core/agent365-exporter.test.ts | 15 ++++++++--- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index 3ab93a9c..bcf2884f 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -7,6 +7,7 @@ import { ConsoleSpanExporter, BatchSpanProcessor } from '@opentelemetry/sdk-trac import { SpanProcessor } from './tracing/processors/SpanProcessor'; import { isAgent365ExporterEnabled } from './tracing/util'; import { Agent365Exporter, TokenResolver } from './tracing/exporter/Agent365Exporter'; +import { Agent365ExporterOptions } from './tracing/exporter/Agent365ExporterOptions'; import { resourceFromAttributes } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; import { trace } from '@opentelemetry/api'; @@ -67,17 +68,24 @@ export class ObservabilityBuilder { return this; } - private getTraceExporter() { - if (isAgent365ExporterEnabled()){ - return new Agent365Exporter( - this.options.tokenResolver, - this.options.clusterCategory || 'prod' - ); - } else { - return new ConsoleSpanExporter(); + private createBatchProcessor(): BatchSpanProcessor { + if (!isAgent365ExporterEnabled()) { + return new BatchSpanProcessor(new ConsoleSpanExporter()); } + const opts = new Agent365ExporterOptions(); + opts.clusterCategory = this.options.clusterCategory || 'prod'; + if (this.options.tokenResolver) { + opts.tokenResolver = this.options.tokenResolver; + } + return new BatchSpanProcessor(new Agent365Exporter(opts), { + maxQueueSize: opts.maxQueueSize, + scheduledDelayMillis: opts.scheduledDelayMilliseconds, + exportTimeoutMillis: opts.exporterTimeoutMilliseconds, + maxExportBatchSize: opts.maxExportBatchSize + }); } + private createResource() { const serviceName = this.options.serviceVersion ? `${this.options.serviceName}-${this.options.serviceVersion}` @@ -101,7 +109,7 @@ export class ObservabilityBuilder { const spanProcessor = new SpanProcessor(); // 2. batch processor that actually ships spans out - const batchProcessor = new BatchSpanProcessor(this.getTraceExporter()); + const batchProcessor = this.createBatchProcessor(); const globalProvider: any = trace.getTracerProvider(); diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index a60e2c53..fa62c42b 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -77,19 +77,13 @@ export class Agent365Exporter implements SpanExporter { private readonly options: Agent365ExporterOptions; /** - * Initializes a new instance of the Agent365Exporter class. - * @param tokenResolver The token resolver function. - * @param clusterCategory The cluster category (optional, defaults to 'prod'). + * Initialize exporter with a fully constructed options instance. + * If tokenResolver is missing, installs cache-backed resolver. */ - constructor(tokenResolver?: TokenResolver, clusterCategory: ClusterCategory = 'prod') { - this.options = new Agent365ExporterOptions(); - this.options.clusterCategory = clusterCategory; + constructor(options: Agent365ExporterOptions) { - if (tokenResolver) { - this.options.tokenResolver = tokenResolver; - logger.info('Agent365Exporter initialized with custom tokenResolver', `clusterCategory=${this.options.clusterCategory}`); - } else { - this.options.tokenResolver = async (agentId: string, tenantId: string): Promise => { + if (!options.tokenResolver) { + options.tokenResolver = (agentId: string, tenantId: string): string | null => { const key = AgenticTokenCacheInstance.createCacheKey(agentId, tenantId); const cached = AgenticTokenCacheInstance.get(key); if (!cached) { @@ -99,8 +93,12 @@ export class Agent365Exporter implements SpanExporter { } return cached; }; - logger.info('Agent365Exporter initialized with cache-backed tokenResolver', `clusterCategory=${this.options.clusterCategory}`); + logger.info('Agent365Exporter initialized with cache-backed tokenResolver', `clusterCategory=${options.clusterCategory}`); + } else { + logger.info('Agent365Exporter initialized with custom tokenResolver', `clusterCategory=${options.clusterCategory}`); } + + this.options = options; } /** diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index 43b7ce6a..2dd075c8 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import { Agent365Exporter } from '@microsoft/agents-a365-observability/src/tracing/exporter/Agent365Exporter'; +import { Agent365ExporterOptions } from '@microsoft/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions'; import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability/src/utils/AgenticTokenCache'; // Using standard import instead of 'import type' to avoid Babel/Jest transform issues in this workspace import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; @@ -58,7 +59,10 @@ describe('Agent365Exporter', () => { }); it('returns success immediately with no spans', async () => { - const exporter = new Agent365Exporter(() => null, 'local'); + const opts = new Agent365ExporterOptions(); + opts.clusterCategory = 'local'; + opts.tokenResolver = () => null; + const exporter = new Agent365Exporter(opts); const callback = jest.fn(); await exporter.export([], callback); expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); @@ -67,7 +71,10 @@ describe('Agent365Exporter', () => { it('uses provided token resolver and sets authorization header', async () => { const token = 'abc123'; mockFetchSequence([200]); - const exporter = new Agent365Exporter(() => token, 'local'); + const opts = new Agent365ExporterOptions(); + opts.clusterCategory = 'local'; + opts.tokenResolver = () => token; + const exporter = new Agent365Exporter(opts); const spans = [ makeSpan({ @@ -101,7 +108,9 @@ describe('Agent365Exporter', () => { const key = AgenticTokenCacheInstance.createCacheKey(agent, tenant); AgenticTokenCacheInstance.set(key, 'cached-token'); - const exporter = new Agent365Exporter(undefined, 'local'); // no resolver provided + const opts = new Agent365ExporterOptions(); + opts.clusterCategory = 'local'; // no tokenResolver assigned -> fallback to cache + const exporter = new Agent365Exporter(opts); // no resolver provided const spans = [ makeSpan({ [OpenTelemetryConstants.TENANT_ID_KEY]: tenant, From 88605ebaf7b270776be52dc986ad2224c8a1db43 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 11 Nov 2025 11:51:37 -0800 Subject: [PATCH 05/26] copilot comment --- .../src/ObservabilityBuilder.ts | 1 - .../src/tracing/exporter/Agent365Exporter.ts | 13 +++++++++++-- tests/observability/core/agent365-exporter.test.ts | 13 ++++++------- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index bcf2884f..4101478a 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -85,7 +85,6 @@ export class ObservabilityBuilder { }); } - private createResource() { const serviceName = this.options.serviceVersion ? `${this.options.serviceName}-${this.options.serviceVersion}` diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index fa62c42b..e978c9c3 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -1,3 +1,8 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------------------------ + import { ExportResult,ExportResultCode } from '@opentelemetry/core'; import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; @@ -75,12 +80,16 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null export class Agent365Exporter implements SpanExporter { private closed = false; private readonly options: Agent365ExporterOptions; + private readonly tokenResolver: TokenResolver; // defensive copy of resolver /** * Initialize exporter with a fully constructed options instance. * If tokenResolver is missing, installs cache-backed resolver. */ constructor(options: Agent365ExporterOptions) { + if (!options) { + throw new Error('Agent365ExporterOptions must be provided (was null/undefined)'); + } if (!options.tokenResolver) { options.tokenResolver = (agentId: string, tenantId: string): string | null => { @@ -97,8 +106,8 @@ export class Agent365Exporter implements SpanExporter { } else { logger.info('Agent365Exporter initialized with custom tokenResolver', `clusterCategory=${options.clusterCategory}`); } - this.options = options; + this.tokenResolver = options.tokenResolver!; } /** @@ -163,7 +172,7 @@ export class Agent365Exporter implements SpanExporter { 'content-type': 'application/json' }; - const tokenResult = this.options.tokenResolver!(agentId, tenantId); + const tokenResult = this.tokenResolver(agentId, tenantId); const token = tokenResult instanceof Promise ? await tokenResult : tokenResult; if (token) { headers['authorization'] = `Bearer ${token}`; diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index 2dd075c8..45f672c7 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -54,8 +54,8 @@ describe('Agent365Exporter', () => { afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); - global.fetch = originalFetch; - AgenticTokenCacheInstance.clear(); + global.fetch = originalFetch; + AgenticTokenCacheInstance.clear(); }); it('returns success immediately with no spans', async () => { @@ -108,9 +108,9 @@ describe('Agent365Exporter', () => { const key = AgenticTokenCacheInstance.createCacheKey(agent, tenant); AgenticTokenCacheInstance.set(key, 'cached-token'); - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'local'; // no tokenResolver assigned -> fallback to cache - const exporter = new Agent365Exporter(opts); // no resolver provided + const opts = new Agent365ExporterOptions(); + opts.clusterCategory = 'local'; // no tokenResolver assigned -> fallback to cache + const exporter = new Agent365Exporter(opts); // no resolver provided const spans = [ makeSpan({ [OpenTelemetryConstants.TENANT_ID_KEY]: tenant, @@ -130,8 +130,7 @@ describe('Agent365Exporter', () => { const bodyJson = JSON.parse(bodyStr); expect(Array.isArray(bodyJson.resourceSpans)).toBe(true); expect(bodyJson.resourceSpans.length).toBe(1); - // eslint-disable-next-line no-console - console.log('[test] resourceSpans:', JSON.stringify(bodyJson.resourceSpans, null, 2)); + // console.log('[test] resourceSpans:', JSON.stringify(bodyJson.resourceSpans, null, 2)); const rs = bodyJson.resourceSpans[0]; expect(Array.isArray(rs.scopeSpans)).toBe(true); expect(rs.scopeSpans.length).toBe(1); From ca17b9632a8971bd96f8ce86f8352c771cd81ce4 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Wed, 12 Nov 2025 15:58:52 -0800 Subject: [PATCH 06/26] checkpoint to use cached context and authorization to exchange token. --- .../agents-a365-observability/src/index.ts | 1 + .../src/tracing/exporter/Agent365Exporter.ts | 17 +- .../src/utils/AgenticTokenCache.ts | 223 ++++++++++++++---- .../basic-agent-sdk-sample/src/agent.ts | 11 +- .../basic-agent-sdk-sample/src/index.ts | 4 +- .../basic-agent-sdk-sample/src/telemetry.ts | 2 +- 6 files changed, 189 insertions(+), 69 deletions(-) diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index 083b2477..25f94072 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -35,3 +35,4 @@ export { OpenTelemetryScope } from './tracing/scopes/OpenTelemetryScope'; export { ExecuteToolScope } from './tracing/scopes/ExecuteToolScope'; export { InvokeAgentScope } from './tracing/scopes/InvokeAgentScope'; export { InferenceScope} from './tracing/scopes/InferenceScope'; +export { AgenticTokenCacheInstance } from './utils/AgenticTokenCache'; diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index e978c9c3..4b3d9ab8 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -80,7 +80,6 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null export class Agent365Exporter implements SpanExporter { private closed = false; private readonly options: Agent365ExporterOptions; - private readonly tokenResolver: TokenResolver; // defensive copy of resolver /** * Initialize exporter with a fully constructed options instance. @@ -92,22 +91,12 @@ export class Agent365Exporter implements SpanExporter { } if (!options.tokenResolver) { - options.tokenResolver = (agentId: string, tenantId: string): string | null => { - const key = AgenticTokenCacheInstance.createCacheKey(agentId, tenantId); - const cached = AgenticTokenCacheInstance.get(key); - if (!cached) { - logger.warn('Token cache miss', { agentId, tenantId }); - } else { - logger.info('Token cache hit', { agentId, tenantId }); - } - return cached; - }; - logger.info('Agent365Exporter initialized with cache-backed tokenResolver', `clusterCategory=${options.clusterCategory}`); + options.tokenResolver = AgenticTokenCacheInstance.getObservabilityToken.bind(AgenticTokenCacheInstance); + logger.info('Agent365Exporter initialized with agentic resolver', `clusterCategory=${options.clusterCategory}`); } else { logger.info('Agent365Exporter initialized with custom tokenResolver', `clusterCategory=${options.clusterCategory}`); } this.options = options; - this.tokenResolver = options.tokenResolver!; } /** @@ -172,7 +161,7 @@ export class Agent365Exporter implements SpanExporter { 'content-type': 'application/json' }; - const tokenResult = this.tokenResolver(agentId, tenantId); + const tokenResult = this.options.tokenResolver!(agentId, tenantId,); const token = tokenResult instanceof Promise ? await tokenResult : tokenResult; if (token) { headers['authorization'] = `Bearer ${token}`; diff --git a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts index a8308b38..5cdc841e 100644 --- a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts +++ b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts @@ -1,68 +1,203 @@ // ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. // ------------------------------------------------------------------------------ -/** - * Options for configuring the AgenticTokenCache. - */ -export interface AgenticTokenCacheOptions { - /** Default time-to-live in milliseconds applied when set() is called without an explicit ttl. */ - defaultTtlMs?: number; -} +import { TurnContext, Authorization } from '@microsoft/agents-hosting'; +import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runtime'; +import logger, { formatError } from './logging'; +// Structure stored per key interface CacheEntry { - token: string; - /** Expiration timestamp in epoch milliseconds. */ - expiresAt: number; - /** Creation timestamp. */ - createdAt: number; + turnContext: TurnContext; + scopes: string[]; + token?: string; // Cached token value + expiresOn?: number; // Expiration epoch millis (if provided by exchange response) + authorization: Authorization; } -/** - * Lightweight in-memory TTL cache for agent tokens. - * Minimal parity with C# version: set/get with expiration and cache key helper. - */ + export class AgenticTokenCache { - private readonly options: Required; - private readonly store: Map = new Map(); + private readonly _map = new Map(); + private readonly _defaultRefreshSkewMs = 60_000; // refresh 60s before expiry + // Per-key promise chain to serialize mutations & exchanges + private readonly _keyLocks = new Map>(); + private makeKey(agentId: string, tenantId: string): string { + return `${agentId}:${tenantId}`; + } - constructor(options?: AgenticTokenCacheOptions) { - this.options = { - defaultTtlMs: options?.defaultTtlMs ?? 50 * 60 * 1000 - }; + /** + * Registers observability context for (agentId, tenantId). + * First registration wins; subsequent calls are ignored (idempotent, no mutation/merge). + */ + registerObservability( + agentId: string, + tenantId: string, + turnContext: TurnContext, + authorization: Authorization, + scopes?: string[], + ): void { + if (!authorization) { + throw new Error('authorization cannot be null.'); + } + if (!agentId || !agentId.trim()) { + throw new Error('agentId cannot be null or whitespace.'); + } + if (!tenantId || !tenantId.trim()) { + throw new Error('tenantId cannot be null or whitespace.'); + } + if (!turnContext) { + throw new Error('turnContext cannot be null.'); + } + + const key = this.makeKey(agentId, tenantId); + const effectiveScopes = (scopes && scopes.length > 0) + ? scopes + : getObservabilityAuthenticationScope(); + if (this._map.has(key)) { + return; + } + // Clone the TurnContext to avoid later 'Proxy has been revoked' errors when accessed asynchronously + const cloned = this.cloneTurnContext(turnContext); + this._map.set(key, { + turnContext: cloned, + scopes: effectiveScopes, + authorization, + }); } - /** Create a cache key from agent/tenant identifiers. */ - createCacheKey(agentId: string, tenantId?: string): string { - return tenantId ? `${agentId}:${tenantId}` : agentId; + /** + * Retrieves (and if necessary exchanges) the observability token. + * Returns null on failure or if not registered. + */ + async getObservabilityToken(agentId: string, tenantId: string): Promise { + const key = this.makeKey(agentId, tenantId); + const entry = this._map.get(key); + if (!entry) { + logger.error(`AgenticTokenCache: No auth registration needed is found. No exchange token will run. agentId: ${agentId}, tenantId: ${tenantId}`); + return null; + } + + return this.withKeyLock(key, async () => { + if (entry.token && !this.isExpired(entry)) { + return entry.token; + } + const token = await this.exchangeToken(entry).catch(() => null); + entry.token = token || undefined; + return token; + }); } - /** Set a token value with optional TTL override. */ - set(key: string, token: string, ttlMs?: number): void { - const now = Date.now(); - const ttl = ttlMs ?? this.options.defaultTtlMs; - const expiresAt = now + Math.max(0, ttl); + /** + * Explicitly invalidates a cached token forcing re-exchange on next request. + */ + invalidateToken(agentId: string, tenantId: string): void { + const key = this.makeKey(agentId, tenantId); + const entry = this._map.get(key); + if (entry) { + entry.token = undefined; + entry.expiresOn = undefined; + } + } - this.store.set(key, { token, expiresAt, createdAt: now }); + /** Clears all cached tokens & registrations. */ + invalidateAll(): void { + this._map.clear(); } - /** Retrieve a token if present and not expired; otherwise returns null. */ - get(key: string): string | null { - const entry = this.store.get(key); - if (!entry) { - return null; + private isExpired(entry: CacheEntry): boolean { + if (!entry.expiresOn) { + return false; // If we don't have expiration metadata, assume still valid } - if (entry.expiresAt <= Date.now()) { - this.store.delete(key); - return null; + const now = Date.now(); + return now >= (entry.expiresOn - this._defaultRefreshSkewMs); // Refresh early by skew + } + + private async exchangeToken(entry: CacheEntry): Promise { + logger.info('AgenticTokenCache: Exchanging token via Authorization.exchangeToken...'); + if(!entry.authorization) { + throw new Error('Authorization instance not set.'); + } + try { + const tokenResponse = await entry.authorization.exchangeToken( + entry.turnContext, + 'agentic', + { scopes: entry.scopes } + ); + if (!tokenResponse?.token) { + logger.error('AgenticTokenCache: Token exchange returned undefined token'); + return null; + } + const expiresOn = (tokenResponse as { expiresOn?: number | Date | string }).expiresOn; + if (expiresOn instanceof Date) { + entry.expiresOn = expiresOn.getTime(); + } else if (typeof expiresOn === 'number') { + entry.expiresOn = expiresOn; + } else if (typeof expiresOn === 'string') { + const parsed = Date.parse(expiresOn); + if (!isNaN(parsed)) { + entry.expiresOn = parsed; + } + } + return tokenResponse.token; + } catch (e) { + logger.error('AgenticTokenCache: Token exchange failed with', formatError(e)); + return null; // Silent failure } - return entry.token; } - /** Clear all cache entries (primarily for test isolation). */ - clear(): void { - this.store.clear(); + private async withKeyLock(key: string, fn: () => Promise): Promise { + const previous = this._keyLocks.get(key); + if (previous) { + try { await previous; } catch { /* empty */ } + } + const currentPromise: Promise = fn().finally(() => { + if (this._keyLocks.get(key) === currentPromise) { + this._keyLocks.delete(key); + } + }); + this._keyLocks.set(key, currentPromise); + return currentPromise; } + + /** + * Creates a shallow clone of the TurnContext preserving activity and services. + * Falls back gracefully if a native clone() exists. + */ + private cloneTurnContext(ctx: TurnContext): TurnContext { + // Prefer native implementation if available + const possibleClone = (ctx as unknown as { clone?: () => TurnContext }).clone; + if (typeof possibleClone === 'function') { + try { + return possibleClone.call(ctx); + } catch { + // Ignore and fallback + } + } + + // Derive a typed helper interface to avoid 'any' + interface TurnContextLike { + activity: Record; + [key: string]: unknown; + } + const original = ctx as unknown as TurnContextLike; + + const proto = Object.getPrototypeOf(ctx); + const shallow: TurnContextLike = Object.create(proto); + for (const k of Object.keys(original)) { + shallow[k] = original[k]; + } + // Shallow copy activity object + shallow.activity = { ...original.activity }; + return shallow as unknown as TurnContext; + } +} + +// Helper for external callers to build a cache key if needed +export function createAgenticTokenCacheKey(agentId: string, tenantId: string): string { + return `${agentId}:${tenantId}`; } +export type TokenResolver = (agentId: string, tenantId: string) => string | null | Promise; + export const AgenticTokenCacheInstance = new AgenticTokenCache(); diff --git a/tests-agent/basic-agent-sdk-sample/src/agent.ts b/tests-agent/basic-agent-sdk-sample/src/agent.ts index 040becbe..359e5afb 100644 --- a/tests-agent/basic-agent-sdk-sample/src/agent.ts +++ b/tests-agent/basic-agent-sdk-sample/src/agent.ts @@ -18,6 +18,7 @@ import { ExecutionType, EnhancedAgentDetails, ServiceEndpoint, + AgenticTokenCacheInstance, } from '@microsoft/agents-a365-observability'; import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runtime'; @@ -87,15 +88,7 @@ agentApplication.onActivity( })); - // Cache the agentic token for observability token resolver - // const aauToken = await agentApplication.authorization.exchangeToken(context, ['https://api.powerplatform.com/.default'],'agentic') - const aauToken = await agentApplication.authorization.exchangeToken(context,'agentic', { - scopes: getObservabilityAuthenticationScope() - } ) - const cacheKey = createAgenticTokenCacheKey(agentInfo.agentId, tenantInfo.tenantId); - tokenCache.set(cacheKey, aauToken?.token || ''); - - await context.sendActivity(`(Agentic) You said: ${context.activity.text}, user token length=${aauToken.token?.length ?? 0}`); + AgenticTokenCacheInstance.registerObservability(agentInfo.agentId, tenantInfo.tenantId, context, agentApplication.authorization, getObservabilityAuthenticationScope()); const llmResponse = await performInference( context.activity.text ?? 'Unknown text', diff --git a/tests-agent/basic-agent-sdk-sample/src/index.ts b/tests-agent/basic-agent-sdk-sample/src/index.ts index 975b8787..a78807a4 100644 --- a/tests-agent/basic-agent-sdk-sample/src/index.ts +++ b/tests-agent/basic-agent-sdk-sample/src/index.ts @@ -27,7 +27,9 @@ app.use((req: Request, res: Response, next: NextFunction) => { azp: authConfig.clientId || 'mock-client-id' } next() -}) +}) + +//app.use(authorizeJWT(authConfig)); } catch (err) { console.warn('Skipping mock authentication middleware:', err); } diff --git a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts index ded2bb86..679cceeb 100644 --- a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts +++ b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts @@ -37,7 +37,7 @@ export const a365Observability = ObservabilityManager.configure( (builder: Builder) => builder .withService('TypeScript Sample Agent', '1.0.0') - .withTokenResolver(tokenResolver) + //.withTokenResolver(tokenResolver) .withClusterCategory(getClusterCategory()) ); From 1b89510e4614bd2ef219e16b21e3935d86bb4113 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Thu, 13 Nov 2025 15:35:57 -0800 Subject: [PATCH 07/26] only do refresh token in message handler, unit tests --- .../src/utils/AgenticTokenCache.ts | 272 ++++++++++-------- .../core/agent365-exporter.test.ts | 15 +- .../core/agentic-token-cache.test.ts | 137 +++++++++ 3 files changed, 306 insertions(+), 118 deletions(-) create mode 100644 tests/observability/core/agentic-token-cache.test.ts diff --git a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts index 5cdc841e..703339d0 100644 --- a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts +++ b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts @@ -9,15 +9,27 @@ import logger, { formatError } from './logging'; // Structure stored per key interface CacheEntry { - turnContext: TurnContext; scopes: string[]; token?: string; // Cached token value expiresOn?: number; // Expiration epoch millis (if provided by exchange response) - authorization: Authorization; } - -export class AgenticTokenCache { +/** + * In-memory cache for observability tokens keyed by agentId and tenantId. + * Features: + * - Stores bearer token + decoded expiration per (agentId, tenantId) key. + * - Applies an early refresh skew so tokens are proactively refreshed before hard expiry. + * - Retries transient exchange failures (network / HTTP 408, 429, 5xx) with linear backoff (200ms, 400ms). + * - Serializes write/exchange operations per key with a Promise chain (withKeyLock) to avoid duplicate exchanges. + * - Provides synchronous read access (getObservabilityToken) that never triggers network IO. + * + * Thread Safety: + * Per-key serialization ensures at most one exchange updates a given entry concurrently. Reads are lock‑free. + * + * Limitations: + * Process-local only; for multi-process or horizontal scaling scenarios a distributed cache/service is required. + */ +class AgenticTokenCache { private readonly _map = new Map(); private readonly _defaultRefreshSkewMs = 60_000; // refresh 60s before expiry // Per-key promise chain to serialize mutations & exchanges @@ -27,69 +39,125 @@ export class AgenticTokenCache { } /** - * Registers observability context for (agentId, tenantId). - * First registration wins; subsequent calls are ignored (idempotent, no mutation/merge). + * Returns the currently cached valid token for the key (no network calls). + * @param agentId Unique agent/application identifier. + * @param tenantId Tenant identifier (AAD tenant / customer context). + * @returns Cached bearer token string if present & not expired; otherwise null. */ - registerObservability( + public getObservabilityToken(agentId: string, tenantId: string): string | null { + const key = this.makeKey(agentId, tenantId); + const entry = this._map.get(key); + if (!entry) { + logger.error(`[AgenticTokenCache] No cache entry found for agentId=${agentId} tenantId=${tenantId}`); + return null; + } + if (!entry.token) { + logger.error(`[AgenticTokenCache] No token cached for agentId=${agentId} tenantId=${tenantId}`); + return null; + } + if (this.isExpired(entry)) { + logger.error(`[AgenticTokenCache] Cached token expired for agentId=${agentId} tenantId=${tenantId}`); + return null; + } + return entry.token; + } + + /** + * Ensures a valid token is cached for the (agentId, tenantId) key. Performs an exchange when: + * - No token exists yet. + * - Token is expired OR within the early refresh skew window. + * Retries transient failures up to 2 times (3 total attempts) with linear backoff (200ms, 400ms). + * Idempotent under the per-key lock: concurrent callers serialize and reuse the first successful result. + * @param agentId Unique agent identifier. + * @param tenantId Tenant identifier. + * @param turnContext TurnContext providing activity/service metadata required for exchange. + * @param authorization Authorization instance used to perform the token exchange. + * @param scopes Requested scopes; falls back to getObservabilityAuthenticationScope() if empty. + * @returns Promise resolved once cache updated (success or failure). Inspect using getObservabilityToken(). + */ + public async RefreshObservabilityToken( agentId: string, tenantId: string, turnContext: TurnContext, authorization: Authorization, - scopes?: string[], - ): void { + scopes: string[] + ): Promise { + const key = this.makeKey(agentId, tenantId); if (!authorization) { - throw new Error('authorization cannot be null.'); - } - if (!agentId || !agentId.trim()) { - throw new Error('agentId cannot be null or whitespace.'); - } - if (!tenantId || !tenantId.trim()) { - throw new Error('tenantId cannot be null or whitespace.'); - } - if (!turnContext) { - throw new Error('turnContext cannot be null.'); + logger.error('[AgenticTokenCache] Cannot exchange token. Authorization instance not set.'); + return; } - const key = this.makeKey(agentId, tenantId); - const effectiveScopes = (scopes && scopes.length > 0) - ? scopes - : getObservabilityAuthenticationScope(); - if (this._map.has(key)) { + if (!turnContext) { + logger.error('[AgenticTokenCache] Cannot exchange token. TurnContext instance not set.'); return; } - // Clone the TurnContext to avoid later 'Proxy has been revoked' errors when accessed asynchronously - const cloned = this.cloneTurnContext(turnContext); - this._map.set(key, { - turnContext: cloned, - scopes: effectiveScopes, - authorization, - }); - } - /** - * Retrieves (and if necessary exchanges) the observability token. - * Returns null on failure or if not registered. - */ - async getObservabilityToken(agentId: string, tenantId: string): Promise { - const key = this.makeKey(agentId, tenantId); - const entry = this._map.get(key); - if (!entry) { - logger.error(`AgenticTokenCache: No auth registration needed is found. No exchange token will run. agentId: ${agentId}, tenantId: ${tenantId}`); - return null; - } + // Acquire or return cached token under key lock + return this.withKeyLock(key, async () => { + // Entry creation moved inside lock to avoid race on first initialization + let entry = this._map.get(key); + if (!entry) { + const effectiveScopes = (scopes && scopes.length > 0) ? scopes : getObservabilityAuthenticationScope(); + entry = { scopes: effectiveScopes }; + this._map.set(key, entry); + } + try { + if (entry.token && !this.isExpired(entry)) { + return; + } - return this.withKeyLock(key, async () => { - if (entry.token && !this.isExpired(entry)) { - return entry.token; + const maxRetries = 2; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + logger.info(`[AgenticTokenCache] No cached token found. Exchanging token ... attempt ${attempt + 1}/${maxRetries + 1}`); + try { + const tokenResponse = await authorization.exchangeToken( + turnContext, + 'agentic1', + { scopes: entry.scopes } + ); + if (!tokenResponse?.token) { + logger.error('[AgenticTokenCache] Token exchange returned undefined token, please check agent permission configuration.'); + entry.token = undefined; + entry.expiresOn = undefined; + // Undefined token generally not transient; stop retries. + break; + } + entry.token = tokenResponse.token; + const oboExp = this.decodeExp(entry.token); + if (oboExp) { + entry.expiresOn = oboExp * 1000; // to epoch millisecond + } + logger.info('[AgenticTokenCache] Token exchange successful and cached.'); + // success + return; + } catch (e) { + const retriable = this.isRetriableError(e); + if (retriable && attempt < maxRetries) { + logger.warn(`[AgenticTokenCache] Retriable token exchange failure (attempt ${attempt + 1})`, formatError(e)); + const backoffMs = 200 * (attempt + 1); + await this.sleep(backoffMs); + continue; + } + logger.error('[AgenticTokenCache] Non-retriable token exchange failure', formatError(e)); + entry.token = undefined; + entry.expiresOn = undefined; + break; + } + } + } catch (e) { + logger.error('[AgenticTokenCache] Token exchange failed unexpectedly', formatError(e)); + entry.token = undefined; + entry.expiresOn = undefined; } - const token = await this.exchangeToken(entry).catch(() => null); - entry.token = token || undefined; - return token; + return; }); } /** - * Explicitly invalidates a cached token forcing re-exchange on next request. + * Explicitly clears token + expiration for one key forcing a fresh exchange next time. + * @param agentId Agent identifier. + * @param tenantId Tenant identifier. */ invalidateToken(agentId: string, tenantId: string): void { const key = this.makeKey(agentId, tenantId); @@ -100,11 +168,27 @@ export class AgenticTokenCache { } } - /** Clears all cached tokens & registrations. */ + /** + * Clears all cached entries (tokens + metadata) for every key. + */ invalidateAll(): void { this._map.clear(); } + + /** Decode exp from JWT (returns epoch seconds). */ + private decodeExp(jwt: string): number | undefined { + try { + if (!jwt) { return undefined; } + const parts = jwt.split('.'); + if (parts.length < 2) { return undefined; } + const payload = parts[1] + '='.repeat((4 - (parts[1].length % 4)) % 4); // base64 padding + const json = JSON.parse(Buffer.from(payload, 'base64').toString('utf8')) as { exp?: unknown }; + return typeof json.exp === 'number' ? json.exp : undefined; + } catch { + return undefined; + } + } private isExpired(entry: CacheEntry): boolean { if (!entry.expiresOn) { return false; // If we don't have expiration metadata, assume still valid @@ -113,37 +197,24 @@ export class AgenticTokenCache { return now >= (entry.expiresOn - this._defaultRefreshSkewMs); // Refresh early by skew } - private async exchangeToken(entry: CacheEntry): Promise { - logger.info('AgenticTokenCache: Exchanging token via Authorization.exchangeToken...'); - if(!entry.authorization) { - throw new Error('Authorization instance not set.'); - } - try { - const tokenResponse = await entry.authorization.exchangeToken( - entry.turnContext, - 'agentic', - { scopes: entry.scopes } - ); - if (!tokenResponse?.token) { - logger.error('AgenticTokenCache: Token exchange returned undefined token'); - return null; - } - const expiresOn = (tokenResponse as { expiresOn?: number | Date | string }).expiresOn; - if (expiresOn instanceof Date) { - entry.expiresOn = expiresOn.getTime(); - } else if (typeof expiresOn === 'number') { - entry.expiresOn = expiresOn; - } else if (typeof expiresOn === 'string') { - const parsed = Date.parse(expiresOn); - if (!isNaN(parsed)) { - entry.expiresOn = parsed; - } - } - return tokenResponse.token; - } catch (e) { - logger.error('AgenticTokenCache: Token exchange failed with', formatError(e)); - return null; // Silent failure + /** Basic transient error classification for retry logic */ + private isRetriableError(err: unknown): boolean { + const e = err as { code?: string; status?: number; message?: string } | undefined; + if (!e) return false; + // Network / timeout style codes + const msg = (e.message || '').toLowerCase(); + if (msg.includes('timeout') || msg.includes('ecconnreset') || msg.includes('network')) return true; + // HTTP status heuristics + if (typeof e.status === 'number') { + if (e.status === 408 || e.status === 429) return true; + if (e.status >= 500 && e.status < 600) return true; } + return false; + } + + /** Simple sleep helper for retry backoff */ + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); } private async withKeyLock(key: string, fn: () => Promise): Promise { @@ -159,41 +230,14 @@ export class AgenticTokenCache { this._keyLocks.set(key, currentPromise); return currentPromise; } - - /** - * Creates a shallow clone of the TurnContext preserving activity and services. - * Falls back gracefully if a native clone() exists. - */ - private cloneTurnContext(ctx: TurnContext): TurnContext { - // Prefer native implementation if available - const possibleClone = (ctx as unknown as { clone?: () => TurnContext }).clone; - if (typeof possibleClone === 'function') { - try { - return possibleClone.call(ctx); - } catch { - // Ignore and fallback - } - } - - // Derive a typed helper interface to avoid 'any' - interface TurnContextLike { - activity: Record; - [key: string]: unknown; - } - const original = ctx as unknown as TurnContextLike; - - const proto = Object.getPrototypeOf(ctx); - const shallow: TurnContextLike = Object.create(proto); - for (const k of Object.keys(original)) { - shallow[k] = original[k]; - } - // Shallow copy activity object - shallow.activity = { ...original.activity }; - return shallow as unknown as TurnContext; - } } -// Helper for external callers to build a cache key if needed +/** + * Helper for external callers to build a cache key string (agentId:tenantId) consistent with internal usage. + * @param agentId Agent identifier. + * @param tenantId Tenant identifier. + * @returns Combined cache key string in format "agentId:tenantId". + */ export function createAgenticTokenCacheKey(agentId: string, tenantId: string): string { return `${agentId}:${tenantId}`; } diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index 45f672c7..51c7c6c4 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -55,7 +55,7 @@ describe('Agent365Exporter', () => { jest.clearAllTimers(); jest.useRealTimers(); global.fetch = originalFetch; - AgenticTokenCacheInstance.clear(); + AgenticTokenCacheInstance.invalidateAll(); }); it('returns success immediately with no spans', async () => { @@ -102,11 +102,15 @@ describe('Agent365Exporter', () => { it('falls back to AgenticTokenCache when no custom resolver provided', async () => { mockFetchSequence([200]); - // Preload cache + // Preload cache via RefreshObservabilityToken API const tenant = tenantId; const agent = agentId; - const key = AgenticTokenCacheInstance.createCacheKey(agent, tenant); - AgenticTokenCacheInstance.set(key, 'cached-token'); + const ctx = { activity: { id: 'x' } } as any; // minimal TurnContext stub + const auth = { exchangeToken: async () => ({ token: 'cached-token' }) } as any; // Authorization stub + await AgenticTokenCacheInstance.RefreshObservabilityToken(agent, tenant, ctx, auth, ['scope.read']); + + // Spy on getObservabilityToken to assert fallback path uses cache retrieval + const getTokenSpy = jest.spyOn(AgenticTokenCacheInstance as any, 'getObservabilityToken'); const opts = new Agent365ExporterOptions(); opts.clusterCategory = 'local'; // no tokenResolver assigned -> fallback to cache @@ -120,6 +124,9 @@ describe('Agent365Exporter', () => { const callback = jest.fn(); await exporter.export(spans, callback); expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + expect(getTokenSpy).toHaveBeenCalledTimes(1); + expect(getTokenSpy.mock.calls[0][0]).toBe(agent); + expect(getTokenSpy.mock.calls[0][1]).toBe(tenant); const fetchCalls = (global.fetch as unknown as { mock: { calls: any[] } }).mock.calls; expect(fetchCalls.length).toBe(1); const headersArg = fetchCalls[0][1].headers; diff --git a/tests/observability/core/agentic-token-cache.test.ts b/tests/observability/core/agentic-token-cache.test.ts new file mode 100644 index 00000000..6640567d --- /dev/null +++ b/tests/observability/core/agentic-token-cache.test.ts @@ -0,0 +1,137 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------------------------ + +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability/src/utils/AgenticTokenCache'; +import type { Authorization, TurnContext } from '@microsoft/agents-hosting'; + +// Minimal stubs +const makeTurnContext = (): TurnContext => ({ activity: { id: 'a1' } } as unknown as TurnContext); + +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`; // signature value irrelevant for our decoder +} + +function makeAuthorizationMock(sequence: Array<{ token?: string; error?: any }>): Authorization { + let call = 0; + const authLike = { + exchangeToken: async () => { + const current = sequence[Math.min(call, sequence.length - 1)]; + call++; + if (current.error) { + throw current.error; + } + return { token: current.token } as any; + }, + // Unused members stubbed to satisfy Authorization interface typing expectations. + getToken: async () => undefined, + signOut: async () => {}, + onSignInSuccess: () => {}, + onSignInFailure: () => {} + } as unknown as Authorization; + return authLike; +} + +// Silence logger noise in tests by mocking logger's methods if available +jest.mock('@microsoft/agents-a365-observability/src/utils/logging', () => { + const orig: any = jest.requireActual('@microsoft/agents-a365-observability/src/utils/logging'); + return { + __esModule: true, + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + }, + formatError: orig.formatError || ((e: unknown) => String(e)) + }; +}); + +describe('AgenticTokenCacheInstance', () => { + beforeEach(() => { + AgenticTokenCacheInstance.invalidateAll(); + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns null when no entry exists', () => { + const token = AgenticTokenCacheInstance.getObservabilityToken('agentX', 'tenantY'); + expect(token).toBeNull(); + }); + + it('exchanges and caches token on first call', async () => { + const token = makeJwtWithExp(300); + const auth = makeAuthorizationMock([{ token }]); + await AgenticTokenCacheInstance.RefreshObservabilityToken('agentA', 'tenantA', makeTurnContext(), auth, ['scope.read']); + const tokenReturned = AgenticTokenCacheInstance.getObservabilityToken('agentA', 'tenantA'); + expect(tokenReturned).not.toBeNull(); + expect(tokenReturned).toBe(token); + }); + + it('retries on retriable error then succeeds', async () => { + const token = makeJwtWithExp(300); + const retriableErr = { status: 500, message: 'server error' }; + const sequence: Array<{ token?: string; error?: any }> = [ + { 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 } as any; + }); + const auth = { + exchangeToken: exchangeFn, + getToken: async () => undefined, + signOut: async () => {}, + onSignInSuccess: () => {}, + onSignInFailure: () => {} + } as unknown as Authorization; + const p = AgenticTokenCacheInstance.RefreshObservabilityToken('agentB', 'tenantB', makeTurnContext(), auth, ['scope.read']); + // Fast-forward timers to allow retry backoff sleeps (200ms + 400ms linear) + await jest.advanceTimersByTimeAsync(1000); + await p; + const tokenReturned = AgenticTokenCacheInstance.getObservabilityToken('agentB', 'tenantB'); + expect(tokenReturned).not.toBeNull(); + expect(tokenReturned).toBe(token); + expect(exchangeFn).toHaveBeenCalledTimes(2); + }); + + 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', makeTurnContext(), auth, ['scope.read']); + const token = AgenticTokenCacheInstance.getObservabilityToken('agentC', 'tenantC'); + expect(token).toBeNull(); + }); + + it('treats near-expiry token as expired (skew refresh)', async () => { + // exp in 30s, skew is 60s -> considered expired immediately + const auth = makeAuthorizationMock([{ token: makeJwtWithExp(30) }]); + await AgenticTokenCacheInstance.RefreshObservabilityToken('agentD', 'tenantD', makeTurnContext(), auth, ['scope.read']); + const token = AgenticTokenCacheInstance.getObservabilityToken('agentD', 'tenantD'); + expect(token).toBeNull(); // because isExpired returned true and retrieval logs expiration + }); + + it('returns cached token before expiry then invalid after advancing time', async () => { + const auth = makeAuthorizationMock([{ token: makeJwtWithExp(120) }]); // 2 minutes + await AgenticTokenCacheInstance.RefreshObservabilityToken('agentE', 'tenantE', makeTurnContext(), auth, ['scope.read']); + const tokenBefore = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE'); + expect(tokenBefore).not.toBeNull(); + // Advance time just before skew boundary (expire - skew + 1000ms) + jest.advanceTimersByTime(61_000); // move forward > skew (60s) so token becomes expired + const tokenAfter = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE'); + expect(tokenAfter).toBeNull(); + }); +}); From fa9534932ca1d87a9717a70f1c6e6d95268aa6be Mon Sep 17 00:00:00 2001 From: jsl517 Date: Thu, 13 Nov 2025 16:24:12 -0800 Subject: [PATCH 08/26] sample update --- .../basic-agent-sdk-sample/src/agent.ts | 23 ++++++++++++++++--- .../basic-agent-sdk-sample/src/telemetry.ts | 2 +- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tests-agent/basic-agent-sdk-sample/src/agent.ts b/tests-agent/basic-agent-sdk-sample/src/agent.ts index 359e5afb..d24e8b95 100644 --- a/tests-agent/basic-agent-sdk-sample/src/agent.ts +++ b/tests-agent/basic-agent-sdk-sample/src/agent.ts @@ -87,9 +87,26 @@ agentApplication.onActivity( type: 'typing', })); - - AgenticTokenCacheInstance.registerObservability(agentInfo.agentId, tenantInfo.tenantId, context, agentApplication.authorization, getObservabilityAuthenticationScope()); - + /* + // Cache the agentic token for observability token resolver, only needed for custom token resolver example + // const aauToken = await agentApplication.authorization.exchangeToken(context, ['https://api.powerplatform.com/.default'],'agentic') + const aauToken = await agentApplication.authorization.exchangeToken(context,'agentic', { + scopes: getObservabilityAuthenticationScope() + } ) + const cacheKey = createAgenticTokenCacheKey(agentInfo.agentId, tenantInfo.tenantId); + tokenCache.set(cacheKey, aauToken?.token || ''); + */ + + // Preload/refresh the observability token into the shared AgenticTokenCache. Comment the code out if using custom token resolver example. + // We don't immediately need the token here, and if acquisition fails we continue (non-fatal for this demo sample). + await AgenticTokenCacheInstance.RefreshObservabilityToken( + agentInfo.agentId, + tenantInfo.tenantId, + context, + agentApplication.authorization, + getObservabilityAuthenticationScope() + ); + const llmResponse = await performInference( context.activity.text ?? 'Unknown text', context diff --git a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts index 679cceeb..8936d193 100644 --- a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts +++ b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts @@ -37,7 +37,7 @@ export const a365Observability = ObservabilityManager.configure( (builder: Builder) => builder .withService('TypeScript Sample Agent', '1.0.0') - //.withTokenResolver(tokenResolver) + //.withTokenResolver(tokenResolver) // specify a custom token resolver. When the custom token resolver is not specified, the default resolver AgenticTokenCache will be used. .withClusterCategory(getClusterCategory()) ); From 1c3940ef9838e3d4f7f892475db31007c26c1f0d Mon Sep 17 00:00:00 2001 From: jsl517 Date: Thu, 13 Nov 2025 17:28:46 -0800 Subject: [PATCH 09/26] comments --- .../src/ObservabilityBuilder.ts | 3 +- .../src/tracing/exporter/Agent365Exporter.ts | 7 +-- .../exporter/Agent365ExporterOptions.ts | 52 ++++++++++--------- .../src/utils/AgenticTokenCache.ts | 3 +- .../basic-agent-sdk-sample/src/agent.ts | 40 +++++++------- .../basic-agent-sdk-sample/src/index.ts | 20 ++++--- .../basic-agent-sdk-sample/src/telemetry.ts | 17 +++--- 7 files changed, 70 insertions(+), 72 deletions(-) diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index 4101478a..f82391a2 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -6,7 +6,8 @@ import { NodeSDK } from '@opentelemetry/sdk-node'; import { ConsoleSpanExporter, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { SpanProcessor } from './tracing/processors/SpanProcessor'; import { isAgent365ExporterEnabled } from './tracing/util'; -import { Agent365Exporter, TokenResolver } from './tracing/exporter/Agent365Exporter'; +import { Agent365Exporter } from './tracing/exporter/Agent365Exporter'; +import type { TokenResolver } from './tracing/exporter/Agent365ExporterOptions'; import { Agent365ExporterOptions } from './tracing/exporter/Agent365ExporterOptions'; import { resourceFromAttributes } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index 4b3d9ab8..140bf797 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -11,7 +11,6 @@ import { partitionByIdentity, parseIdentityKey, hexTraceId, hexSpanId, kindName, import logger, { formatError } from '../../utils/logging'; import { AgenticTokenCacheInstance } from '../../utils/AgenticTokenCache'; import { Agent365ExporterOptions } from './Agent365ExporterOptions'; - const DEFAULT_HTTP_TIMEOUT_SECONDS = 30000; // 30 seconds in ms const DEFAULT_MAX_RETRIES = 3; @@ -65,10 +64,6 @@ interface OTLPStatus { message?: string; } -/** - * Token resolver function type - supports both sync and async implementations - */ -export type TokenResolver = (agentId: string, tenantId: string) => string | null | Promise; /** * Observability span exporter for Agent365: @@ -161,7 +156,7 @@ export class Agent365Exporter implements SpanExporter { 'content-type': 'application/json' }; - const tokenResult = this.options.tokenResolver!(agentId, tenantId,); + const tokenResult = this.options.tokenResolver!(agentId, tenantId); const token = tokenResult instanceof Promise ? await tokenResult : tokenResult; if (token) { headers['authorization'] = `Bearer ${token}`; diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index 4eabaad4..70f91b3b 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -4,46 +4,48 @@ // ------------------------------------------------------------------------------ import { ClusterCategory } from '@microsoft/agents-a365-runtime'; -import { TokenResolver } from './Agent365Exporter'; +/** + * A function that resolves and returns an authentication token for the given agent and tenant. + * 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. + */ +export type TokenResolver = (agentId: string, tenantId: string) => string | null | Promise; /** - * Configuration for Agent365Exporter. - * Only ClusterCategory and TokenResolver are required for core operation. + * Options controlling the behavior of the Agent365 OpenTelemetry span exporter. + * + * These values tune batching, timeouts, token acquisition and endpoint shape. All properties have sensible + * defaults so callers can usually construct without arguments and override selectively. + * + * @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. "preprod", "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 uses service-to-service path (/maven/agent365/service/agents/{agentId}/traces); + * when false uses the standard path (/maven/agent365/agents/{agentId}/traces). + * @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 Per-export timeout (abort if exceeded). + * @property {number} maxExportBatchSize Maximum number of spans per export batch. */ export class Agent365ExporterOptions { - /** - * Environment / cluster category - */ + /** Environment / cluster category (e.g. "preprod", "prod"). */ public clusterCategory: ClusterCategory | string = 'preprod'; - /** - * Resolver used to resolve the auth token. Optional - falls back to AgenticTokenCache when not provided. - */ + /** Optional delegate to resolve auth token; falls back to AgenticTokenCache when absent. */ public tokenResolver?: TokenResolver; - /** - * When true, uses the service-to-service (S2S) endpoint path: /maven/agent365/service/agents/{agentId}/traces - * When false (default), uses the standard endpoint path: /maven/agent365/agents/{agentId}/traces - */ + /** Use service-to-service endpoint variant when true; standard endpoint when false. */ public useS2SEndpoint: boolean = false; - /** - * Maximum queue size for the batch processor. - */ + /** Maximum span queue size before new spans are dropped. */ public maxQueueSize: number = 2048; - /** - * Delay in milliseconds between export batches. - */ + /** Delay (ms) between automatic batch flush attempts. */ public scheduledDelayMilliseconds: number = 5000; - /** - * Timeout in milliseconds for the export operation. - */ + /** Per-export timeout in milliseconds. */ public exporterTimeoutMilliseconds: number = 30000; - /** - * Maximum batch size for export operations. - */ + /** Maximum number of spans per export batch. */ public maxExportBatchSize: number = 512; } diff --git a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts index 703339d0..aa1bc37d 100644 --- a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts +++ b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts @@ -113,7 +113,7 @@ class AgenticTokenCache { try { const tokenResponse = await authorization.exchangeToken( turnContext, - 'agentic1', + 'agentic', { scopes: entry.scopes } ); if (!tokenResponse?.token) { @@ -242,6 +242,5 @@ export function createAgenticTokenCacheKey(agentId: string, tenantId: string): s return `${agentId}:${tenantId}`; } -export type TokenResolver = (agentId: string, tenantId: string) => string | null | Promise; export const AgenticTokenCacheInstance = new AgenticTokenCache(); diff --git a/tests-agent/basic-agent-sdk-sample/src/agent.ts b/tests-agent/basic-agent-sdk-sample/src/agent.ts index d24e8b95..e93eadd4 100644 --- a/tests-agent/basic-agent-sdk-sample/src/agent.ts +++ b/tests-agent/basic-agent-sdk-sample/src/agent.ts @@ -86,26 +86,26 @@ agentApplication.onActivity( await context.sendActivity(Activity.fromObject({ type: 'typing', })); - - /* - // Cache the agentic token for observability token resolver, only needed for custom token resolver example - // const aauToken = await agentApplication.authorization.exchangeToken(context, ['https://api.powerplatform.com/.default'],'agentic') - const aauToken = await agentApplication.authorization.exchangeToken(context,'agentic', { - scopes: getObservabilityAuthenticationScope() - } ) - const cacheKey = createAgenticTokenCacheKey(agentInfo.agentId, tenantInfo.tenantId); - tokenCache.set(cacheKey, aauToken?.token || ''); - */ - - // Preload/refresh the observability token into the shared AgenticTokenCache. Comment the code out if using custom token resolver example. - // We don't immediately need the token here, and if acquisition fails we continue (non-fatal for this demo sample). - await AgenticTokenCacheInstance.RefreshObservabilityToken( - agentInfo.agentId, - tenantInfo.tenantId, - context, - agentApplication.authorization, - getObservabilityAuthenticationScope() - ); + + // Set Use_Custom_Resolver === 'true' to use custom tokenResolver and custom token cache(see example in telemetry.ts and token-cache.ts) + // Otherwise: we use the default AgenticTokenCache RefreshObservabilityToken path. + if (process.env.Use_Custom_Resolver === 'true') { + const aauToken = await agentApplication.authorization.exchangeToken(context,'agentic', { + scopes: getObservabilityAuthenticationScope() + }); + const cacheKey = createAgenticTokenCacheKey(agentInfo.agentId, tenantInfo.tenantId); + tokenCache.set(cacheKey, aauToken?.token || ''); + } else { + // Preload/refresh the observability token into the shared AgenticTokenCache. + // We don't immediately need the token here, and if acquisition fails we continue (non-fatal for this demo sample). + await AgenticTokenCacheInstance.RefreshObservabilityToken( + agentInfo.agentId, + tenantInfo.tenantId, + context, + agentApplication.authorization, + getObservabilityAuthenticationScope() + ); + } const llmResponse = await performInference( context.activity.text ?? 'Unknown text', diff --git a/tests-agent/basic-agent-sdk-sample/src/index.ts b/tests-agent/basic-agent-sdk-sample/src/index.ts index a78807a4..7e5b0478 100644 --- a/tests-agent/basic-agent-sdk-sample/src/index.ts +++ b/tests-agent/basic-agent-sdk-sample/src/index.ts @@ -19,17 +19,15 @@ a365Observability.start(); // Mock authentication middleware for development // This is only required when running from agents playground try { -app.use((req: Request, res: Response, next: NextFunction) => { - // Create a mock identity when JWT is disabled - req.user = { - aud: authConfig.clientId || 'mock-client-id', - appid: authConfig.clientId || 'mock-client-id', - azp: authConfig.clientId || 'mock-client-id' - } - next() -}) - -//app.use(authorizeJWT(authConfig)); + app.use((req: Request, res: Response, next: NextFunction) => { + // Create a mock identity when JWT is disabled + req.user = { + aud: authConfig.clientId || 'mock-client-id', + appid: authConfig.clientId || 'mock-client-id', + azp: authConfig.clientId || 'mock-client-id' + }; + next(); + }); } catch (err) { console.warn('Skipping mock authentication middleware:', err); } diff --git a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts index 8936d193..85bf7a90 100644 --- a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts +++ b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts @@ -33,12 +33,15 @@ const getClusterCategory = (): ClusterCategory => { return 'dev' as ClusterCategory; // Safe fallback }; -export const a365Observability = ObservabilityManager.configure( - (builder: Builder) => - builder - .withService('TypeScript Sample Agent', '1.0.0') - //.withTokenResolver(tokenResolver) // specify a custom token resolver. When the custom token resolver is not specified, the default resolver AgenticTokenCache will be used. - .withClusterCategory(getClusterCategory()) -); +// Configure observability builder (conditionally adding token resolver based on env flag) +export const a365Observability = ObservabilityManager.configure((builder: Builder) => { + builder + .withService('TypeScript Sample Agent', '1.0.0') + .withClusterCategory(getClusterCategory()); + // Opt-in custom token resolver via env flag `Use_Custom_Resolver=true` + if (process.env.Use_Custom_Resolver === 'true') { + builder.withTokenResolver(tokenResolver); + } +}); From 36c59668d01879e747df2fce14b6b5e91e0c6051 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Thu, 13 Nov 2025 18:51:17 -0800 Subject: [PATCH 10/26] comments --- .../src/tracing/exporter/Agent365Exporter.ts | 6 +- .../src/utils/AgenticTokenCache.ts | 43 ++++++-- .../basic-agent-sdk-sample/src/agent.ts | 104 +++++++++--------- .../basic-agent-sdk-sample/src/index.ts | 26 ++--- .../core/agentic-token-cache.test.ts | 12 +- 5 files changed, 109 insertions(+), 82 deletions(-) diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index 140bf797..8414cf0f 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -156,7 +156,11 @@ export class Agent365Exporter implements SpanExporter { 'content-type': 'application/json' }; - const tokenResult = this.options.tokenResolver!(agentId, tenantId); + if (!this.options.tokenResolver) { + logger.error('[Agent365Exporter] tokenResolver is undefined, skip exporting'); + return; + } + const tokenResult = this.options.tokenResolver(agentId, tenantId); const token = tokenResult instanceof Promise ? await tokenResult : tokenResult; if (token) { headers['authorization'] = `Bearer ${token}`; diff --git a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts index aa1bc37d..10eec7d5 100644 --- a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts +++ b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts @@ -10,8 +10,9 @@ import logger, { formatError } from './logging'; // Structure stored per key interface CacheEntry { scopes: string[]; - token?: string; // Cached token value - expiresOn?: number; // Expiration epoch millis (if provided by exchange response) + token?: string; // Cached token value + expiresOn?: number; // Expiration epoch millis (if provided by exchange response) + acquiredOn?: number; // Epoch millis when token was acquired (fallback TTL when expiresOn missing) } /** @@ -22,6 +23,7 @@ interface CacheEntry { * - Retries transient exchange failures (network / HTTP 408, 429, 5xx) with linear backoff (200ms, 400ms). * - Serializes write/exchange operations per key with a Promise chain (withKeyLock) to avoid duplicate exchanges. * - Provides synchronous read access (getObservabilityToken) that never triggers network IO. + * - Applies a fallback max age (1h) for tokens that lack embedded expiration metadata (exp claim). * * Thread Safety: * Per-key serialization ensures at most one exchange updates a given entry concurrently. Reads are lock‑free. @@ -32,6 +34,7 @@ interface CacheEntry { class AgenticTokenCache { private readonly _map = new Map(); private readonly _defaultRefreshSkewMs = 60_000; // refresh 60s before expiry + private readonly _defaultMaxTokenAgeMs = 3_600_000; // 1 hour fallback TTL if exp not provided // Per-key promise chain to serialize mutations & exchanges private readonly _keyLocks = new Map>(); private makeKey(agentId: string, tenantId: string): string { @@ -99,9 +102,19 @@ class AgenticTokenCache { let entry = this._map.get(key); if (!entry) { const effectiveScopes = (scopes && scopes.length > 0) ? scopes : getObservabilityAuthenticationScope(); + if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) { + logger.error('[AgenticTokenCache] Cannot exchange token. No valid scopes provided or available from fallback.'); + return; // abort early; entry not created + } entry = { scopes: effectiveScopes }; this._map.set(key, entry); } + + // Validate existing entry scopes (in case previously created with empty array before fix) + if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) { + logger.error('[AgenticTokenCache] Cannot exchange token. Cache entry has invalid/empty scopes.'); + return; + } try { if (entry.token && !this.isExpired(entry)) { return; @@ -124,9 +137,13 @@ class AgenticTokenCache { break; } entry.token = tokenResponse.token; + entry.acquiredOn = Date.now(); const oboExp = this.decodeExp(entry.token); if (oboExp) { entry.expiresOn = oboExp * 1000; // to epoch millisecond + } else { + // No exp claim present; will rely on fallback TTL. + logger.warn('[AgenticTokenCache] Token has no exp claim. Applying fallback TTL (1h).'); } logger.info('[AgenticTokenCache] Token exchange successful and cached.'); // success @@ -190,11 +207,16 @@ class AgenticTokenCache { } } private isExpired(entry: CacheEntry): boolean { - if (!entry.expiresOn) { - return false; // If we don't have expiration metadata, assume still valid - } const now = Date.now(); - return now >= (entry.expiresOn - this._defaultRefreshSkewMs); // Refresh early by skew + if (entry.expiresOn) { + return now >= (entry.expiresOn - this._defaultRefreshSkewMs); // Refresh early by skew + } + // Fallback: if no explicit expiration, treat as expired after max age. + if (entry.acquiredOn) { + return now >= (entry.acquiredOn + this._defaultMaxTokenAgeMs); + } + // No timing metadata at all; force refresh immediately. + return true; } /** Basic transient error classification for retry logic */ @@ -203,7 +225,7 @@ class AgenticTokenCache { if (!e) return false; // Network / timeout style codes const msg = (e.message || '').toLowerCase(); - if (msg.includes('timeout') || msg.includes('ecconnreset') || msg.includes('network')) return true; + if (msg.includes('timeout') || msg.includes('ECONNRESET') || msg.includes('network')) return true; // HTTP status heuristics if (typeof e.status === 'number') { if (e.status === 408 || e.status === 429) return true; @@ -220,7 +242,12 @@ class AgenticTokenCache { private async withKeyLock(key: string, fn: () => Promise): Promise { const previous = this._keyLocks.get(key); if (previous) { - try { await previous; } catch { /* empty */ } + try { + await previous; + } catch (err) { + // Previous locked operation failed; log at warn level for visibility without throwing. + logger.warn(`[AgenticTokenCache] withKeyLock: previous promise for key "${key}" rejected:`, formatError(err)); + } } const currentPromise: Promise = fn().finally(() => { if (this._keyLocks.get(key) === currentPromise) { diff --git a/tests-agent/basic-agent-sdk-sample/src/agent.ts b/tests-agent/basic-agent-sdk-sample/src/agent.ts index e93eadd4..e64af70d 100644 --- a/tests-agent/basic-agent-sdk-sample/src/agent.ts +++ b/tests-agent/basic-agent-sdk-sample/src/agent.ts @@ -34,8 +34,8 @@ const storage = new MemoryStorage(); export const agentApplication = new AgentApplication({ authorization: { - agentic: { } // We have the type and scopes set in the .env file - }, + agentic: {} // We have the type and scopes set in the .env file + }, storage, fileDownloaders: [downloader], }); @@ -75,63 +75,63 @@ agentApplication.onActivity( endpoint: {host:context.activity.serviceUrl, port:56150} as ServiceEndpoint, }; - const invokeAgentScope = InvokeAgentScope.start(invokeAgentDetails, tenantInfo); - - await invokeAgentScope.withActiveSpanAsync(async () => { - // Record input message - invokeAgentScope.recordInputMessages([context.activity.text ?? 'Unknown text']); - - await context.sendActivity(`Preparing a response to your query (message #${state.conversation.count})...`); - - await context.sendActivity(Activity.fromObject({ - type: 'typing', - })); - - // Set Use_Custom_Resolver === 'true' to use custom tokenResolver and custom token cache(see example in telemetry.ts and token-cache.ts) - // Otherwise: we use the default AgenticTokenCache RefreshObservabilityToken path. - if (process.env.Use_Custom_Resolver === 'true') { - const aauToken = await agentApplication.authorization.exchangeToken(context,'agentic', { - scopes: getObservabilityAuthenticationScope() - }); - const cacheKey = createAgenticTokenCacheKey(agentInfo.agentId, tenantInfo.tenantId); - tokenCache.set(cacheKey, aauToken?.token || ''); - } else { - // Preload/refresh the observability token into the shared AgenticTokenCache. - // We don't immediately need the token here, and if acquisition fails we continue (non-fatal for this demo sample). - await AgenticTokenCacheInstance.RefreshObservabilityToken( - agentInfo.agentId, - tenantInfo.tenantId, - context, - agentApplication.authorization, - getObservabilityAuthenticationScope() + const invokeAgentScope = InvokeAgentScope.start(invokeAgentDetails, tenantInfo); + + await invokeAgentScope.withActiveSpanAsync(async () => { + // Record input message + invokeAgentScope.recordInputMessages([context.activity.text ?? 'Unknown text']); + + await context.sendActivity(`Preparing a response to your query (message #${state.conversation.count})...`); + + await context.sendActivity(Activity.fromObject({ + type: 'typing', + })); + + // Set Use_Custom_Resolver === 'true' to use a custom token resolver (see telemetry.ts) and a custom token cache (see token-cache.ts). + // Otherwise: use the default AgenticTokenCache via RefreshObservabilityToken. + if (process.env.Use_Custom_Resolver === 'true') { + const aauToken = await agentApplication.authorization.exchangeToken(context, 'agentic', { + scopes: getObservabilityAuthenticationScope() + }); + const cacheKey = createAgenticTokenCacheKey(agentInfo.agentId, tenantInfo.tenantId); + tokenCache.set(cacheKey, aauToken?.token || ''); + } else { + // Preload/refresh the observability token into the shared AgenticTokenCache. + // We don't immediately need the token here, and if acquisition fails we continue (non-fatal for this demo sample). + await AgenticTokenCacheInstance.RefreshObservabilityToken( + agentInfo.agentId, + tenantInfo.tenantId, + context, + agentApplication.authorization, + getObservabilityAuthenticationScope() + ); + } + + const llmResponse = await performInference( + context.activity.text ?? 'Unknown text', + context ); - } - - const llmResponse = await performInference( - context.activity.text ?? 'Unknown text', - context - ); - await context.sendActivity(`LLM Response: ${llmResponse}`); + await context.sendActivity(`LLM Response: ${llmResponse}`); - await context.sendActivity('Now performing a tool call...'); + await context.sendActivity('Now performing a tool call...'); - await context.sendActivity(Activity.fromObject({ - type: 'typing', - })); + await context.sendActivity(Activity.fromObject({ + type: 'typing', + })); - const toolResponse = await performToolCall(context); + const toolResponse = await performToolCall(context); - await context.sendActivity(`Tool Response: ${toolResponse}`); - - // Record output messages - invokeAgentScope.recordOutputMessages([ - `LLM Response: ${llmResponse}`, - `Tool Response: ${toolResponse}` - ]); - }); + await context.sendActivity(`Tool Response: ${toolResponse}`); + + // Record output messages + invokeAgentScope.recordOutputMessages([ + `LLM Response: ${llmResponse}`, + `Tool Response: ${toolResponse}` + ]); + }); - invokeAgentScope.dispose(); + invokeAgentScope.dispose(); }); // Close the baggage scope run } ); diff --git a/tests-agent/basic-agent-sdk-sample/src/index.ts b/tests-agent/basic-agent-sdk-sample/src/index.ts index 7e5b0478..5c7c8d8f 100644 --- a/tests-agent/basic-agent-sdk-sample/src/index.ts +++ b/tests-agent/basic-agent-sdk-sample/src/index.ts @@ -18,19 +18,15 @@ a365Observability.start(); // Mock authentication middleware for development // This is only required when running from agents playground -try { - app.use((req: Request, res: Response, next: NextFunction) => { - // Create a mock identity when JWT is disabled - req.user = { - aud: authConfig.clientId || 'mock-client-id', - appid: authConfig.clientId || 'mock-client-id', - azp: authConfig.clientId || 'mock-client-id' - }; - next(); - }); -} catch (err) { - console.warn('Skipping mock authentication middleware:', err); -} +app.use((req: Request, res: Response, next: NextFunction) => { + // Create a mock identity when JWT is disabled + req.user = { + aud: authConfig.clientId || 'mock-client-id', + appid: authConfig.clientId || 'mock-client-id', + azp: authConfig.clientId || 'mock-client-id' + }; + next(); +}); app.post('/api/messages', async (req: Request, res: Response) => { try { @@ -65,11 +61,11 @@ const server = app.listen(port, () => { console.log(`\nServer listening to port ${port} for appId ${authConfig.clientId} debug ${process.env.DEBUG}`); }).on('error', async (err: Error) => { console.error(err); - await a365Observability.shutdown(); + await a365Observability.shutdown(); process.exit(1); }).on('close', async () => { console.log('Agent365 observability is shutting down...'); - await a365Observability.shutdown(); + await a365Observability.shutdown(); }); process.on('SIGINT', () => { diff --git a/tests/observability/core/agentic-token-cache.test.ts b/tests/observability/core/agentic-token-cache.test.ts index 6640567d..6a71e788 100644 --- a/tests/observability/core/agentic-token-cache.test.ts +++ b/tests/observability/core/agentic-token-cache.test.ts @@ -3,7 +3,7 @@ // Licensed under the MIT License. // ------------------------------------------------------------------------------ -import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability/src/utils/AgenticTokenCache'; import type { Authorization, TurnContext } from '@microsoft/agents-hosting'; @@ -68,7 +68,7 @@ describe('AgenticTokenCacheInstance', () => { it('exchanges and caches token on first call', async () => { const token = makeJwtWithExp(300); const auth = makeAuthorizationMock([{ token }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken('agentA', 'tenantA', makeTurnContext(), auth, ['scope.read']); + await AgenticTokenCacheInstance.RefreshObservabilityToken('agentA', 'tenantA', makeTurnContext(), auth, ['scope.read']); const tokenReturned = AgenticTokenCacheInstance.getObservabilityToken('agentA', 'tenantA'); expect(tokenReturned).not.toBeNull(); expect(tokenReturned).toBe(token); @@ -95,7 +95,7 @@ describe('AgenticTokenCacheInstance', () => { onSignInSuccess: () => {}, onSignInFailure: () => {} } as unknown as Authorization; - const p = AgenticTokenCacheInstance.RefreshObservabilityToken('agentB', 'tenantB', makeTurnContext(), auth, ['scope.read']); + const p = AgenticTokenCacheInstance.RefreshObservabilityToken('agentB', 'tenantB', makeTurnContext(), auth, ['scope.read']); // Fast-forward timers to allow retry backoff sleeps (200ms + 400ms linear) await jest.advanceTimersByTimeAsync(1000); await p; @@ -111,7 +111,7 @@ describe('AgenticTokenCacheInstance', () => { { error: nonRetriableErr }, { token: makeJwtWithExp(300) } // should not be used ]); - await AgenticTokenCacheInstance.RefreshObservabilityToken('agentC', 'tenantC', makeTurnContext(), auth, ['scope.read']); + await AgenticTokenCacheInstance.RefreshObservabilityToken('agentC', 'tenantC', makeTurnContext(), auth, ['scope.read']); const token = AgenticTokenCacheInstance.getObservabilityToken('agentC', 'tenantC'); expect(token).toBeNull(); }); @@ -119,14 +119,14 @@ describe('AgenticTokenCacheInstance', () => { it('treats near-expiry token as expired (skew refresh)', async () => { // exp in 30s, skew is 60s -> considered expired immediately const auth = makeAuthorizationMock([{ token: makeJwtWithExp(30) }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken('agentD', 'tenantD', makeTurnContext(), auth, ['scope.read']); + await AgenticTokenCacheInstance.RefreshObservabilityToken('agentD', 'tenantD', makeTurnContext(), auth, ['scope.read']); const token = AgenticTokenCacheInstance.getObservabilityToken('agentD', 'tenantD'); expect(token).toBeNull(); // because isExpired returned true and retrieval logs expiration }); it('returns cached token before expiry then invalid after advancing time', async () => { const auth = makeAuthorizationMock([{ token: makeJwtWithExp(120) }]); // 2 minutes - await AgenticTokenCacheInstance.RefreshObservabilityToken('agentE', 'tenantE', makeTurnContext(), auth, ['scope.read']); + await AgenticTokenCacheInstance.RefreshObservabilityToken('agentE', 'tenantE', makeTurnContext(), auth, ['scope.read']); const tokenBefore = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE'); expect(tokenBefore).not.toBeNull(); // Advance time just before skew boundary (expire - skew + 1000ms) From 3d174b344bb903d7ab354255248a5955e11972f4 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 14 Nov 2025 14:18:04 -0800 Subject: [PATCH 11/26] observability always use prod --- .../src/ObservabilityBuilder.ts | 1 - .../src/tracing/exporter/Agent365Exporter.ts | 6 +++--- .../src/tracing/exporter/Agent365ExporterOptions.ts | 4 ---- tests/observability/core/agent365-exporter.test.ts | 12 +++--------- 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index f82391a2..e57799c8 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -74,7 +74,6 @@ export class ObservabilityBuilder { return new BatchSpanProcessor(new ConsoleSpanExporter()); } const opts = new Agent365ExporterOptions(); - opts.clusterCategory = this.options.clusterCategory || 'prod'; if (this.options.tokenResolver) { opts.tokenResolver = this.options.tokenResolver; } diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index 8414cf0f..fef54d36 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -87,9 +87,9 @@ export class Agent365Exporter implements SpanExporter { if (!options.tokenResolver) { options.tokenResolver = AgenticTokenCacheInstance.getObservabilityToken.bind(AgenticTokenCacheInstance); - logger.info('Agent365Exporter initialized with agentic resolver', `clusterCategory=${options.clusterCategory}`); + logger.info('Agent365Exporter initialized with agentic resolver'); } else { - logger.info('Agent365Exporter initialized with custom tokenResolver', `clusterCategory=${options.clusterCategory}`); + logger.info('Agent365Exporter initialized with custom tokenResolver'); } this.options = options; } @@ -147,7 +147,7 @@ export class Agent365Exporter implements SpanExporter { const body = JSON.stringify(payload); // Resolve endpoint + token - const discovery = new PowerPlatformApiDiscovery(this.options.clusterCategory as ClusterCategory); + const discovery = new PowerPlatformApiDiscovery('prod'); const endpoint = discovery.getTenantIslandClusterEndpoint(tenantId); const url = `https://${endpoint}/maven/agent365/agents/${agentId}/traces?api-version=1`; logger.info(`[Agent365Exporter] Resolved endpoint: ${endpoint}`); diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index 70f91b3b..b0f7ec05 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -3,7 +3,6 @@ // Licensed under the MIT License. // ------------------------------------------------------------------------------ -import { ClusterCategory } from '@microsoft/agents-a365-runtime'; /** * A function that resolves and returns an authentication token for the given agent and tenant. * Implementations may perform synchronous lookup (e.g., in-memory cache) or asynchronous network calls. @@ -17,7 +16,6 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null * These values tune batching, timeouts, token acquisition and endpoint shape. All properties have sensible * defaults so callers can usually construct without arguments and override selectively. * - * @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. "preprod", "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 uses service-to-service path (/maven/agent365/service/agents/{agentId}/traces); @@ -28,8 +26,6 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null * @property {number} maxExportBatchSize Maximum number of spans per export batch. */ export class Agent365ExporterOptions { - /** Environment / cluster category (e.g. "preprod", "prod"). */ - public clusterCategory: ClusterCategory | string = 'preprod'; /** Optional delegate to resolve auth token; falls back to AgenticTokenCache when absent. */ public tokenResolver?: TokenResolver; diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index 51c7c6c4..b56cf6ac 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -59,8 +59,7 @@ describe('Agent365Exporter', () => { }); it('returns success immediately with no spans', async () => { - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'local'; + const opts = new Agent365ExporterOptions(); opts.tokenResolver = () => null; const exporter = new Agent365Exporter(opts); const callback = jest.fn(); @@ -71,8 +70,7 @@ describe('Agent365Exporter', () => { it('uses provided token resolver and sets authorization header', async () => { const token = 'abc123'; mockFetchSequence([200]); - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'local'; + const opts = new Agent365ExporterOptions(); opts.tokenResolver = () => token; const exporter = new Agent365Exporter(opts); @@ -112,8 +110,7 @@ describe('Agent365Exporter', () => { // Spy on getObservabilityToken to assert fallback path uses cache retrieval const getTokenSpy = jest.spyOn(AgenticTokenCacheInstance as any, 'getObservabilityToken'); - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'local'; // no tokenResolver assigned -> fallback to cache + const opts = new Agent365ExporterOptions(); // no tokenResolver assigned -> fallback to cache const exporter = new Agent365Exporter(opts); // no resolver provided const spans = [ makeSpan({ @@ -151,8 +148,5 @@ describe('Agent365Exporter', () => { expect(span.attributes).toBeDefined(); expect(span.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(tenant); expect(span.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(agent); - // Validate local cluster endpoint format - const urlArg = fetchCalls[0][0] as string; - expect(urlArg).toContain('localhost'); }); }); From 7271de5af02715c3a2e58a0dcdcb75b3ce569e7d Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 14 Nov 2025 14:36:48 -0800 Subject: [PATCH 12/26] should be lowercase --- .../agents-a365-observability/src/utils/AgenticTokenCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts index 10eec7d5..93487d72 100644 --- a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts +++ b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts @@ -225,7 +225,7 @@ class AgenticTokenCache { if (!e) return false; // Network / timeout style codes const msg = (e.message || '').toLowerCase(); - if (msg.includes('timeout') || msg.includes('ECONNRESET') || msg.includes('network')) return true; + if (msg.includes('timeout') || msg.includes('econnreset') || msg.includes('network')) return true; // HTTP status heuristics if (typeof e.status === 'number') { if (e.status === 408 || e.status === 429) return true; From ea0cfc63107b0f4a5a00468a61734850fc0ebab5 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 14 Nov 2025 17:14:24 -0800 Subject: [PATCH 13/26] Revert "observability always use prod" This reverts commit 3d174b344bb903d7ab354255248a5955e11972f4. --- .../src/ObservabilityBuilder.ts | 1 + .../src/tracing/exporter/Agent365Exporter.ts | 6 +++--- .../src/tracing/exporter/Agent365ExporterOptions.ts | 4 ++++ tests/observability/core/agent365-exporter.test.ts | 12 +++++++++--- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index 82876eb7..48f1af36 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -74,6 +74,7 @@ export class ObservabilityBuilder { return new BatchSpanProcessor(new ConsoleSpanExporter()); } const opts = new Agent365ExporterOptions(); + opts.clusterCategory = this.options.clusterCategory || 'prod'; if (this.options.tokenResolver) { opts.tokenResolver = this.options.tokenResolver; } diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index fef54d36..8414cf0f 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -87,9 +87,9 @@ export class Agent365Exporter implements SpanExporter { if (!options.tokenResolver) { options.tokenResolver = AgenticTokenCacheInstance.getObservabilityToken.bind(AgenticTokenCacheInstance); - logger.info('Agent365Exporter initialized with agentic resolver'); + logger.info('Agent365Exporter initialized with agentic resolver', `clusterCategory=${options.clusterCategory}`); } else { - logger.info('Agent365Exporter initialized with custom tokenResolver'); + logger.info('Agent365Exporter initialized with custom tokenResolver', `clusterCategory=${options.clusterCategory}`); } this.options = options; } @@ -147,7 +147,7 @@ export class Agent365Exporter implements SpanExporter { const body = JSON.stringify(payload); // Resolve endpoint + token - const discovery = new PowerPlatformApiDiscovery('prod'); + const discovery = new PowerPlatformApiDiscovery(this.options.clusterCategory as ClusterCategory); const endpoint = discovery.getTenantIslandClusterEndpoint(tenantId); const url = `https://${endpoint}/maven/agent365/agents/${agentId}/traces?api-version=1`; logger.info(`[Agent365Exporter] Resolved endpoint: ${endpoint}`); diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index b0f7ec05..70f91b3b 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -3,6 +3,7 @@ // Licensed under the MIT License. // ------------------------------------------------------------------------------ +import { ClusterCategory } from '@microsoft/agents-a365-runtime'; /** * A function that resolves and returns an authentication token for the given agent and tenant. * Implementations may perform synchronous lookup (e.g., in-memory cache) or asynchronous network calls. @@ -16,6 +17,7 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null * These values tune batching, timeouts, token acquisition and endpoint shape. All properties have sensible * defaults so callers can usually construct without arguments and override selectively. * + * @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. "preprod", "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 uses service-to-service path (/maven/agent365/service/agents/{agentId}/traces); @@ -26,6 +28,8 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null * @property {number} maxExportBatchSize Maximum number of spans per export batch. */ export class Agent365ExporterOptions { + /** Environment / cluster category (e.g. "preprod", "prod"). */ + public clusterCategory: ClusterCategory | string = 'preprod'; /** Optional delegate to resolve auth token; falls back to AgenticTokenCache when absent. */ public tokenResolver?: TokenResolver; diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index b56cf6ac..51c7c6c4 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -59,7 +59,8 @@ describe('Agent365Exporter', () => { }); it('returns success immediately with no spans', async () => { - const opts = new Agent365ExporterOptions(); + const opts = new Agent365ExporterOptions(); + opts.clusterCategory = 'local'; opts.tokenResolver = () => null; const exporter = new Agent365Exporter(opts); const callback = jest.fn(); @@ -70,7 +71,8 @@ describe('Agent365Exporter', () => { it('uses provided token resolver and sets authorization header', async () => { const token = 'abc123'; mockFetchSequence([200]); - const opts = new Agent365ExporterOptions(); + const opts = new Agent365ExporterOptions(); + opts.clusterCategory = 'local'; opts.tokenResolver = () => token; const exporter = new Agent365Exporter(opts); @@ -110,7 +112,8 @@ describe('Agent365Exporter', () => { // Spy on getObservabilityToken to assert fallback path uses cache retrieval const getTokenSpy = jest.spyOn(AgenticTokenCacheInstance as any, 'getObservabilityToken'); - const opts = new Agent365ExporterOptions(); // no tokenResolver assigned -> fallback to cache + const opts = new Agent365ExporterOptions(); + opts.clusterCategory = 'local'; // no tokenResolver assigned -> fallback to cache const exporter = new Agent365Exporter(opts); // no resolver provided const spans = [ makeSpan({ @@ -148,5 +151,8 @@ describe('Agent365Exporter', () => { expect(span.attributes).toBeDefined(); expect(span.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(tenant); expect(span.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(agent); + // Validate local cluster endpoint format + const urlArg = fetchCalls[0][0] as string; + expect(urlArg).toContain('localhost'); }); }); From 64728ca03f41d9de543f9d30dcf2c0f268a39599 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 14 Nov 2025 17:26:48 -0800 Subject: [PATCH 14/26] default to prod --- .../src/tracing/exporter/Agent365ExporterOptions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index 70f91b3b..6bb5d783 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -17,7 +17,7 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null * These values tune batching, timeouts, token acquisition and endpoint shape. All properties have sensible * defaults so callers can usually construct without arguments and override selectively. * - * @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. "preprod", "prod"). + * @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. "preprod", "prod", default to "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 uses service-to-service path (/maven/agent365/service/agents/{agentId}/traces); @@ -29,7 +29,7 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null */ export class Agent365ExporterOptions { /** Environment / cluster category (e.g. "preprod", "prod"). */ - public clusterCategory: ClusterCategory | string = 'preprod'; + public clusterCategory: ClusterCategory | string = 'prod'; /** Optional delegate to resolve auth token; falls back to AgenticTokenCache when absent. */ public tokenResolver?: TokenResolver; From f1d9e618655bca5e784686f47ba61941c003a927 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Mon, 17 Nov 2025 16:16:43 -0800 Subject: [PATCH 15/26] expose Agent365ExporterOptions --- .../src/ObservabilityBuilder.ts | 29 ++++++- .../core/observabilityBuilder-options.test.ts | 77 +++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 tests/observability/core/observabilityBuilder-options.test.ts diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index 48f1af36..48db7ff5 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -26,6 +26,14 @@ export interface BuilderOptions { tokenResolver?: TokenResolver; /** Environment / cluster category (e.g., "preprod", "prod"). */ clusterCategory?: ClusterCategory; + /** + * Optional partial set of exporter options allowing agent developers to customize. + * Any values omitted will fall back to the defaults defined in Agent365ExporterOptions. + * Values provided here take precedence over those inferred from other builder methods (except that an + * explicitly provided tokenResolver / clusterCategory via dedicated builder + * methods will override this object). + */ + exporterOptions?: Partial; } @@ -69,12 +77,30 @@ export class ObservabilityBuilder { return this; } + /** + * Provide a partial set of Agent365ExporterOptions. These will be merged with + * defaults and any explicitly configured clusterCategory/tokenResolver. + * @param exporterOptions Partial exporter options + * @returns The builder instance for chaining + */ + public withExporterOptions(exporterOptions: Partial): ObservabilityBuilder { + this.options.exporterOptions = { + ...(this.options.exporterOptions || {}), + ...exporterOptions + }; + return this; + } + private createBatchProcessor(): BatchSpanProcessor { if (!isAgent365ExporterEnabled()) { return new BatchSpanProcessor(new ConsoleSpanExporter()); } + const opts = new Agent365ExporterOptions(); - opts.clusterCategory = this.options.clusterCategory || 'prod'; + if (this.options.exporterOptions) { + Object.assign(opts, this.options.exporterOptions); + } + opts.clusterCategory = this.options.clusterCategory||opts.clusterCategory || 'prod'; if (this.options.tokenResolver) { opts.tokenResolver = this.options.tokenResolver; } @@ -179,4 +205,3 @@ export class ObservabilityBuilder { } } } - diff --git a/tests/observability/core/observabilityBuilder-options.test.ts b/tests/observability/core/observabilityBuilder-options.test.ts new file mode 100644 index 00000000..f4486e45 --- /dev/null +++ b/tests/observability/core/observabilityBuilder-options.test.ts @@ -0,0 +1,77 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------------ + +import { ObservabilityBuilder } from '@microsoft/agents-a365-observability/dist/cjs/ObservabilityBuilder'; + +// Mock the Agent365Exporter so we can capture the constructed options without performing network calls. +jest.mock('@microsoft/agents-a365-observability/dist/cjs/tracing/exporter/Agent365Exporter', () => { + return { + Agent365Exporter: class { + public static lastOptions: any; + constructor(opts: any) { + // Capture the options passed from ObservabilityBuilder + (global as any).__capturedExporterOptions = opts; + ;(global as any).__capturedExporterOptionsCallCount = ((global as any).__capturedExporterOptionsCallCount || 0) + 1; + (this.constructor as any).lastOptions = opts; + } + export() {/* no-op */} + shutdown() {/* no-op */} + forceFlush() {/* no-op */} + } + }; +}); + +describe('ObservabilityBuilder exporterOptions merging', () => { + beforeEach(() => { + // Ensure exporter is enabled so BatchSpanProcessor is created with Agent365Exporter + process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; + delete (global as any).__capturedExporterOptions; + delete (global as any).__capturedExporterOptionsCallCount; + }); + + afterEach(() => { + delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; + }); + + it('applies provided exporterOptions and allows builder overrides to take precedence', () => { + const builder = new ObservabilityBuilder() + .withExporterOptions({ + maxQueueSize: 10, + scheduledDelayMilliseconds: 1111, + exporterTimeoutMilliseconds: 2222, + maxExportBatchSize: 33, + // These should be overridden by explicit builder methods below + clusterCategory: 'dev' as any, + tokenResolver: () => 'token-from-exporterOptions' + }) + .withClusterCategory('test') + .withTokenResolver(() => 'token-from-builder'); + + const built = builder.build(); + expect(built).toBe(true); + + const captured: any = (global as any).__capturedExporterOptions; + expect(captured).toBeDefined(); + // Custom numeric options preserved + expect(captured.maxQueueSize).toBe(10); + expect(captured.scheduledDelayMilliseconds).toBe(1111); + expect(captured.exporterTimeoutMilliseconds).toBe(2222); + expect(captured.maxExportBatchSize).toBe(33); + // Explicit builder overrides should win + expect(captured.clusterCategory).toBe('test'); + expect(typeof captured.tokenResolver).toBe('function'); + expect(captured.tokenResolver('a','b')).toBe('token-from-builder'); + }); + + it('defaults to prod clusterCategory when none provided', () => { + const builder = new ObservabilityBuilder() + .withExporterOptions({ maxQueueSize: 15 }); // no cluster category passed + + builder.build(); + const captured: any = (global as any).__capturedExporterOptions; + expect(captured.clusterCategory).toBe('prod'); + expect(captured.maxQueueSize).toBe(15); + expect(captured.scheduledDelayMilliseconds).toBe(5000); // default value + }); +}); From e07318bb356d8d0a85134684c93e23a961c04ae1 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Mon, 17 Nov 2025 17:25:33 -0800 Subject: [PATCH 16/26] expose Agent365ExporterOptions --- .../src/ObservabilityBuilder.ts | 9 ++++----- packages/agents-a365-observability/src/index.ts | 2 +- tests-agent/basic-agent-sdk-sample/src/telemetry.ts | 11 ++++++++--- tests/observability/core/agent365-exporter.test.ts | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index 48db7ff5..a9bdcb0c 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -28,10 +28,9 @@ export interface BuilderOptions { clusterCategory?: ClusterCategory; /** * Optional partial set of exporter options allowing agent developers to customize. - * Any values omitted will fall back to the defaults defined in Agent365ExporterOptions. - * Values provided here take precedence over those inferred from other builder methods (except that an - * explicitly provided tokenResolver / clusterCategory via dedicated builder - * methods will override this object). + * Any values omitted will fall back to the defaults defined in Agent365ExporterOptions. + * Values provided here will be overridden by explicitly configured tokenResolver or clusterCategory + * from dedicated builder methods. */ exporterOptions?: Partial; @@ -100,7 +99,7 @@ export class ObservabilityBuilder { if (this.options.exporterOptions) { Object.assign(opts, this.options.exporterOptions); } - opts.clusterCategory = this.options.clusterCategory||opts.clusterCategory || 'prod'; + opts.clusterCategory = this.options.clusterCategory || opts.clusterCategory || 'prod'; if (this.options.tokenResolver) { opts.tokenResolver = this.options.tokenResolver; } diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index 25f94072..17320776 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -5,7 +5,7 @@ // Main SDK classes export { ObservabilityManager } from './ObservabilityManager'; export { ObservabilityBuilder as Builder, BuilderOptions } from './ObservabilityBuilder'; - +export { Agent365ExporterOptions } from './tracing/exporter/Agent365ExporterOptions'; // Tracing constants export { OpenTelemetryConstants } from './tracing/constants'; diff --git a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts index 85bf7a90..d521f630 100644 --- a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts +++ b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts @@ -1,6 +1,7 @@ import { Builder, - ObservabilityManager + ObservabilityManager, + Agent365ExporterOptions } from '@microsoft/agents-a365-observability'; import { createAgenticTokenCacheKey } from './agent'; @@ -30,14 +31,18 @@ const getClusterCategory = (): ClusterCategory => { if (category) { return category as ClusterCategory; } - return 'dev' as ClusterCategory; // Safe fallback + return 'prod' as ClusterCategory; // Safe fallback }; // Configure observability builder (conditionally adding token resolver based on env flag) export const a365Observability = ObservabilityManager.configure((builder: Builder) => { + const exporterOptions = new Agent365ExporterOptions(); + exporterOptions.maxQueueSize = 10; // customized per request + builder .withService('TypeScript Sample Agent', '1.0.0') - .withClusterCategory(getClusterCategory()); + .withClusterCategory(getClusterCategory()) + .withExporterOptions(exporterOptions); // Opt-in custom token resolver via env flag `Use_Custom_Resolver=true` if (process.env.Use_Custom_Resolver === 'true') { builder.withTokenResolver(tokenResolver); diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index 51c7c6c4..2549986d 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -55,7 +55,7 @@ describe('Agent365Exporter', () => { jest.clearAllTimers(); jest.useRealTimers(); global.fetch = originalFetch; - AgenticTokenCacheInstance.invalidateAll(); + AgenticTokenCacheInstance.invalidateAll(); }); it('returns success immediately with no spans', async () => { From 057aae7d24ac497df4d988331f01fa1edb40b3b7 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 18 Nov 2025 16:50:34 -0800 Subject: [PATCH 17/26] move azure token cache to its own package --- .../README.md | 20 ++ .../package.json | 62 ++++ .../src/AgenticTokenCache.ts | 104 +++++++ .../src/index.ts | 6 + .../tsconfig.cjs.json | 7 + .../tsconfig.esm.json | 7 + .../tsconfig.json | 21 ++ .../agents-a365-observability/src/index.ts | 1 - .../src/tracing/exporter/Agent365Exporter.ts | 9 +- .../exporter/Agent365ExporterOptions.ts | 2 +- .../src/utils/AgenticTokenCache.ts | 273 ------------------ pnpm-lock.yaml | 46 +++ .../basic-agent-sdk-sample/.env.example | 1 + .../basic-agent-sdk-sample/package.json | 1 + .../basic-agent-sdk-sample/src/agent.ts | 8 +- .../basic-agent-sdk-sample/src/telemetry.ts | 11 +- .../core/agent365-exporter.test.ts | 59 +--- .../tokencache}/agentic-token-cache.test.ts | 79 +++-- tests/package.json | 2 + tests/tsconfig.json | 2 +- 20 files changed, 335 insertions(+), 386 deletions(-) create mode 100644 packages/agents-a365-observability-tokenCache/README.md create mode 100644 packages/agents-a365-observability-tokenCache/package.json create mode 100644 packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts create mode 100644 packages/agents-a365-observability-tokenCache/src/index.ts create mode 100644 packages/agents-a365-observability-tokenCache/tsconfig.cjs.json create mode 100644 packages/agents-a365-observability-tokenCache/tsconfig.esm.json create mode 100644 packages/agents-a365-observability-tokenCache/tsconfig.json delete mode 100644 packages/agents-a365-observability/src/utils/AgenticTokenCache.ts rename tests/observability/{core => extension/tokencache}/agentic-token-cache.test.ts (61%) diff --git a/packages/agents-a365-observability-tokenCache/README.md b/packages/agents-a365-observability-tokenCache/README.md new file mode 100644 index 00000000..8b2bdec7 --- /dev/null +++ b/packages/agents-a365-observability-tokenCache/README.md @@ -0,0 +1,20 @@ +# @microsoft/agents-a365-observability-tokencache + +Observability token cache utilities for Agent365 SDK. Provides an in-memory cache for observability bearer tokens with early refresh, retry, and per-key serialization. + +## Installation + +```bash +pnpm add @microsoft/agents-a365-observability-tokencache +``` + +## Usage + +```ts +import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-tokencache'; + +const token = AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId); +``` + +## License +MIT diff --git a/packages/agents-a365-observability-tokenCache/package.json b/packages/agents-a365-observability-tokenCache/package.json new file mode 100644 index 00000000..4caa0da6 --- /dev/null +++ b/packages/agents-a365-observability-tokenCache/package.json @@ -0,0 +1,62 @@ +{ + "name": "@microsoft/agents-a365-observability-tokencache", + "version": "0.0.0-placeholder", + "description": "Microsoft Agent 365 SDK observability token cache utilities", + "keywords": [ + "agent365", + "observability", + "telemetry", + "token", + "cache" + ], + "homepage": "https://github.com/microsoft/Agent365-nodejs", + "bugs": { + "url": "https://github.com/microsoft/Agent365-nodejs/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/Agent365-nodejs.git", + "directory": "packages/agents-a365-observability-tokenCache" + }, + "license": "MIT", + "author": "Microsoft", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/cjs/index.d.ts", + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "build": "npm run build:cjs && npm run build:esm", + "build:cjs": "npx tsc --project tsconfig.cjs.json", + "build:esm": "npx tsc --project tsconfig.esm.json", + "build:watch": "npx tsc --watch", + "clean": "npx rimraf dist", + "lint": "eslint src/**/*.ts", + "lint:fix": "eslint src/**/*.ts --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "pack": "npm pack --pack-destination=../" + }, + "dependencies": { + "@microsoft/agents-hosting": "workspace:*", + "@microsoft/agents-a365-runtime": "workspace:*" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.0.0", + "jest": "^29.7.0", + "rimraf": "^6.0.0", + "ts-jest": "^29.2.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts b/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts new file mode 100644 index 00000000..116ebb80 --- /dev/null +++ b/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts @@ -0,0 +1,104 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------------------------ + +import { TurnContext, Authorization } from '@microsoft/agents-hosting'; +import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runtime'; +// We intentionally do not depend on the observability package logger to avoid circular dependency; +// lightweight console wrappers are used instead. Adjust if centralized logging is required. +const logger = { + info: (...a: unknown[]) => console.info(...a), + warn: (...a: unknown[]) => console.warn(...a), + error: (...a: unknown[]) => console.error(...a) +}; + +function formatError(e: unknown): string { + if (e instanceof Error) { return `${e.name}: ${e.message}`; } + return String(e); +} + +interface CacheEntry { + scopes: string[]; + token?: string; + expiresOn?: number; + acquiredOn?: number; +} + +class AgenticTokenCache { + private readonly _map = new Map(); + private readonly _defaultRefreshSkewMs = 60_000; + private readonly _defaultMaxTokenAgeMs = 3_600_000; + private readonly _keyLocks = new Map>(); + private makeKey(agentId: string, tenantId: string): string { return `${agentId}:${tenantId}`; } + + public getObservabilityToken(agentId: string, tenantId: string): string | null { + const key = this.makeKey(agentId, tenantId); + const entry = this._map.get(key); + if (!entry) { logger.error(`[AgenticTokenCache] No cache entry for ${key}`); return null; } + if (!entry.token) { logger.error(`[AgenticTokenCache] No token cached for ${key}`); return null; } + if (this.isExpired(entry)) { logger.error(`[AgenticTokenCache] Token expired for ${key}`); return null; } + return entry.token; + } + + public async RefreshObservabilityToken( + agentId: string, + tenantId: string, + turnContext: TurnContext, + authorization: Authorization, + scopes: string[] + ): Promise { + const key = this.makeKey(agentId, tenantId); + if (!authorization) { logger.error('[AgenticTokenCache] Authorization not set'); return; } + if (!turnContext) { logger.error('[AgenticTokenCache] TurnContext not set'); return; } + return this.withKeyLock(key, async () => { + let entry = this._map.get(key); + if (!entry) { + const effectiveScopes = (scopes && scopes.length > 0) ? scopes : getObservabilityAuthenticationScope(); + if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) { logger.error('[AgenticTokenCache] No valid scopes'); return; } + entry = { scopes: effectiveScopes }; + this._map.set(key, entry); + } + if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) { logger.error('[AgenticTokenCache] Entry has invalid scopes'); return; } + try { + if (entry.token && !this.isExpired(entry)) { return; } + const maxRetries = 2; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + logger.info(`[AgenticTokenCache] Exchanging token attempt ${attempt + 1}/${maxRetries + 1}`); + try { + const tokenResponse = await authorization.exchangeToken(turnContext, 'agentic', { scopes: entry.scopes }); + if (!tokenResponse?.token) { logger.error('[AgenticTokenCache] Undefined token returned'); entry.token = undefined; entry.expiresOn = undefined; break; } + entry.token = tokenResponse.token; + entry.acquiredOn = Date.now(); + const oboExp = this.decodeExp(entry.token); + if (oboExp) { entry.expiresOn = oboExp * 1000; } else { logger.warn('[AgenticTokenCache] No exp claim, fallback TTL'); } + logger.info('[AgenticTokenCache] Token cached'); + return; + } catch (e) { + const retriable = this.isRetriableError(e); + if (retriable && attempt < maxRetries) { logger.warn(`[AgenticTokenCache] Retriable failure attempt ${attempt + 1}`, formatError(e)); await this.sleep(200 * (attempt + 1)); continue; } + logger.error('[AgenticTokenCache] Non-retriable failure', formatError(e)); entry.token = undefined; entry.expiresOn = undefined; break; + } + } + } catch (e) { + logger.error('[AgenticTokenCache] Unexpected failure', formatError(e)); entry.token = undefined; entry.expiresOn = undefined; + } + }); + } + + invalidateToken(agentId: string, tenantId: string): void { const entry = this._map.get(this.makeKey(agentId, tenantId)); if (entry) { entry.token = undefined; entry.expiresOn = undefined; } } + invalidateAll(): void { this._map.clear(); } + + private decodeExp(jwt: string): number | undefined { + try { if (!jwt) return undefined; const parts = jwt.split('.'); if (parts.length < 2) return undefined; const payload = parts[1] + '='.repeat((4 - (parts[1].length % 4)) % 4); const json = JSON.parse(Buffer.from(payload, 'base64').toString('utf8')) as { exp?: unknown }; return typeof json.exp === 'number' ? json.exp : undefined; } catch { return undefined; } + } + private isExpired(entry: CacheEntry): boolean { const now = Date.now(); if (entry.expiresOn) return now >= (entry.expiresOn - this._defaultRefreshSkewMs); if (entry.acquiredOn) return now >= (entry.acquiredOn + this._defaultMaxTokenAgeMs); return true; } + private isRetriableError(err: unknown): boolean { const e = err as { code?: string; status?: number; message?: string } | undefined; if (!e) return false; const msg = (e.message || '').toLowerCase(); if (msg.includes('timeout') || msg.includes('econnreset') || msg.includes('network')) return true; if (typeof e.status === 'number') { if (e.status === 408 || e.status === 429) return true; if (e.status >= 500 && e.status < 600) return true; } return false; } + private sleep(ms: number): Promise { return new Promise(r => setTimeout(r, ms)); } + private async withKeyLock(key: string, fn: () => Promise): Promise { const previous = this._keyLocks.get(key); if (previous) { try { await previous; } catch (err) { logger.warn(`[AgenticTokenCache] previous promise for ${key} rejected:`, formatError(err)); } } const currentPromise: Promise = fn().finally(() => { if (this._keyLocks.get(key) === currentPromise) { this._keyLocks.delete(key); } }); this._keyLocks.set(key, currentPromise); return currentPromise; } +} + +export function createAgenticTokenCacheKey(agentId: string, tenantId: string): string { return `${agentId}:${tenantId}`; } +export const AgenticTokenCacheInstance = new AgenticTokenCache(); + +export default AgenticTokenCacheInstance; diff --git a/packages/agents-a365-observability-tokenCache/src/index.ts b/packages/agents-a365-observability-tokenCache/src/index.ts new file mode 100644 index 00000000..4c7931b8 --- /dev/null +++ b/packages/agents-a365-observability-tokenCache/src/index.ts @@ -0,0 +1,6 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------------------------ + +export { AgenticTokenCacheInstance, createAgenticTokenCacheKey } from './AgenticTokenCache'; diff --git a/packages/agents-a365-observability-tokenCache/tsconfig.cjs.json b/packages/agents-a365-observability-tokenCache/tsconfig.cjs.json new file mode 100644 index 00000000..44b90283 --- /dev/null +++ b/packages/agents-a365-observability-tokenCache/tsconfig.cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "./dist/cjs" + } +} diff --git a/packages/agents-a365-observability-tokenCache/tsconfig.esm.json b/packages/agents-a365-observability-tokenCache/tsconfig.esm.json new file mode 100644 index 00000000..0ebcdc1a --- /dev/null +++ b/packages/agents-a365-observability-tokenCache/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "esnext", + "outDir": "./dist/esm" + } +} diff --git a/packages/agents-a365-observability-tokenCache/tsconfig.json b/packages/agents-a365-observability-tokenCache/tsconfig.json new file mode 100644 index 00000000..1584f54a --- /dev/null +++ b/packages/agents-a365-observability-tokenCache/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2023", "DOM"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts"] +} diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index 17320776..80235c87 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -35,4 +35,3 @@ export { OpenTelemetryScope } from './tracing/scopes/OpenTelemetryScope'; export { ExecuteToolScope } from './tracing/scopes/ExecuteToolScope'; export { InvokeAgentScope } from './tracing/scopes/InvokeAgentScope'; export { InferenceScope} from './tracing/scopes/InferenceScope'; -export { AgenticTokenCacheInstance } from './utils/AgenticTokenCache'; diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index 8414cf0f..4608b536 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -9,7 +9,6 @@ import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; import { PowerPlatformApiDiscovery, ClusterCategory } from '@microsoft/agents-a365-runtime'; import { partitionByIdentity, parseIdentityKey, hexTraceId, hexSpanId, kindName, statusName } from './utils'; import logger, { formatError } from '../../utils/logging'; -import { AgenticTokenCacheInstance } from '../../utils/AgenticTokenCache'; import { Agent365ExporterOptions } from './Agent365ExporterOptions'; const DEFAULT_HTTP_TIMEOUT_SECONDS = 30000; // 30 seconds in ms const DEFAULT_MAX_RETRIES = 3; @@ -86,10 +85,7 @@ export class Agent365Exporter implements SpanExporter { } if (!options.tokenResolver) { - options.tokenResolver = AgenticTokenCacheInstance.getObservabilityToken.bind(AgenticTokenCacheInstance); - logger.info('Agent365Exporter initialized with agentic resolver', `clusterCategory=${options.clusterCategory}`); - } else { - logger.info('Agent365Exporter initialized with custom tokenResolver', `clusterCategory=${options.clusterCategory}`); + throw new Error('Agent365Exporter tokenResolver must be provided'); } this.options = options; } @@ -118,8 +114,9 @@ export class Agent365Exporter implements SpanExporter { const promises: Promise[] = []; for (const [identityKey, activities] of groups) { - const promise = this.exportGroup(identityKey, activities).catch(() => { + const promise = this.exportGroup(identityKey, activities).catch((err) => { anyFailure = true; + logger.error(`[Agent365Exporter] Error exporting group ${identityKey}: ${formatError(err)}`); }); promises.push(promise); } diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index 6bb5d783..2b7d60a0 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -32,7 +32,7 @@ export class Agent365ExporterOptions { public clusterCategory: ClusterCategory | string = 'prod'; /** Optional delegate to resolve auth token; falls back to AgenticTokenCache when absent. */ - public tokenResolver?: TokenResolver; + public tokenResolver?: TokenResolver; // A tokenResolver MUST be provided by callers. /** Use service-to-service endpoint variant when true; standard endpoint when false. */ public useS2SEndpoint: boolean = false; diff --git a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts b/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts deleted file mode 100644 index 93487d72..00000000 --- a/packages/agents-a365-observability/src/utils/AgenticTokenCache.ts +++ /dev/null @@ -1,273 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { TurnContext, Authorization } from '@microsoft/agents-hosting'; -import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runtime'; -import logger, { formatError } from './logging'; - -// Structure stored per key -interface CacheEntry { - scopes: string[]; - token?: string; // Cached token value - expiresOn?: number; // Expiration epoch millis (if provided by exchange response) - acquiredOn?: number; // Epoch millis when token was acquired (fallback TTL when expiresOn missing) -} - -/** - * In-memory cache for observability tokens keyed by agentId and tenantId. - * Features: - * - Stores bearer token + decoded expiration per (agentId, tenantId) key. - * - Applies an early refresh skew so tokens are proactively refreshed before hard expiry. - * - Retries transient exchange failures (network / HTTP 408, 429, 5xx) with linear backoff (200ms, 400ms). - * - Serializes write/exchange operations per key with a Promise chain (withKeyLock) to avoid duplicate exchanges. - * - Provides synchronous read access (getObservabilityToken) that never triggers network IO. - * - Applies a fallback max age (1h) for tokens that lack embedded expiration metadata (exp claim). - * - * Thread Safety: - * Per-key serialization ensures at most one exchange updates a given entry concurrently. Reads are lock‑free. - * - * Limitations: - * Process-local only; for multi-process or horizontal scaling scenarios a distributed cache/service is required. - */ -class AgenticTokenCache { - private readonly _map = new Map(); - private readonly _defaultRefreshSkewMs = 60_000; // refresh 60s before expiry - private readonly _defaultMaxTokenAgeMs = 3_600_000; // 1 hour fallback TTL if exp not provided - // Per-key promise chain to serialize mutations & exchanges - private readonly _keyLocks = new Map>(); - private makeKey(agentId: string, tenantId: string): string { - return `${agentId}:${tenantId}`; - } - - /** - * Returns the currently cached valid token for the key (no network calls). - * @param agentId Unique agent/application identifier. - * @param tenantId Tenant identifier (AAD tenant / customer context). - * @returns Cached bearer token string if present & not expired; otherwise null. - */ - public getObservabilityToken(agentId: string, tenantId: string): string | null { - const key = this.makeKey(agentId, tenantId); - const entry = this._map.get(key); - if (!entry) { - logger.error(`[AgenticTokenCache] No cache entry found for agentId=${agentId} tenantId=${tenantId}`); - return null; - } - if (!entry.token) { - logger.error(`[AgenticTokenCache] No token cached for agentId=${agentId} tenantId=${tenantId}`); - return null; - } - if (this.isExpired(entry)) { - logger.error(`[AgenticTokenCache] Cached token expired for agentId=${agentId} tenantId=${tenantId}`); - return null; - } - return entry.token; - } - - /** - * Ensures a valid token is cached for the (agentId, tenantId) key. Performs an exchange when: - * - No token exists yet. - * - Token is expired OR within the early refresh skew window. - * Retries transient failures up to 2 times (3 total attempts) with linear backoff (200ms, 400ms). - * Idempotent under the per-key lock: concurrent callers serialize and reuse the first successful result. - * @param agentId Unique agent identifier. - * @param tenantId Tenant identifier. - * @param turnContext TurnContext providing activity/service metadata required for exchange. - * @param authorization Authorization instance used to perform the token exchange. - * @param scopes Requested scopes; falls back to getObservabilityAuthenticationScope() if empty. - * @returns Promise resolved once cache updated (success or failure). Inspect using getObservabilityToken(). - */ - public async RefreshObservabilityToken( - agentId: string, - tenantId: string, - turnContext: TurnContext, - authorization: Authorization, - scopes: string[] - ): Promise { - const key = this.makeKey(agentId, tenantId); - if (!authorization) { - logger.error('[AgenticTokenCache] Cannot exchange token. Authorization instance not set.'); - return; - } - - if (!turnContext) { - logger.error('[AgenticTokenCache] Cannot exchange token. TurnContext instance not set.'); - return; - } - - // Acquire or return cached token under key lock - return this.withKeyLock(key, async () => { - // Entry creation moved inside lock to avoid race on first initialization - let entry = this._map.get(key); - if (!entry) { - const effectiveScopes = (scopes && scopes.length > 0) ? scopes : getObservabilityAuthenticationScope(); - if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) { - logger.error('[AgenticTokenCache] Cannot exchange token. No valid scopes provided or available from fallback.'); - return; // abort early; entry not created - } - entry = { scopes: effectiveScopes }; - this._map.set(key, entry); - } - - // Validate existing entry scopes (in case previously created with empty array before fix) - if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) { - logger.error('[AgenticTokenCache] Cannot exchange token. Cache entry has invalid/empty scopes.'); - return; - } - try { - if (entry.token && !this.isExpired(entry)) { - return; - } - - const maxRetries = 2; - for (let attempt = 0; attempt <= maxRetries; attempt++) { - logger.info(`[AgenticTokenCache] No cached token found. Exchanging token ... attempt ${attempt + 1}/${maxRetries + 1}`); - try { - const tokenResponse = await authorization.exchangeToken( - turnContext, - 'agentic', - { scopes: entry.scopes } - ); - if (!tokenResponse?.token) { - logger.error('[AgenticTokenCache] Token exchange returned undefined token, please check agent permission configuration.'); - entry.token = undefined; - entry.expiresOn = undefined; - // Undefined token generally not transient; stop retries. - break; - } - entry.token = tokenResponse.token; - entry.acquiredOn = Date.now(); - const oboExp = this.decodeExp(entry.token); - if (oboExp) { - entry.expiresOn = oboExp * 1000; // to epoch millisecond - } else { - // No exp claim present; will rely on fallback TTL. - logger.warn('[AgenticTokenCache] Token has no exp claim. Applying fallback TTL (1h).'); - } - logger.info('[AgenticTokenCache] Token exchange successful and cached.'); - // success - return; - } catch (e) { - const retriable = this.isRetriableError(e); - if (retriable && attempt < maxRetries) { - logger.warn(`[AgenticTokenCache] Retriable token exchange failure (attempt ${attempt + 1})`, formatError(e)); - const backoffMs = 200 * (attempt + 1); - await this.sleep(backoffMs); - continue; - } - logger.error('[AgenticTokenCache] Non-retriable token exchange failure', formatError(e)); - entry.token = undefined; - entry.expiresOn = undefined; - break; - } - } - } catch (e) { - logger.error('[AgenticTokenCache] Token exchange failed unexpectedly', formatError(e)); - entry.token = undefined; - entry.expiresOn = undefined; - } - return; - }); - } - - /** - * Explicitly clears token + expiration for one key forcing a fresh exchange next time. - * @param agentId Agent identifier. - * @param tenantId Tenant identifier. - */ - invalidateToken(agentId: string, tenantId: string): void { - const key = this.makeKey(agentId, tenantId); - const entry = this._map.get(key); - if (entry) { - entry.token = undefined; - entry.expiresOn = undefined; - } - } - - /** - * Clears all cached entries (tokens + metadata) for every key. - */ - invalidateAll(): void { - this._map.clear(); - } - - - /** Decode exp from JWT (returns epoch seconds). */ - private decodeExp(jwt: string): number | undefined { - try { - if (!jwt) { return undefined; } - const parts = jwt.split('.'); - if (parts.length < 2) { return undefined; } - const payload = parts[1] + '='.repeat((4 - (parts[1].length % 4)) % 4); // base64 padding - const json = JSON.parse(Buffer.from(payload, 'base64').toString('utf8')) as { exp?: unknown }; - return typeof json.exp === 'number' ? json.exp : undefined; - } catch { - return undefined; - } - } - private isExpired(entry: CacheEntry): boolean { - const now = Date.now(); - if (entry.expiresOn) { - return now >= (entry.expiresOn - this._defaultRefreshSkewMs); // Refresh early by skew - } - // Fallback: if no explicit expiration, treat as expired after max age. - if (entry.acquiredOn) { - return now >= (entry.acquiredOn + this._defaultMaxTokenAgeMs); - } - // No timing metadata at all; force refresh immediately. - return true; - } - - /** Basic transient error classification for retry logic */ - private isRetriableError(err: unknown): boolean { - const e = err as { code?: string; status?: number; message?: string } | undefined; - if (!e) return false; - // Network / timeout style codes - const msg = (e.message || '').toLowerCase(); - if (msg.includes('timeout') || msg.includes('econnreset') || msg.includes('network')) return true; - // HTTP status heuristics - if (typeof e.status === 'number') { - if (e.status === 408 || e.status === 429) return true; - if (e.status >= 500 && e.status < 600) return true; - } - return false; - } - - /** Simple sleep helper for retry backoff */ - private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - private async withKeyLock(key: string, fn: () => Promise): Promise { - const previous = this._keyLocks.get(key); - if (previous) { - try { - await previous; - } catch (err) { - // Previous locked operation failed; log at warn level for visibility without throwing. - logger.warn(`[AgenticTokenCache] withKeyLock: previous promise for key "${key}" rejected:`, formatError(err)); - } - } - const currentPromise: Promise = fn().finally(() => { - if (this._keyLocks.get(key) === currentPromise) { - this._keyLocks.delete(key); - } - }); - this._keyLocks.set(key, currentPromise); - return currentPromise; - } -} - -/** - * Helper for external callers to build a cache key string (agentId:tenantId) consistent with internal usage. - * @param agentId Agent identifier. - * @param tenantId Tenant identifier. - * @returns Combined cache key string in format "agentId:tenantId". - */ -export function createAgenticTokenCacheKey(agentId: string, tenantId: string): string { - return `${agentId}:${tenantId}`; -} - - -export const AgenticTokenCacheInstance = new AgenticTokenCache(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad256424..3d8767cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,43 @@ importers: specifier: ^5.6.0 version: 5.9.3 + packages/agents-a365-observability-tokenCache: + dependencies: + '@microsoft/agents-a365-runtime': + specifier: workspace:* + version: link:../agents-a365-runtime + '@microsoft/agents-hosting': + specifier: ^1.1.0-alpha.85 + version: 1.1.0-alpha.85 + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/node': + specifier: ^20.17.0 + version: 20.19.25 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + eslint: + specifier: ^8.57.0 + version: 8.57.1 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + rimraf: + specifier: ^6.0.0 + version: 6.1.0 + ts-jest: + specifier: ^29.2.0 + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + packages/agents-a365-runtime: dependencies: '@azure/identity': @@ -418,9 +455,15 @@ importers: '@microsoft/agents-a365-observability-extensions-openai': specifier: workspace:* version: link:../packages/agents-a365-observability-extensions-openai + '@microsoft/agents-a365-observability-tokencache': + specifier: workspace:* + version: link:../packages/agents-a365-observability-tokenCache '@microsoft/agents-a365-runtime': specifier: workspace:* version: link:../packages/agents-a365-runtime + '@microsoft/agents-hosting': + specifier: ^1.1.0-alpha.85 + version: 1.1.0-alpha.85 '@modelcontextprotocol/sdk': specifier: ^1.19.0 version: 1.21.1(@cfworker/json-schema@4.1.1) @@ -491,6 +534,9 @@ importers: '@microsoft/agents-a365-observability': specifier: workspace:* version: link:../../packages/agents-a365-observability + '@microsoft/agents-a365-observability-tokencache': + specifier: workspace:* + version: link:../../packages/agents-a365-observability-tokenCache '@microsoft/agents-a365-runtime': specifier: workspace:* version: link:../../packages/agents-a365-runtime diff --git a/tests-agent/basic-agent-sdk-sample/.env.example b/tests-agent/basic-agent-sdk-sample/.env.example index c8692772..14fc85fc 100644 --- a/tests-agent/basic-agent-sdk-sample/.env.example +++ b/tests-agent/basic-agent-sdk-sample/.env.example @@ -14,3 +14,4 @@ ENABLE_OBSERVABILITY=true ENABLE_A365_OBSERVABILITY_EXPORTER=true CLUSTER_CATEGORY=prod # optional - defaults to 'prod' if not set A365_OBSERVABILITY_LOG_LEVEL= # optional - set to enable observability logs, value can be 'info', 'warn', or 'error', default to 'none' if not set +Use_Custom_Resolver= # optional - set to 'true' to use custom token resolver, defaults to 'false' if not set diff --git a/tests-agent/basic-agent-sdk-sample/package.json b/tests-agent/basic-agent-sdk-sample/package.json index d1f36a43..1844f51b 100644 --- a/tests-agent/basic-agent-sdk-sample/package.json +++ b/tests-agent/basic-agent-sdk-sample/package.json @@ -18,6 +18,7 @@ "@microsoft/agents-activity": "*", "@microsoft/agents-a365-observability": "workspace:*", "@microsoft/agents-a365-runtime": "workspace:*", + "@microsoft/agents-a365-observability-tokencache": "workspace:*", "dotenv": "*", "express": "*", "uuid": "*" diff --git a/tests-agent/basic-agent-sdk-sample/src/agent.ts b/tests-agent/basic-agent-sdk-sample/src/agent.ts index e64af70d..75df9d42 100644 --- a/tests-agent/basic-agent-sdk-sample/src/agent.ts +++ b/tests-agent/basic-agent-sdk-sample/src/agent.ts @@ -1,3 +1,8 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------------------------ + import { TurnState, AgentApplication, @@ -18,10 +23,9 @@ import { ExecutionType, EnhancedAgentDetails, ServiceEndpoint, - AgenticTokenCacheInstance, } from '@microsoft/agents-a365-observability'; import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runtime'; - +import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-tokencache'; import tokenCache from './token-cache'; interface ConversationState { count: number; diff --git a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts index d521f630..6e6a34ba 100644 --- a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts +++ b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts @@ -1,12 +1,17 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------------------------ + import { Builder, ObservabilityManager, Agent365ExporterOptions } from '@microsoft/agents-a365-observability'; - import { createAgenticTokenCacheKey } from './agent'; import tokenCache from './token-cache'; import { ClusterCategory } from '@microsoft/agents-a365-runtime'; +import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-tokencache'; // Configure observability with token resolver (like Python's token_resolver function) const tokenResolver = (agentId: string, tenantId: string): string | null => { @@ -47,6 +52,10 @@ export const a365Observability = ObservabilityManager.configure((builder: Builde if (process.env.Use_Custom_Resolver === 'true') { builder.withTokenResolver(tokenResolver); } + else { + // use resolver from azure token package + builder.withTokenResolver((agentId: string, tenantId: string) => AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId)); + } }); diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index 2549986d..7f1fc265 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import { Agent365Exporter } from '@microsoft/agents-a365-observability/src/tracing/exporter/Agent365Exporter'; import { Agent365ExporterOptions } from '@microsoft/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions'; -import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability/src/utils/AgenticTokenCache'; // Using standard import instead of 'import type' to avoid Babel/Jest transform issues in this workspace import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { ExportResultCode } from '@opentelemetry/core'; @@ -55,7 +54,6 @@ describe('Agent365Exporter', () => { jest.clearAllTimers(); jest.useRealTimers(); global.fetch = originalFetch; - AgenticTokenCacheInstance.invalidateAll(); }); it('returns success immediately with no spans', async () => { @@ -100,59 +98,10 @@ describe('Agent365Exporter', () => { expect(exportedSpan.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(agentId); }); - it('falls back to AgenticTokenCache when no custom resolver provided', async () => { - mockFetchSequence([200]); - // Preload cache via RefreshObservabilityToken API - const tenant = tenantId; - const agent = agentId; - const ctx = { activity: { id: 'x' } } as any; // minimal TurnContext stub - const auth = { exchangeToken: async () => ({ token: 'cached-token' }) } as any; // Authorization stub - await AgenticTokenCacheInstance.RefreshObservabilityToken(agent, tenant, ctx, auth, ['scope.read']); - - // Spy on getObservabilityToken to assert fallback path uses cache retrieval - const getTokenSpy = jest.spyOn(AgenticTokenCacheInstance as any, 'getObservabilityToken'); - + it('requires a tokenResolver and fails export when missing', async () => { const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'local'; // no tokenResolver assigned -> fallback to cache - const exporter = new Agent365Exporter(opts); // no resolver provided - const spans = [ - makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenant, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agent - }) - ]; - const callback = jest.fn(); - await exporter.export(spans, callback); - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - expect(getTokenSpy).toHaveBeenCalledTimes(1); - expect(getTokenSpy.mock.calls[0][0]).toBe(agent); - expect(getTokenSpy.mock.calls[0][1]).toBe(tenant); - const fetchCalls = (global.fetch as unknown as { mock: { calls: any[] } }).mock.calls; - expect(fetchCalls.length).toBe(1); - const headersArg = fetchCalls[0][1].headers; - expect(headersArg['authorization']).toBe('Bearer cached-token'); - // Validate payload structure - const bodyStr = fetchCalls[0][1].body as string; - expect(typeof bodyStr).toBe('string'); - const bodyJson = JSON.parse(bodyStr); - expect(Array.isArray(bodyJson.resourceSpans)).toBe(true); - expect(bodyJson.resourceSpans.length).toBe(1); - // console.log('[test] resourceSpans:', JSON.stringify(bodyJson.resourceSpans, null, 2)); - const rs = bodyJson.resourceSpans[0]; - expect(Array.isArray(rs.scopeSpans)).toBe(true); - expect(rs.scopeSpans.length).toBe(1); - const scopeSpan = rs.scopeSpans[0]; - expect(Array.isArray(scopeSpan.spans)).toBe(true); - expect(scopeSpan.spans.length).toBe(1); - const span = scopeSpan.spans[0]; - expect(span.name).toBe('test'); - expect(span.traceId).toEqual('00000000000000000000000000000001'); - expect(span.spanId).toEqual('0000000000000002'); - expect(span.attributes).toBeDefined(); - expect(span.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(tenant); - expect(span.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(agent); - // Validate local cluster endpoint format - const urlArg = fetchCalls[0][0] as string; - expect(urlArg).toContain('localhost'); + opts.clusterCategory = 'local'; + // Intentionally omit tokenResolver + expect(() => new Agent365Exporter(opts)).toThrow(/tokenResolver must be provided/); }); }); diff --git a/tests/observability/core/agentic-token-cache.test.ts b/tests/observability/extension/tokencache/agentic-token-cache.test.ts similarity index 61% rename from tests/observability/core/agentic-token-cache.test.ts rename to tests/observability/extension/tokencache/agentic-token-cache.test.ts index 6a71e788..859fc120 100644 --- a/tests/observability/core/agentic-token-cache.test.ts +++ b/tests/observability/extension/tokencache/agentic-token-cache.test.ts @@ -3,54 +3,44 @@ // Licensed under the MIT License. // ------------------------------------------------------------------------------ -import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; -import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability/src/utils/AgenticTokenCache'; -import type { Authorization, TurnContext } from '@microsoft/agents-hosting'; +const { AgenticTokenCacheInstance } = require('@microsoft/agents-a365-observability-tokencache') as { AgenticTokenCacheInstance: any }; -// Minimal stubs -const makeTurnContext = (): TurnContext => ({ activity: { id: 'a1' } } as unknown as TurnContext); +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' } }); 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`; // signature value irrelevant for our decoder + return `${header}.${payload}.sig`; } -function makeAuthorizationMock(sequence: Array<{ token?: string; error?: any }>): Authorization { +function makeAuthorizationMock(sequence: SequenceStep[]): AuthorizationStub { let call = 0; - const authLike = { + 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 } as any; + if (current.error) throw current.error; + return { token: current.token || '' }; }, - // Unused members stubbed to satisfy Authorization interface typing expectations. - getToken: async () => undefined, + getToken: async () => ({ token: 'unused' }), signOut: async () => {}, onSignInSuccess: () => {}, onSignInFailure: () => {} - } as unknown as Authorization; + }; return authLike; } -// Silence logger noise in tests by mocking logger's methods if available -jest.mock('@microsoft/agents-a365-observability/src/utils/logging', () => { - const orig: any = jest.requireActual('@microsoft/agents-a365-observability/src/utils/logging'); - return { - __esModule: true, - default: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn() - }, - formatError: orig.formatError || ((e: unknown) => String(e)) - }; -}); - describe('AgenticTokenCacheInstance', () => { beforeEach(() => { AgenticTokenCacheInstance.invalidateAll(); @@ -68,7 +58,7 @@ describe('AgenticTokenCacheInstance', () => { it('exchanges and caches token on first call', async () => { const token = makeJwtWithExp(300); const auth = makeAuthorizationMock([{ token }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken('agentA', 'tenantA', makeTurnContext(), auth, ['scope.read']); + await AgenticTokenCacheInstance.RefreshObservabilityToken('agentA', 'tenantA', makeTurnContext(), auth as any, ['scope.read']); const tokenReturned = AgenticTokenCacheInstance.getObservabilityToken('agentA', 'tenantA'); expect(tokenReturned).not.toBeNull(); expect(tokenReturned).toBe(token); @@ -77,7 +67,7 @@ describe('AgenticTokenCacheInstance', () => { it('retries on retriable error then succeeds', async () => { const token = makeJwtWithExp(300); const retriableErr = { status: 500, message: 'server error' }; - const sequence: Array<{ token?: string; error?: any }> = [ + const sequence: SequenceStep[] = [ { error: retriableErr }, { token } ]; @@ -86,18 +76,17 @@ describe('AgenticTokenCacheInstance', () => { const current = sequence[Math.min(call, sequence.length - 1)]; call++; if (current.error) throw current.error; - return { token: current.token } as any; + return { token: current.token }; }); - const auth = { + const auth: AuthorizationStub = { exchangeToken: exchangeFn, - getToken: async () => undefined, + getToken: async () => ({ token: 'unused' }), signOut: async () => {}, onSignInSuccess: () => {}, onSignInFailure: () => {} - } as unknown as Authorization; - const p = AgenticTokenCacheInstance.RefreshObservabilityToken('agentB', 'tenantB', makeTurnContext(), auth, ['scope.read']); - // Fast-forward timers to allow retry backoff sleeps (200ms + 400ms linear) - await jest.advanceTimersByTimeAsync(1000); + }; + const p = AgenticTokenCacheInstance.RefreshObservabilityToken('agentB', 'tenantB', makeTurnContext() as any, 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(); @@ -111,26 +100,24 @@ describe('AgenticTokenCacheInstance', () => { { error: nonRetriableErr }, { token: makeJwtWithExp(300) } // should not be used ]); - await AgenticTokenCacheInstance.RefreshObservabilityToken('agentC', 'tenantC', makeTurnContext(), auth, ['scope.read']); + await AgenticTokenCacheInstance.RefreshObservabilityToken('agentC', 'tenantC', makeTurnContext(), auth as any, ['scope.read']); const token = AgenticTokenCacheInstance.getObservabilityToken('agentC', 'tenantC'); expect(token).toBeNull(); }); it('treats near-expiry token as expired (skew refresh)', async () => { - // exp in 30s, skew is 60s -> considered expired immediately const auth = makeAuthorizationMock([{ token: makeJwtWithExp(30) }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken('agentD', 'tenantD', makeTurnContext(), auth, ['scope.read']); + await AgenticTokenCacheInstance.RefreshObservabilityToken('agentD', 'tenantD', makeTurnContext(), auth as any, ['scope.read']); const token = AgenticTokenCacheInstance.getObservabilityToken('agentD', 'tenantD'); - expect(token).toBeNull(); // because isExpired returned true and retrieval logs expiration + expect(token).toBeNull(); }); it('returns cached token before expiry then invalid after advancing time', async () => { - const auth = makeAuthorizationMock([{ token: makeJwtWithExp(120) }]); // 2 minutes - await AgenticTokenCacheInstance.RefreshObservabilityToken('agentE', 'tenantE', makeTurnContext(), auth, ['scope.read']); + const auth = makeAuthorizationMock([{ token: makeJwtWithExp(120) }]); + await AgenticTokenCacheInstance.RefreshObservabilityToken('agentE', 'tenantE', makeTurnContext(), auth as any, ['scope.read']); const tokenBefore = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE'); expect(tokenBefore).not.toBeNull(); - // Advance time just before skew boundary (expire - skew + 1000ms) - jest.advanceTimersByTime(61_000); // move forward > skew (60s) so token becomes expired + jest.advanceTimersByTime(61_000); const tokenAfter = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE'); expect(tokenAfter).toBeNull(); }); diff --git a/tests/package.json b/tests/package.json index dd428804..cae09c25 100644 --- a/tests/package.json +++ b/tests/package.json @@ -35,8 +35,10 @@ }, "dependencies": { "@microsoft/agents-a365-observability": "workspace:*", + "@microsoft/agents-a365-observability-tokencache": "workspace:*", "@microsoft/agents-a365-observability-extensions-openai": "workspace:*", "@microsoft/agents-a365-runtime": "workspace:*", + "@microsoft/agents-hosting": "workspace:*", "@azure/monitor-opentelemetry-exporter": "*", "@modelcontextprotocol/sdk": "*", "@openai/agents": "*", diff --git a/tests/tsconfig.json b/tests/tsconfig.json index d81ada35..43886d63 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -16,7 +16,7 @@ "moduleResolution": "node", "experimentalDecorators": true, "emitDecoratorMetadata": true, - "types": ["jest"] + "types": ["jest", "node"] }, "include": [ "**/*" From 20133be593db731faabbda3cf83dc5889b2051ec Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 18 Nov 2025 17:04:41 -0800 Subject: [PATCH 18/26] readme update --- .../README.md | 88 ++++++++++++++++++- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/agents-a365-observability-tokenCache/README.md b/packages/agents-a365-observability-tokenCache/README.md index 8b2bdec7..39134fce 100644 --- a/packages/agents-a365-observability-tokenCache/README.md +++ b/packages/agents-a365-observability-tokenCache/README.md @@ -1,6 +1,14 @@ # @microsoft/agents-a365-observability-tokencache -Observability token cache utilities for Agent365 SDK. Provides an in-memory cache for observability bearer tokens with early refresh, retry, and per-key serialization. +Observability token cache utilities for the Agent365 SDK. This package provides: + +- In‑memory storage for observability (telemetry/export) bearer tokens +- Early refresh using an expiration skew (default 60s before real expiry) +- Automatic fallback TTL if the token lacks an `exp` claim +- Linear retry on transient failures (timeouts, 5xx, 408, 429) during token exchange +- Per key (agent + tenant) serialization to avoid thundering herds + +> Note: This cache is intentionally lightweight and console‑logged to avoid any circular dependency on the broader observability logging utilities. ## Installation @@ -8,13 +16,87 @@ Observability token cache utilities for Agent365 SDK. Provides an in-memory cach pnpm add @microsoft/agents-a365-observability-tokencache ``` -## Usage +## Core API + +```ts +import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-tokencache'; + + +## Using With Observability Builder (Telemetry Exporter) + +When configuring the observability manager, supply a token resolver. Do **not** pass the method reference directly (it would lose `this`); wrap it to preserve context or use `bind`: + +```ts +import { Builder, ObservabilityManager, Agent365ExporterOptions } from '@microsoft/agents-a365-observability'; +import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-tokencache'; + +export const a365Observability = ObservabilityManager.configure((builder: Builder) => { + const exporterOptions = new Agent365ExporterOptions(); + exporterOptions.maxQueueSize = 10; + + builder + .withService('TypeScript Sample Agent', '1.0.0') + .withClusterCategory('prod') + .withExporterOptions(exporterOptions) + // Wrap to ensure `this` binding (so internal map & methods work). + .withTokenResolver((agentId, tenantId) => AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId)); +}); +``` + +Alternatively: + +```ts +builder.withTokenResolver(AgenticTokenCacheInstance.getObservabilityToken.bind(AgenticTokenCacheInstance)); +``` + +## Example: Preloading in an Agent Turn ```ts import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-tokencache'; +import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runtime'; + +// Inside activity handler: +await AgenticTokenCacheInstance.RefreshObservabilityToken( + agentInfo.agentId, + tenantInfo.tenantId, + context, + agentApplication.authorization, + getObservabilityAuthenticationScope() +); +// Token is now cached (non-blocking if acquisition fails; subsequent resolver will return null until success). +``` + +## Custom Token Resolver Example (Using Application-Level Cache) + +If you prefer to manage the token yourself and only use this cache for retrieval: + +```ts +const tokenResolver = (agentId: string, tenantId: string): string | null => { + const t = AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId); + return t ?? null; +}; -const token = AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId); +builder.withTokenResolver(tokenResolver); ``` +## When to Refresh vs. When to Read + +- Use `RefreshObservabilityToken` when you have access to `TurnContext` and `Authorization` and want to ensure a fresh token is available. +- Use `getObservabilityToken` inside exporters / resolvers where only agent & tenant IDs are available, and you can tolerate `null` (meaning skip authenticated export or wait until later). + +## Handling Expiration + +The cache considers a token expired if: +1. It has an `exp` and current time >= `exp * 1000 - skewMs` (default skew 60s) +2. Or it has no `exp` and current time >= `acquiredOn + maxTokenAgeMs` (default 1h) + +Expired tokens are not returned; they force a refresh on next `RefreshObservabilityToken` call. + +## Error & Retry Behavior + +- Transient errors (timeouts, network issues, 408, 429, 5xx) trigger up to 2 linear backoff retries (200ms, then 400ms). +- Non-retriable errors clear the entry’s token & expiry; subsequent reads return `null` until a successful refresh. +- All events are logged via lightweight console wrappers (info/warn/error). + ## License MIT From 6058c84f6221b232a89d1dddb4e009e174b534fd Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 18 Nov 2025 17:16:57 -0800 Subject: [PATCH 19/26] fix package.json error --- tests-agent/basic-agent-sdk-sample/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests-agent/basic-agent-sdk-sample/package.json b/tests-agent/basic-agent-sdk-sample/package.json index 0f3e2493..892283eb 100644 --- a/tests-agent/basic-agent-sdk-sample/package.json +++ b/tests-agent/basic-agent-sdk-sample/package.json @@ -20,7 +20,7 @@ "@microsoft/agents-a365-runtime": "workspace:*", "dotenv": "^17.2.2", "express": "^5.1.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", "@microsoft/agents-a365-observability-tokencache": "workspace:*", "dotenv": "*", "express": "*", From 1149e09b3c23657f0bdda609506e5c816b289105 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 18 Nov 2025 18:11:15 -0800 Subject: [PATCH 20/26] fix logging --- .../package.json | 3 +- .../src/AgenticTokenCache.ts | 202 +++++++++++++----- .../agents-a365-observability/src/index.ts | 1 + .../src/utils/logging.ts | 2 +- pnpm-lock.yaml | 3 + 5 files changed, 159 insertions(+), 52 deletions(-) diff --git a/packages/agents-a365-observability-tokenCache/package.json b/packages/agents-a365-observability-tokenCache/package.json index 4caa0da6..b65e5eb0 100644 --- a/packages/agents-a365-observability-tokenCache/package.json +++ b/packages/agents-a365-observability-tokenCache/package.json @@ -43,7 +43,8 @@ }, "dependencies": { "@microsoft/agents-hosting": "workspace:*", - "@microsoft/agents-a365-runtime": "workspace:*" + "@microsoft/agents-a365-runtime": "workspace:*", + "@microsoft/agents-a365-observability": "workspace:*" }, "devDependencies": { "@types/jest": "^29.5.12", diff --git a/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts b/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts index 116ebb80..2ad4d17a 100644 --- a/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts +++ b/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts @@ -5,18 +5,7 @@ import { TurnContext, Authorization } from '@microsoft/agents-hosting'; import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runtime'; -// We intentionally do not depend on the observability package logger to avoid circular dependency; -// lightweight console wrappers are used instead. Adjust if centralized logging is required. -const logger = { - info: (...a: unknown[]) => console.info(...a), - warn: (...a: unknown[]) => console.warn(...a), - error: (...a: unknown[]) => console.error(...a) -}; - -function formatError(e: unknown): string { - if (e instanceof Error) { return `${e.name}: ${e.message}`; } - return String(e); -} +import { logger, formatError } from '@microsoft/agents-a365-observability/logging'; interface CacheEntry { scopes: string[]; @@ -30,14 +19,26 @@ class AgenticTokenCache { private readonly _defaultRefreshSkewMs = 60_000; private readonly _defaultMaxTokenAgeMs = 3_600_000; private readonly _keyLocks = new Map>(); - private makeKey(agentId: string, tenantId: string): string { return `${agentId}:${tenantId}`; } + + private makeKey(agentId: string, tenantId: string): string { + return `${agentId}:${tenantId}`; + } public getObservabilityToken(agentId: string, tenantId: string): string | null { const key = this.makeKey(agentId, tenantId); const entry = this._map.get(key); - if (!entry) { logger.error(`[AgenticTokenCache] No cache entry for ${key}`); return null; } - if (!entry.token) { logger.error(`[AgenticTokenCache] No token cached for ${key}`); return null; } - if (this.isExpired(entry)) { logger.error(`[AgenticTokenCache] Token expired for ${key}`); return null; } + if (!entry) { + logger.error(`[AgenticTokenCache] No cache entry for ${key}`); + return null; + } + if (!entry.token) { + logger.error(`[AgenticTokenCache] No token cached for ${key}`); + return null; + } + if (this.isExpired(entry)) { + logger.error(`[AgenticTokenCache] Token expired for ${key}`); + return null; + } return entry.token; } @@ -49,56 +50,157 @@ class AgenticTokenCache { scopes: string[] ): Promise { const key = this.makeKey(agentId, tenantId); - if (!authorization) { logger.error('[AgenticTokenCache] Authorization not set'); return; } - if (!turnContext) { logger.error('[AgenticTokenCache] TurnContext not set'); return; } + if (!authorization) { + logger.error('[AgenticTokenCache] Authorization not set'); + return; + } + if (!turnContext) { + logger.error('[AgenticTokenCache] TurnContext not set'); + return; + } return this.withKeyLock(key, async () => { let entry = this._map.get(key); if (!entry) { const effectiveScopes = (scopes && scopes.length > 0) ? scopes : getObservabilityAuthenticationScope(); - if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) { logger.error('[AgenticTokenCache] No valid scopes'); return; } + if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) { + logger.error('[AgenticTokenCache] No valid scopes'); + return; + } entry = { scopes: effectiveScopes }; this._map.set(key, entry); } - if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) { logger.error('[AgenticTokenCache] Entry has invalid scopes'); return; } - try { - if (entry.token && !this.isExpired(entry)) { return; } - const maxRetries = 2; - for (let attempt = 0; attempt <= maxRetries; attempt++) { - logger.info(`[AgenticTokenCache] Exchanging token attempt ${attempt + 1}/${maxRetries + 1}`); - try { - const tokenResponse = await authorization.exchangeToken(turnContext, 'agentic', { scopes: entry.scopes }); - if (!tokenResponse?.token) { logger.error('[AgenticTokenCache] Undefined token returned'); entry.token = undefined; entry.expiresOn = undefined; break; } - entry.token = tokenResponse.token; - entry.acquiredOn = Date.now(); - const oboExp = this.decodeExp(entry.token); - if (oboExp) { entry.expiresOn = oboExp * 1000; } else { logger.warn('[AgenticTokenCache] No exp claim, fallback TTL'); } - logger.info('[AgenticTokenCache] Token cached'); - return; - } catch (e) { - const retriable = this.isRetriableError(e); - if (retriable && attempt < maxRetries) { logger.warn(`[AgenticTokenCache] Retriable failure attempt ${attempt + 1}`, formatError(e)); await this.sleep(200 * (attempt + 1)); continue; } - logger.error('[AgenticTokenCache] Non-retriable failure', formatError(e)); entry.token = undefined; entry.expiresOn = undefined; break; - } + if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) { + logger.error('[AgenticTokenCache] Entry has invalid scopes'); + return; + } + + if (entry.token && !this.isExpired(entry)) { + return; + } + + const maxRetries = 2; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + logger.info(`[AgenticTokenCache] Exchanging token attempt ${attempt + 1}/${maxRetries + 1}`); + try { + const tokenResponse = await authorization.exchangeToken(turnContext, 'agentic', { scopes: entry.scopes }); + if (!tokenResponse?.token) { + logger.error('[AgenticTokenCache] Undefined token returned'); + entry.token = undefined; + entry.expiresOn = undefined; + break; + } + entry.token = tokenResponse.token; + entry.acquiredOn = Date.now(); + const oboExp = this.decodeExp(entry.token); + if (oboExp) { + entry.expiresOn = oboExp * 1000; + } else { + logger.warn('[AgenticTokenCache] No exp claim, fallback TTL'); + } + logger.info('[AgenticTokenCache] Token cached'); + return; + } catch (e) { + const retriable = this.isRetriableError(e); + if (retriable && attempt < maxRetries) { + logger.warn(`[AgenticTokenCache] Retriable failure attempt ${attempt + 1}`, formatError(e)); + await this.sleep(200 * (attempt + 1)); + continue; + } + logger.error('[AgenticTokenCache] Non-retriable failure', formatError(e)); + entry.token = undefined; + entry.expiresOn = undefined; + break; } - } catch (e) { - logger.error('[AgenticTokenCache] Unexpected failure', formatError(e)); entry.token = undefined; entry.expiresOn = undefined; } }); } - invalidateToken(agentId: string, tenantId: string): void { const entry = this._map.get(this.makeKey(agentId, tenantId)); if (entry) { entry.token = undefined; entry.expiresOn = undefined; } } - invalidateAll(): void { this._map.clear(); } + public invalidateToken(agentId: string, tenantId: string): void { + const entry = this._map.get(this.makeKey(agentId, tenantId)); + if (entry) { + entry.token = undefined; + entry.expiresOn = undefined; + } + } + + public invalidateAll(): void { + this._map.clear(); + } private decodeExp(jwt: string): number | undefined { - try { if (!jwt) return undefined; const parts = jwt.split('.'); if (parts.length < 2) return undefined; const payload = parts[1] + '='.repeat((4 - (parts[1].length % 4)) % 4); const json = JSON.parse(Buffer.from(payload, 'base64').toString('utf8')) as { exp?: unknown }; return typeof json.exp === 'number' ? json.exp : undefined; } catch { return undefined; } + try { + if (!jwt) { + return undefined; + } + const parts = jwt.split('.'); + if (parts.length < 2) { + return undefined; + } + const payloadSegment = parts[1]; + const padded = payloadSegment + '='.repeat((4 - (payloadSegment.length % 4)) % 4); + const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as { exp?: unknown }; + return typeof json.exp === 'number' ? json.exp : undefined; + } catch { + return undefined; + } + } + + private isExpired(entry: CacheEntry): boolean { + const now = Date.now(); + if (entry.expiresOn) { + return now >= (entry.expiresOn - this._defaultRefreshSkewMs); + } + if (entry.acquiredOn) { + return now >= (entry.acquiredOn + this._defaultMaxTokenAgeMs); + } + return true; + } + + private isRetriableError(err: unknown): boolean { + const e = err as { code?: string; status?: number; message?: string } | undefined; + if (!e) { + return false; + } + const msg = (e.message || '').toLowerCase(); + if (msg.includes('timeout') || msg.includes('econnreset') || msg.includes('network')) { + return true; + } + if (typeof e.status === 'number') { + if (e.status === 408 || e.status === 429) { + return true; + } + if (e.status >= 500 && e.status < 600) { + return true; + } + } + return false; + } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private async withKeyLock(key: string, fn: () => Promise): Promise { + const previous = this._keyLocks.get(key); + if (previous) { + try { + await previous; + } catch (err) { + logger.warn(`[AgenticTokenCache] previous promise for ${key} rejected:`, formatError(err)); + } + } + const currentPromise: Promise = fn().finally(() => { + if (this._keyLocks.get(key) === currentPromise) { + this._keyLocks.delete(key); + } + }); + this._keyLocks.set(key, currentPromise); + return currentPromise; } - private isExpired(entry: CacheEntry): boolean { const now = Date.now(); if (entry.expiresOn) return now >= (entry.expiresOn - this._defaultRefreshSkewMs); if (entry.acquiredOn) return now >= (entry.acquiredOn + this._defaultMaxTokenAgeMs); return true; } - private isRetriableError(err: unknown): boolean { const e = err as { code?: string; status?: number; message?: string } | undefined; if (!e) return false; const msg = (e.message || '').toLowerCase(); if (msg.includes('timeout') || msg.includes('econnreset') || msg.includes('network')) return true; if (typeof e.status === 'number') { if (e.status === 408 || e.status === 429) return true; if (e.status >= 500 && e.status < 600) return true; } return false; } - private sleep(ms: number): Promise { return new Promise(r => setTimeout(r, ms)); } - private async withKeyLock(key: string, fn: () => Promise): Promise { const previous = this._keyLocks.get(key); if (previous) { try { await previous; } catch (err) { logger.warn(`[AgenticTokenCache] previous promise for ${key} rejected:`, formatError(err)); } } const currentPromise: Promise = fn().finally(() => { if (this._keyLocks.get(key) === currentPromise) { this._keyLocks.delete(key); } }); this._keyLocks.set(key, currentPromise); return currentPromise; } } -export function createAgenticTokenCacheKey(agentId: string, tenantId: string): string { return `${agentId}:${tenantId}`; } +export function createAgenticTokenCacheKey(agentId: string, tenantId: string): string { + return `${agentId}:${tenantId}`; +} export const AgenticTokenCacheInstance = new AgenticTokenCache(); - export default AgenticTokenCacheInstance; diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index 80235c87..85b7d975 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -35,3 +35,4 @@ export { OpenTelemetryScope } from './tracing/scopes/OpenTelemetryScope'; export { ExecuteToolScope } from './tracing/scopes/ExecuteToolScope'; export { InvokeAgentScope } from './tracing/scopes/InvokeAgentScope'; export { InferenceScope} from './tracing/scopes/InferenceScope'; +export { logger, formatError } from './utils/logging'; diff --git a/packages/agents-a365-observability/src/utils/logging.ts b/packages/agents-a365-observability/src/utils/logging.ts index bc00c540..1e852514 100644 --- a/packages/agents-a365-observability/src/utils/logging.ts +++ b/packages/agents-a365-observability/src/utils/logging.ts @@ -69,7 +69,7 @@ function parseLogLevel(level: string): Set { const enabledLogLevels = parseLogLevel(process.env.A365_OBSERVABILITY_LOG_LEVEL || 'none'); -const logger = { +export const logger = { info: (message: string, ...args: unknown[]) => { if (enabledLogLevels.has(LOG_LEVELS.info)) { // eslint-disable-next-line no-console diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c1a87c7..d637c946 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: packages/agents-a365-observability-tokenCache: dependencies: + '@microsoft/agents-a365-observability': + specifier: workspace:* + version: link:../agents-a365-observability '@microsoft/agents-a365-runtime': specifier: workspace:* version: link:../agents-a365-runtime From 5e3d93b9fe3ea69968b8c40fe0d55dc2a1a06362 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 18 Nov 2025 18:46:56 -0800 Subject: [PATCH 21/26] lint,jest config --- .../jest.config.cjs | 6 + .../package.json | 2 +- .../src/.eslintrc.json | 32 ++ .../src/AgenticTokenCache.ts | 376 +++++++++--------- 4 files changed, 227 insertions(+), 189 deletions(-) create mode 100644 packages/agents-a365-observability-tokenCache/jest.config.cjs create mode 100644 packages/agents-a365-observability-tokenCache/src/.eslintrc.json diff --git a/packages/agents-a365-observability-tokenCache/jest.config.cjs b/packages/agents-a365-observability-tokenCache/jest.config.cjs new file mode 100644 index 00000000..b3c6a1b5 --- /dev/null +++ b/packages/agents-a365-observability-tokenCache/jest.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testPathIgnorePatterns: ['/integration/'], + clearMocks: true, +}; diff --git a/packages/agents-a365-observability-tokenCache/package.json b/packages/agents-a365-observability-tokenCache/package.json index b65e5eb0..6f1b8caf 100644 --- a/packages/agents-a365-observability-tokenCache/package.json +++ b/packages/agents-a365-observability-tokenCache/package.json @@ -36,7 +36,7 @@ "clean": "npx rimraf dist", "lint": "eslint src/**/*.ts", "lint:fix": "eslint src/**/*.ts --fix", - "test": "jest", + "test": "jest --config ./jest.config.cjs --passWithNoTests", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "pack": "npm pack --pack-destination=../" diff --git a/packages/agents-a365-observability-tokenCache/src/.eslintrc.json b/packages/agents-a365-observability-tokenCache/src/.eslintrc.json new file mode 100644 index 00000000..861f3062 --- /dev/null +++ b/packages/agents-a365-observability-tokenCache/src/.eslintrc.json @@ -0,0 +1,32 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "extends": ["eslint:recommended"], + "rules": { + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error", { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + }], + "@typescript-eslint/no-explicit-any": "error", + "prefer-const": "error", + "no-var": "error", + "no-console": "error", + "semi": ["error", "always"], + "quotes": ["error", "single"], + "indent": ["error", 4], + "no-trailing-spaces": "error" + }, + "env": { + "node": true, + "es6": true, + "jest": true + }, + "ignorePatterns": ["dist/**/*", "node_modules/**/*", "*.js"] +} diff --git a/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts b/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts index 2ad4d17a..56568d31 100644 --- a/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts +++ b/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts @@ -5,202 +5,202 @@ import { TurnContext, Authorization } from '@microsoft/agents-hosting'; import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runtime'; -import { logger, formatError } from '@microsoft/agents-a365-observability/logging'; +import { logger, formatError } from '@microsoft/agents-a365-observability'; interface CacheEntry { - scopes: string[]; - token?: string; - expiresOn?: number; - acquiredOn?: number; + scopes: string[]; + token?: string; + expiresOn?: number; + acquiredOn?: number; } class AgenticTokenCache { - private readonly _map = new Map(); - private readonly _defaultRefreshSkewMs = 60_000; - private readonly _defaultMaxTokenAgeMs = 3_600_000; - private readonly _keyLocks = new Map>(); - - private makeKey(agentId: string, tenantId: string): string { - return `${agentId}:${tenantId}`; - } - - public getObservabilityToken(agentId: string, tenantId: string): string | null { - const key = this.makeKey(agentId, tenantId); - const entry = this._map.get(key); - if (!entry) { - logger.error(`[AgenticTokenCache] No cache entry for ${key}`); - return null; - } - if (!entry.token) { - logger.error(`[AgenticTokenCache] No token cached for ${key}`); - return null; - } - if (this.isExpired(entry)) { - logger.error(`[AgenticTokenCache] Token expired for ${key}`); - return null; - } - return entry.token; - } - - public async RefreshObservabilityToken( - agentId: string, - tenantId: string, - turnContext: TurnContext, - authorization: Authorization, - scopes: string[] - ): Promise { - const key = this.makeKey(agentId, tenantId); - if (!authorization) { - logger.error('[AgenticTokenCache] Authorization not set'); - return; - } - if (!turnContext) { - logger.error('[AgenticTokenCache] TurnContext not set'); - return; - } - return this.withKeyLock(key, async () => { - let entry = this._map.get(key); - if (!entry) { - const effectiveScopes = (scopes && scopes.length > 0) ? scopes : getObservabilityAuthenticationScope(); - if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) { - logger.error('[AgenticTokenCache] No valid scopes'); - return; - } - entry = { scopes: effectiveScopes }; - this._map.set(key, entry); - } - if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) { - logger.error('[AgenticTokenCache] Entry has invalid scopes'); - return; - } - - if (entry.token && !this.isExpired(entry)) { - return; - } - - const maxRetries = 2; - for (let attempt = 0; attempt <= maxRetries; attempt++) { - logger.info(`[AgenticTokenCache] Exchanging token attempt ${attempt + 1}/${maxRetries + 1}`); - try { - const tokenResponse = await authorization.exchangeToken(turnContext, 'agentic', { scopes: entry.scopes }); - if (!tokenResponse?.token) { - logger.error('[AgenticTokenCache] Undefined token returned'); - entry.token = undefined; - entry.expiresOn = undefined; - break; - } - entry.token = tokenResponse.token; - entry.acquiredOn = Date.now(); - const oboExp = this.decodeExp(entry.token); - if (oboExp) { - entry.expiresOn = oboExp * 1000; - } else { - logger.warn('[AgenticTokenCache] No exp claim, fallback TTL'); - } - logger.info('[AgenticTokenCache] Token cached'); - return; - } catch (e) { - const retriable = this.isRetriableError(e); - if (retriable && attempt < maxRetries) { - logger.warn(`[AgenticTokenCache] Retriable failure attempt ${attempt + 1}`, formatError(e)); - await this.sleep(200 * (attempt + 1)); - continue; - } - logger.error('[AgenticTokenCache] Non-retriable failure', formatError(e)); - entry.token = undefined; - entry.expiresOn = undefined; - break; - } - } - }); - } - - public invalidateToken(agentId: string, tenantId: string): void { - const entry = this._map.get(this.makeKey(agentId, tenantId)); - if (entry) { - entry.token = undefined; - entry.expiresOn = undefined; - } - } - - public invalidateAll(): void { - this._map.clear(); - } - - private decodeExp(jwt: string): number | undefined { - try { - if (!jwt) { - return undefined; - } - const parts = jwt.split('.'); - if (parts.length < 2) { - return undefined; - } - const payloadSegment = parts[1]; - const padded = payloadSegment + '='.repeat((4 - (payloadSegment.length % 4)) % 4); - const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as { exp?: unknown }; - return typeof json.exp === 'number' ? json.exp : undefined; - } catch { - return undefined; - } - } - - private isExpired(entry: CacheEntry): boolean { - const now = Date.now(); - if (entry.expiresOn) { - return now >= (entry.expiresOn - this._defaultRefreshSkewMs); - } - if (entry.acquiredOn) { - return now >= (entry.acquiredOn + this._defaultMaxTokenAgeMs); - } - return true; - } - - private isRetriableError(err: unknown): boolean { - const e = err as { code?: string; status?: number; message?: string } | undefined; - if (!e) { - return false; - } - const msg = (e.message || '').toLowerCase(); - if (msg.includes('timeout') || msg.includes('econnreset') || msg.includes('network')) { - return true; - } - if (typeof e.status === 'number') { - if (e.status === 408 || e.status === 429) { - return true; - } - if (e.status >= 500 && e.status < 600) { - return true; - } - } - return false; - } - - private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - private async withKeyLock(key: string, fn: () => Promise): Promise { - const previous = this._keyLocks.get(key); - if (previous) { - try { - await previous; - } catch (err) { - logger.warn(`[AgenticTokenCache] previous promise for ${key} rejected:`, formatError(err)); - } - } - const currentPromise: Promise = fn().finally(() => { - if (this._keyLocks.get(key) === currentPromise) { - this._keyLocks.delete(key); - } - }); - this._keyLocks.set(key, currentPromise); - return currentPromise; - } + private readonly _map = new Map(); + private readonly _defaultRefreshSkewMs = 60_000; + private readonly _defaultMaxTokenAgeMs = 3_600_000; + private readonly _keyLocks = new Map>(); + + private makeKey(agentId: string, tenantId: string): string { + return `${agentId}:${tenantId}`; + } + + public getObservabilityToken(agentId: string, tenantId: string): string | null { + const key = this.makeKey(agentId, tenantId); + const entry = this._map.get(key); + if (!entry) { + logger.error(`[AgenticTokenCache] No cache entry for ${key}`); + return null; + } + if (!entry.token) { + logger.error(`[AgenticTokenCache] No token cached for ${key}`); + return null; + } + if (this.isExpired(entry)) { + logger.error(`[AgenticTokenCache] Token expired for ${key}`); + return null; + } + return entry.token; + } + + public async RefreshObservabilityToken( + agentId: string, + tenantId: string, + turnContext: TurnContext, + authorization: Authorization, + scopes: string[] + ): Promise { + const key = this.makeKey(agentId, tenantId); + if (!authorization) { + logger.error('[AgenticTokenCache] Authorization not set'); + return; + } + if (!turnContext) { + logger.error('[AgenticTokenCache] TurnContext not set'); + return; + } + return this.withKeyLock(key, async () => { + let entry = this._map.get(key); + if (!entry) { + const effectiveScopes = (scopes && scopes.length > 0) ? scopes : getObservabilityAuthenticationScope(); + if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) { + logger.error('[AgenticTokenCache] No valid scopes'); + return; + } + entry = { scopes: effectiveScopes }; + this._map.set(key, entry); + } + if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) { + logger.error('[AgenticTokenCache] Entry has invalid scopes'); + return; + } + + if (entry.token && !this.isExpired(entry)) { + return; + } + + const maxRetries = 2; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + logger.info(`[AgenticTokenCache] Exchanging token attempt ${attempt + 1}/${maxRetries + 1}`); + try { + const tokenResponse = await authorization.exchangeToken(turnContext, 'agentic', { scopes: entry.scopes }); + if (!tokenResponse?.token) { + logger.error('[AgenticTokenCache] Undefined token returned'); + entry.token = undefined; + entry.expiresOn = undefined; + break; + } + entry.token = tokenResponse.token; + entry.acquiredOn = Date.now(); + const oboExp = this.decodeExp(entry.token); + if (oboExp) { + entry.expiresOn = oboExp * 1000; + } else { + logger.warn('[AgenticTokenCache] No exp claim, fallback TTL'); + } + logger.info('[AgenticTokenCache] Token cached'); + return; + } catch (e) { + const retriable = this.isRetriableError(e); + if (retriable && attempt < maxRetries) { + logger.warn(`[AgenticTokenCache] Retriable failure attempt ${attempt + 1}`, formatError(e)); + await this.sleep(200 * (attempt + 1)); + continue; + } + logger.error('[AgenticTokenCache] Non-retriable failure', formatError(e)); + entry.token = undefined; + entry.expiresOn = undefined; + break; + } + } + }); + } + + public invalidateToken(agentId: string, tenantId: string): void { + const entry = this._map.get(this.makeKey(agentId, tenantId)); + if (entry) { + entry.token = undefined; + entry.expiresOn = undefined; + } + } + + public invalidateAll(): void { + this._map.clear(); + } + + private decodeExp(jwt: string): number | undefined { + try { + if (!jwt) { + return undefined; + } + const parts = jwt.split('.'); + if (parts.length < 2) { + return undefined; + } + const payloadSegment = parts[1]; + const padded = payloadSegment + '='.repeat((4 - (payloadSegment.length % 4)) % 4); + const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as { exp?: unknown }; + return typeof json.exp === 'number' ? json.exp : undefined; + } catch { + return undefined; + } + } + + private isExpired(entry: CacheEntry): boolean { + const now = Date.now(); + if (entry.expiresOn) { + return now >= (entry.expiresOn - this._defaultRefreshSkewMs); + } + if (entry.acquiredOn) { + return now >= (entry.acquiredOn + this._defaultMaxTokenAgeMs); + } + return true; + } + + private isRetriableError(err: unknown): boolean { + const e = err as { code?: string; status?: number; message?: string } | undefined; + if (!e) { + return false; + } + const msg = (e.message || '').toLowerCase(); + if (msg.includes('timeout') || msg.includes('econnreset') || msg.includes('network')) { + return true; + } + if (typeof e.status === 'number') { + if (e.status === 408 || e.status === 429) { + return true; + } + if (e.status >= 500 && e.status < 600) { + return true; + } + } + return false; + } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private async withKeyLock(key: string, fn: () => Promise): Promise { + const previous = this._keyLocks.get(key); + if (previous) { + try { + await previous; + } catch (err) { + logger.warn(`[AgenticTokenCache] previous promise for ${key} rejected:`, formatError(err)); + } + } + const currentPromise: Promise = fn().finally(() => { + if (this._keyLocks.get(key) === currentPromise) { + this._keyLocks.delete(key); + } + }); + this._keyLocks.set(key, currentPromise); + return currentPromise; + } } export function createAgenticTokenCacheKey(agentId: string, tenantId: string): string { - return `${agentId}:${tenantId}`; + return `${agentId}:${tenantId}`; } export const AgenticTokenCacheInstance = new AgenticTokenCache(); export default AgenticTokenCacheInstance; From 55b5536c5eb38e50f40cc0f36b80a2320e600a1f Mon Sep 17 00:00:00 2001 From: jsl517 Date: Tue, 18 Nov 2025 19:04:58 -0800 Subject: [PATCH 22/26] cleanup --- .../src/AgenticTokenCache.ts | 11 ++++------- .../agents-a365-observability-tokenCache/src/index.ts | 2 +- tests-agent/basic-agent-sdk-sample/package.json | 5 +---- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts b/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts index 56568d31..7e4edbf3 100644 --- a/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts +++ b/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts @@ -20,12 +20,12 @@ class AgenticTokenCache { private readonly _defaultMaxTokenAgeMs = 3_600_000; private readonly _keyLocks = new Map>(); - private makeKey(agentId: string, tenantId: string): string { + public static makeKey(agentId: string, tenantId: string): string { return `${agentId}:${tenantId}`; } public getObservabilityToken(agentId: string, tenantId: string): string | null { - const key = this.makeKey(agentId, tenantId); + const key = AgenticTokenCache.makeKey(agentId, tenantId); const entry = this._map.get(key); if (!entry) { logger.error(`[AgenticTokenCache] No cache entry for ${key}`); @@ -49,7 +49,7 @@ class AgenticTokenCache { authorization: Authorization, scopes: string[] ): Promise { - const key = this.makeKey(agentId, tenantId); + const key = AgenticTokenCache.makeKey(agentId, tenantId); if (!authorization) { logger.error('[AgenticTokenCache] Authorization not set'); return; @@ -116,7 +116,7 @@ class AgenticTokenCache { } public invalidateToken(agentId: string, tenantId: string): void { - const entry = this._map.get(this.makeKey(agentId, tenantId)); + const entry = this._map.get(AgenticTokenCache.makeKey(agentId, tenantId)); if (entry) { entry.token = undefined; entry.expiresOn = undefined; @@ -199,8 +199,5 @@ class AgenticTokenCache { } } -export function createAgenticTokenCacheKey(agentId: string, tenantId: string): string { - return `${agentId}:${tenantId}`; -} export const AgenticTokenCacheInstance = new AgenticTokenCache(); export default AgenticTokenCacheInstance; diff --git a/packages/agents-a365-observability-tokenCache/src/index.ts b/packages/agents-a365-observability-tokenCache/src/index.ts index 4c7931b8..d52e0fef 100644 --- a/packages/agents-a365-observability-tokenCache/src/index.ts +++ b/packages/agents-a365-observability-tokenCache/src/index.ts @@ -3,4 +3,4 @@ // Licensed under the MIT License. // ------------------------------------------------------------------------------ -export { AgenticTokenCacheInstance, createAgenticTokenCacheKey } from './AgenticTokenCache'; +export { AgenticTokenCacheInstance } from './AgenticTokenCache'; diff --git a/tests-agent/basic-agent-sdk-sample/package.json b/tests-agent/basic-agent-sdk-sample/package.json index 892283eb..01d96685 100644 --- a/tests-agent/basic-agent-sdk-sample/package.json +++ b/tests-agent/basic-agent-sdk-sample/package.json @@ -21,10 +21,7 @@ "dotenv": "^17.2.2", "express": "^5.1.0", "uuid": "^9.0.0", - "@microsoft/agents-a365-observability-tokencache": "workspace:*", - "dotenv": "*", - "express": "*", - "uuid": "*" + "@microsoft/agents-a365-observability-tokencache": "workspace:*" }, "devDependencies": { "@microsoft/m365agentsplayground": "^0.2.18", From cf3125468c9c58650f45bf216eb9c21fdecd90b5 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Wed, 19 Nov 2025 10:41:52 -0800 Subject: [PATCH 23/26] comment --- packages/agents-a365-observability-tokenCache/README.md | 4 +--- .../agents-a365-observability-tokenCache/package.json | 2 +- .../src/AgenticTokenCache.ts | 8 +++----- .../src/tracing/exporter/Agent365ExporterOptions.ts | 7 ++----- pnpm-lock.yaml | 2 +- tests-agent/basic-agent-sdk-sample/src/telemetry.ts | 2 +- .../core/observabilityBuilder-options.test.ts | 2 +- 7 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/agents-a365-observability-tokenCache/README.md b/packages/agents-a365-observability-tokenCache/README.md index 39134fce..5da274ee 100644 --- a/packages/agents-a365-observability-tokenCache/README.md +++ b/packages/agents-a365-observability-tokenCache/README.md @@ -8,8 +8,6 @@ Observability token cache utilities for the Agent365 SDK. This package provides: - Linear retry on transient failures (timeouts, 5xx, 408, 429) during token exchange - Per key (agent + tenant) serialization to avoid thundering herds -> Note: This cache is intentionally lightweight and console‑logged to avoid any circular dependency on the broader observability logging utilities. - ## Installation ```bash @@ -20,7 +18,7 @@ pnpm add @microsoft/agents-a365-observability-tokencache ```ts import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-tokencache'; - +``` ## Using With Observability Builder (Telemetry Exporter) diff --git a/packages/agents-a365-observability-tokenCache/package.json b/packages/agents-a365-observability-tokenCache/package.json index 6f1b8caf..cba7868c 100644 --- a/packages/agents-a365-observability-tokenCache/package.json +++ b/packages/agents-a365-observability-tokenCache/package.json @@ -16,7 +16,7 @@ "repository": { "type": "git", "url": "https://github.com/microsoft/Agent365-nodejs.git", - "directory": "packages/agents-a365-observability-tokenCache" + "directory": "packages/agents-a365-observability-tokencache" }, "license": "MIT", "author": "Microsoft", diff --git a/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts b/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts index 7e4edbf3..02a942a8 100644 --- a/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts +++ b/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts @@ -51,13 +51,11 @@ class AgenticTokenCache { ): Promise { const key = AgenticTokenCache.makeKey(agentId, tenantId); if (!authorization) { - logger.error('[AgenticTokenCache] Authorization not set'); - return; + throw new Error('[AgenticTokenCache] Authorization not set'); } if (!turnContext) { - logger.error('[AgenticTokenCache] TurnContext not set'); - return; - } + throw new Error('[AgenticTokenCache] TurnContext not set'); + } return this.withKeyLock(key, async () => { let entry = this._map.get(key); if (!entry) { diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index 2b7d60a0..c8d7d5ef 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -31,11 +31,8 @@ export class Agent365ExporterOptions { /** Environment / cluster category (e.g. "preprod", "prod"). */ public clusterCategory: ClusterCategory | string = 'prod'; - /** Optional delegate to resolve auth token; falls back to AgenticTokenCache when absent. */ - public tokenResolver?: TokenResolver; // A tokenResolver MUST be provided by callers. - - /** Use service-to-service endpoint variant when true; standard endpoint when false. */ - public useS2SEndpoint: boolean = false; + /** Optional delegate to resolve auth token used by exporter */ + public tokenResolver?: TokenResolver; // Optional if ENABLE_A365_OBSERVABILITY_EXPORTER is false /** Maximum span queue size before new spans are dropped. */ public maxQueueSize: number = 2048; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d637c946..17b99e04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,7 +193,7 @@ importers: specifier: ^5.6.0 version: 5.9.3 - packages/agents-a365-observability-tokenCache: + packages/agents-a365-observability-tokencache: dependencies: '@microsoft/agents-a365-observability': specifier: workspace:* diff --git a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts index 6e6a34ba..f2871989 100644 --- a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts +++ b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts @@ -53,7 +53,7 @@ export const a365Observability = ObservabilityManager.configure((builder: Builde builder.withTokenResolver(tokenResolver); } else { - // use resolver from azure token package + // use resolver from observability token cache package builder.withTokenResolver((agentId: string, tenantId: string) => AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId)); } }); diff --git a/tests/observability/core/observabilityBuilder-options.test.ts b/tests/observability/core/observabilityBuilder-options.test.ts index f4486e45..aa834ee8 100644 --- a/tests/observability/core/observabilityBuilder-options.test.ts +++ b/tests/observability/core/observabilityBuilder-options.test.ts @@ -12,7 +12,7 @@ jest.mock('@microsoft/agents-a365-observability/dist/cjs/tracing/exporter/Agent3 constructor(opts: any) { // Capture the options passed from ObservabilityBuilder (global as any).__capturedExporterOptions = opts; - ;(global as any).__capturedExporterOptionsCallCount = ((global as any).__capturedExporterOptionsCallCount || 0) + 1; + (global as any).__capturedExporterOptionsCallCount = ((global as any).__capturedExporterOptionsCallCount || 0) + 1; (this.constructor as any).lastOptions = opts; } export() {/* no-op */} From 142ec287d11bb95a074dd0c14a16ede8a26bf9e2 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Wed, 19 Nov 2025 10:57:13 -0800 Subject: [PATCH 24/26] rename --- .../README.md | 0 .../jest.config.cjs | 0 .../package.json | 0 .../src/.eslintrc.json | 0 .../src/AgenticTokenCache.ts | 0 .../src/index.ts | 0 .../tsconfig.cjs.json | 0 .../tsconfig.esm.json | 0 .../tsconfig.json | 0 pnpm-lock.yaml | 4 ++-- 10 files changed, 2 insertions(+), 2 deletions(-) rename packages/{agents-a365-observability-tokenCache => agents-a365-observability-tokencache}/README.md (100%) rename packages/{agents-a365-observability-tokenCache => agents-a365-observability-tokencache}/jest.config.cjs (100%) rename packages/{agents-a365-observability-tokenCache => agents-a365-observability-tokencache}/package.json (100%) rename packages/{agents-a365-observability-tokenCache => agents-a365-observability-tokencache}/src/.eslintrc.json (100%) rename packages/{agents-a365-observability-tokenCache => agents-a365-observability-tokencache}/src/AgenticTokenCache.ts (100%) rename packages/{agents-a365-observability-tokenCache => agents-a365-observability-tokencache}/src/index.ts (100%) rename packages/{agents-a365-observability-tokenCache => agents-a365-observability-tokencache}/tsconfig.cjs.json (100%) rename packages/{agents-a365-observability-tokenCache => agents-a365-observability-tokencache}/tsconfig.esm.json (100%) rename packages/{agents-a365-observability-tokenCache => agents-a365-observability-tokencache}/tsconfig.json (100%) diff --git a/packages/agents-a365-observability-tokenCache/README.md b/packages/agents-a365-observability-tokencache/README.md similarity index 100% rename from packages/agents-a365-observability-tokenCache/README.md rename to packages/agents-a365-observability-tokencache/README.md diff --git a/packages/agents-a365-observability-tokenCache/jest.config.cjs b/packages/agents-a365-observability-tokencache/jest.config.cjs similarity index 100% rename from packages/agents-a365-observability-tokenCache/jest.config.cjs rename to packages/agents-a365-observability-tokencache/jest.config.cjs diff --git a/packages/agents-a365-observability-tokenCache/package.json b/packages/agents-a365-observability-tokencache/package.json similarity index 100% rename from packages/agents-a365-observability-tokenCache/package.json rename to packages/agents-a365-observability-tokencache/package.json diff --git a/packages/agents-a365-observability-tokenCache/src/.eslintrc.json b/packages/agents-a365-observability-tokencache/src/.eslintrc.json similarity index 100% rename from packages/agents-a365-observability-tokenCache/src/.eslintrc.json rename to packages/agents-a365-observability-tokencache/src/.eslintrc.json diff --git a/packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts b/packages/agents-a365-observability-tokencache/src/AgenticTokenCache.ts similarity index 100% rename from packages/agents-a365-observability-tokenCache/src/AgenticTokenCache.ts rename to packages/agents-a365-observability-tokencache/src/AgenticTokenCache.ts diff --git a/packages/agents-a365-observability-tokenCache/src/index.ts b/packages/agents-a365-observability-tokencache/src/index.ts similarity index 100% rename from packages/agents-a365-observability-tokenCache/src/index.ts rename to packages/agents-a365-observability-tokencache/src/index.ts diff --git a/packages/agents-a365-observability-tokenCache/tsconfig.cjs.json b/packages/agents-a365-observability-tokencache/tsconfig.cjs.json similarity index 100% rename from packages/agents-a365-observability-tokenCache/tsconfig.cjs.json rename to packages/agents-a365-observability-tokencache/tsconfig.cjs.json diff --git a/packages/agents-a365-observability-tokenCache/tsconfig.esm.json b/packages/agents-a365-observability-tokencache/tsconfig.esm.json similarity index 100% rename from packages/agents-a365-observability-tokenCache/tsconfig.esm.json rename to packages/agents-a365-observability-tokencache/tsconfig.esm.json diff --git a/packages/agents-a365-observability-tokenCache/tsconfig.json b/packages/agents-a365-observability-tokencache/tsconfig.json similarity index 100% rename from packages/agents-a365-observability-tokenCache/tsconfig.json rename to packages/agents-a365-observability-tokencache/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17b99e04..164e25a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -467,7 +467,7 @@ importers: version: link:../packages/agents-a365-observability-extensions-openai '@microsoft/agents-a365-observability-tokencache': specifier: workspace:* - version: link:../packages/agents-a365-observability-tokenCache + version: link:../packages/agents-a365-observability-tokencache '@microsoft/agents-a365-runtime': specifier: workspace:* version: link:../packages/agents-a365-runtime @@ -546,7 +546,7 @@ importers: version: link:../../packages/agents-a365-observability '@microsoft/agents-a365-observability-tokencache': specifier: workspace:* - version: link:../../packages/agents-a365-observability-tokenCache + version: link:../../packages/agents-a365-observability-tokencache '@microsoft/agents-a365-runtime': specifier: workspace:* version: link:../../packages/agents-a365-runtime From 54ddd3e2ac58ba0af64299d526d8b7ea1ef67c6c Mon Sep 17 00:00:00 2001 From: jsl517 Date: Wed, 19 Nov 2025 11:03:36 -0800 Subject: [PATCH 25/26] lint --- .../src/AgenticTokenCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agents-a365-observability-tokencache/src/AgenticTokenCache.ts b/packages/agents-a365-observability-tokencache/src/AgenticTokenCache.ts index 02a942a8..8b1abee2 100644 --- a/packages/agents-a365-observability-tokencache/src/AgenticTokenCache.ts +++ b/packages/agents-a365-observability-tokencache/src/AgenticTokenCache.ts @@ -55,7 +55,7 @@ class AgenticTokenCache { } if (!turnContext) { throw new Error('[AgenticTokenCache] TurnContext not set'); - } + } return this.withKeyLock(key, async () => { let entry = this._map.get(key); if (!entry) { From 5303929b3b63093d6d42fee77826618e88532673 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Wed, 19 Nov 2025 11:25:37 -0800 Subject: [PATCH 26/26] comment --- .../package.json | 2 +- .../exporter/Agent365ExporterOptions.ts | 2 - .../tokencache/agentic-token-cache.test.ts | 47 ++++++++++++++++--- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/agents-a365-observability-tokencache/package.json b/packages/agents-a365-observability-tokencache/package.json index cba7868c..bb7e3f43 100644 --- a/packages/agents-a365-observability-tokencache/package.json +++ b/packages/agents-a365-observability-tokencache/package.json @@ -42,7 +42,7 @@ "pack": "npm pack --pack-destination=../" }, "dependencies": { - "@microsoft/agents-hosting": "workspace:*", + "@microsoft/agents-hosting": "^1.1.0-alpha.85", "@microsoft/agents-a365-runtime": "workspace:*", "@microsoft/agents-a365-observability": "workspace:*" }, diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index c8d7d5ef..547b4e47 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -20,8 +20,6 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null * @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. "preprod", "prod", default to "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 uses service-to-service path (/maven/agent365/service/agents/{agentId}/traces); - * when false uses the standard path (/maven/agent365/agents/{agentId}/traces). * @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 Per-export timeout (abort if exceeded). diff --git a/tests/observability/extension/tokencache/agentic-token-cache.test.ts b/tests/observability/extension/tokencache/agentic-token-cache.test.ts index 859fc120..5898f871 100644 --- a/tests/observability/extension/tokencache/agentic-token-cache.test.ts +++ b/tests/observability/extension/tokencache/agentic-token-cache.test.ts @@ -3,7 +3,7 @@ // Licensed under the MIT License. // ------------------------------------------------------------------------------ -const { AgenticTokenCacheInstance } = require('@microsoft/agents-a365-observability-tokencache') as { AgenticTokenCacheInstance: any }; +import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-tokencache'; interface TurnContextStub { activity: { id: string } } interface AuthorizationStub { @@ -17,6 +17,11 @@ 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; +}; + 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; @@ -58,7 +63,13 @@ describe('AgenticTokenCacheInstance', () => { it('exchanges and caches token on first call', async () => { const token = makeJwtWithExp(300); const auth = makeAuthorizationMock([{ token }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken('agentA', 'tenantA', makeTurnContext(), auth as any, ['scope.read']); + 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); @@ -85,7 +96,13 @@ describe('AgenticTokenCacheInstance', () => { onSignInSuccess: () => {}, onSignInFailure: () => {} }; - const p = AgenticTokenCacheInstance.RefreshObservabilityToken('agentB', 'tenantB', makeTurnContext() as any, auth as any, ['scope.read']); + 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'); @@ -100,21 +117,39 @@ describe('AgenticTokenCacheInstance', () => { { error: nonRetriableErr }, { token: makeJwtWithExp(300) } // should not be used ]); - await AgenticTokenCacheInstance.RefreshObservabilityToken('agentC', 'tenantC', makeTurnContext(), auth as any, ['scope.read']); + await AgenticTokenCacheInstance.RefreshObservabilityToken( + 'agentC', + 'tenantC', + asTurnContext(makeTurnContext()), + auth as any, + ['scope.read'] + ); const token = AgenticTokenCacheInstance.getObservabilityToken('agentC', 'tenantC'); expect(token).toBeNull(); }); it('treats near-expiry token as expired (skew refresh)', async () => { const auth = makeAuthorizationMock([{ token: makeJwtWithExp(30) }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken('agentD', 'tenantD', makeTurnContext(), auth as any, ['scope.read']); + await AgenticTokenCacheInstance.RefreshObservabilityToken( + 'agentD', + 'tenantD', + asTurnContext(makeTurnContext()), + auth as any, + ['scope.read'] + ); const token = AgenticTokenCacheInstance.getObservabilityToken('agentD', 'tenantD'); expect(token).toBeNull(); }); it('returns cached token before expiry then invalid after advancing time', async () => { const auth = makeAuthorizationMock([{ token: makeJwtWithExp(120) }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken('agentE', 'tenantE', makeTurnContext(), auth as any, ['scope.read']); + await AgenticTokenCacheInstance.RefreshObservabilityToken( + 'agentE', + 'tenantE', + asTurnContext(makeTurnContext()), + auth as any, + ['scope.read'] + ); const tokenBefore = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE'); expect(tokenBefore).not.toBeNull(); jest.advanceTimersByTime(61_000);