diff --git a/packages/agents-a365-observability-tokencache/README.md b/packages/agents-a365-observability-tokencache/README.md new file mode 100644 index 00000000..5da274ee --- /dev/null +++ b/packages/agents-a365-observability-tokencache/README.md @@ -0,0 +1,100 @@ +# @microsoft/agents-a365-observability-tokencache + +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 + +## Installation + +```bash +pnpm add @microsoft/agents-a365-observability-tokencache +``` + +## 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; +}; + +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 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 new file mode 100644 index 00000000..bb7e3f43 --- /dev/null +++ b/packages/agents-a365-observability-tokencache/package.json @@ -0,0 +1,63 @@ +{ + "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 --config ./jest.config.cjs --passWithNoTests", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "pack": "npm pack --pack-destination=../" + }, + "dependencies": { + "@microsoft/agents-hosting": "^1.1.0-alpha.85", + "@microsoft/agents-a365-runtime": "workspace:*", + "@microsoft/agents-a365-observability": "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/.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 new file mode 100644 index 00000000..8b1abee2 --- /dev/null +++ b/packages/agents-a365-observability-tokencache/src/AgenticTokenCache.ts @@ -0,0 +1,201 @@ +// ------------------------------------------------------------------------------ +// 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 '@microsoft/agents-a365-observability'; + +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>(); + + public static makeKey(agentId: string, tenantId: string): string { + return `${agentId}:${tenantId}`; + } + + public getObservabilityToken(agentId: string, tenantId: string): string | null { + const key = AgenticTokenCache.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 = AgenticTokenCache.makeKey(agentId, tenantId); + if (!authorization) { + throw new Error('[AgenticTokenCache] Authorization not set'); + } + if (!turnContext) { + throw new Error('[AgenticTokenCache] TurnContext not set'); + } + 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(AgenticTokenCache.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 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..d52e0fef --- /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 } 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/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index cd26049c..a9bdcb0c 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -6,7 +6,9 @@ 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'; import { trace } from '@opentelemetry/api'; @@ -24,6 +26,13 @@ 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 will be overridden by explicitly configured tokenResolver or clusterCategory + * from dedicated builder methods. + */ + exporterOptions?: Partial; } @@ -67,18 +76,39 @@ export class ObservabilityBuilder { return this; } - private getTraceExporter() { - if (isAgent365ExporterEnabled()){ - if (!this.options.tokenResolver) { - throw new Error('tokenResolver must be provided when Agent 365 exporter is enabled'); - } - return new Agent365Exporter( - this.options.tokenResolver, - this.options.clusterCategory || 'prod' - ); - } else { - return new ConsoleSpanExporter(); + /** + * 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(); + 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; + } + return new BatchSpanProcessor(new Agent365Exporter(opts), { + maxQueueSize: opts.maxQueueSize, + scheduledDelayMillis: opts.scheduledDelayMilliseconds, + exportTimeoutMillis: opts.exporterTimeoutMilliseconds, + maxExportBatchSize: opts.maxExportBatchSize + }); } private createResource() { @@ -104,7 +134,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(); @@ -174,4 +204,3 @@ export class ObservabilityBuilder { } } } - diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index 083b2477..85b7d975 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'; @@ -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/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index 96395d4e..4608b536 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -1,10 +1,15 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------------------------ + import { ExportResult,ExportResultCode } from '@opentelemetry/core'; 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 { Agent365ExporterOptions } from './Agent365ExporterOptions'; const DEFAULT_HTTP_TIMEOUT_SECONDS = 30000; // 30 seconds in ms const DEFAULT_MAX_RETRIES = 3; @@ -14,7 +19,7 @@ interface OTLPExportRequest { interface ResourceSpan { resource: { - attributes: Record | null; + attributes: Record | null; }; scopeSpans: ScopeSpan[]; } @@ -35,7 +40,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 +49,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 { @@ -58,10 +63,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: @@ -71,20 +72,22 @@ 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; + + /** + * 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)'); + } - constructor( - tokenResolver: TokenResolver, - clusterCategory: ClusterCategory = 'prod' - ) { - if (!tokenResolver) { - logger.error('[Agent365Exporter] token_resolver is not provided'); - throw new Error('token_resolver must be provided.'); + if (!options.tokenResolver) { + throw new Error('Agent365Exporter tokenResolver must be provided'); } - this.tokenResolver = tokenResolver; - this.clusterCategory = clusterCategory; + this.options = options; } /** @@ -111,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); } @@ -139,8 +143,8 @@ export class Agent365Exporter implements SpanExporter { const payload = this.buildExportRequest(spans); const body = JSON.stringify(payload); - // Resolve endpoint + token based on cluster category (defaults to 'prod') - const discovery = new PowerPlatformApiDiscovery(this.clusterCategory); + // Resolve endpoint + token + 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 +153,11 @@ export class Agent365Exporter implements SpanExporter { 'content-type': 'application/json' }; - const tokenResult = this.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/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts new file mode 100644 index 00000000..547b4e47 --- /dev/null +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// 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. + * 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; + +/** + * 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", 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 {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 (e.g. "preprod", "prod"). */ + public clusterCategory: ClusterCategory | string = 'prod'; + + /** 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; + + /** Delay (ms) between automatic batch flush attempts. */ + public scheduledDelayMilliseconds: number = 5000; + + /** Per-export timeout in milliseconds. */ + public exporterTimeoutMilliseconds: number = 30000; + + /** Maximum number of spans per export batch. */ + public maxExportBatchSize: number = 512; +} 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 52697487..67ec87b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,6 +194,46 @@ importers: specifier: ^5.6.0 version: 5.9.3 + 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 + '@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': @@ -426,9 +466,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) @@ -502,6 +548,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 86b45c22..01d96685 100644 --- a/tests-agent/basic-agent-sdk-sample/package.json +++ b/tests-agent/basic-agent-sdk-sample/package.json @@ -20,7 +20,8 @@ "@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:*" }, "devDependencies": { "@microsoft/m365agentsplayground": "^0.2.18", diff --git a/tests-agent/basic-agent-sdk-sample/src/agent.ts b/tests-agent/basic-agent-sdk-sample/src/agent.ts index 040becbe..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, @@ -20,7 +25,7 @@ import { ServiceEndpoint, } 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; @@ -33,8 +38,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], }); @@ -74,54 +79,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', - })); - - - // 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}`); - - const llmResponse = await performInference( - context.activity.text ?? 'Unknown text', - context - ); - - await context.sendActivity(`LLM Response: ${llmResponse}`); - - await context.sendActivity('Now performing a tool call...'); - - await context.sendActivity(Activity.fromObject({ - type: 'typing', - })); - - const toolResponse = await performToolCall(context); - - await context.sendActivity(`Tool Response: ${toolResponse}`); - - // Record output messages - invokeAgentScope.recordOutputMessages([ - `LLM Response: ${llmResponse}`, - `Tool Response: ${toolResponse}` - ]); - }); - - invokeAgentScope.dispose(); + 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 + ); + + await context.sendActivity(`LLM Response: ${llmResponse}`); + + await context.sendActivity('Now performing a tool call...'); + + await context.sendActivity(Activity.fromObject({ + type: 'typing', + })); + + const toolResponse = await performToolCall(context); + + await context.sendActivity(`Tool Response: ${toolResponse}`); + + // Record output messages + invokeAgentScope.recordOutputMessages([ + `LLM Response: ${llmResponse}`, + `Tool Response: ${toolResponse}` + ]); + }); + + 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 4fe6762e..fb8ec939 100644 --- a/tests-agent/basic-agent-sdk-sample/src/index.ts +++ b/tests-agent/basic-agent-sdk-sample/src/index.ts @@ -24,15 +24,36 @@ app.use((req: Request, res: Response, next: NextFunction) => { aud: authConfig.clientId || 'mock-client-id', appid: authConfig.clientId || 'mock-client-id', azp: authConfig.clientId || 'mock-client-id' - } - next() -}) + }; + next(); +}); 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; @@ -40,7 +61,7 @@ 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('Agent 365 observability is shutting down...'); diff --git a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts index ded2bb86..f2871989 100644 --- a/tests-agent/basic-agent-sdk-sample/src/telemetry.ts +++ b/tests-agent/basic-agent-sdk-sample/src/telemetry.ts @@ -1,11 +1,17 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------------------------ + import { Builder, - ObservabilityManager + 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 => { @@ -30,15 +36,26 @@ const getClusterCategory = (): ClusterCategory => { if (category) { return category as ClusterCategory; } - return 'dev' as ClusterCategory; // Safe fallback + return 'prod' as ClusterCategory; // Safe fallback }; -export const a365Observability = ObservabilityManager.configure( - (builder: Builder) => - builder - .withService('TypeScript Sample Agent', '1.0.0') - .withTokenResolver(tokenResolver) - .withClusterCategory(getClusterCategory()) -); +// 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()) + .withExporterOptions(exporterOptions); + // Opt-in custom token resolver via env flag `Use_Custom_Resolver=true` + if (process.env.Use_Custom_Resolver === 'true') { + builder.withTokenResolver(tokenResolver); + } + else { + // use resolver from observability token cache 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 new file mode 100644 index 00000000..7f1fc265 --- /dev/null +++ b/tests/observability/core/agent365-exporter.test.ts @@ -0,0 +1,107 @@ +// ------------------------------------------------------------------------------ +// 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 { Agent365ExporterOptions } from '@microsoft/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions'; +// 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 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 }); + }); + + it('uses provided token resolver and sets authorization header', async () => { + const token = 'abc123'; + mockFetchSequence([200]); + const opts = new Agent365ExporterOptions(); + opts.clusterCategory = 'local'; + opts.tokenResolver = () => token; + const exporter = new Agent365Exporter(opts); + + 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('requires a tokenResolver and fails export when missing', async () => { + const opts = new Agent365ExporterOptions(); + opts.clusterCategory = 'local'; + // Intentionally omit tokenResolver + expect(() => new Agent365Exporter(opts)).toThrow(/tokenResolver must be provided/); + }); +}); diff --git a/tests/observability/core/observabilityBuilder-options.test.ts b/tests/observability/core/observabilityBuilder-options.test.ts new file mode 100644 index 00000000..aa834ee8 --- /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 + }); +}); diff --git a/tests/observability/extension/tokencache/agentic-token-cache.test.ts b/tests/observability/extension/tokencache/agentic-token-cache.test.ts new file mode 100644 index 00000000..5898f871 --- /dev/null +++ b/tests/observability/extension/tokencache/agentic-token-cache.test.ts @@ -0,0 +1,159 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------------------------ + +import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-tokencache'; + +interface TurnContextStub { activity: { id: string } } +interface AuthorizationStub { + exchangeToken: (...args: any[]) => Promise<{ token: string | undefined }> + getToken: (...args: any[]) => Promise<{ token: string }> + signOut: () => Promise | void + onSignInSuccess: () => void + onSignInFailure: () => void +} +interface SequenceStep { token?: string; error?: unknown } + +const makeTurnContext = (): TurnContextStub => ({ activity: { id: 'a1' } }); + +// Helper to cast our minimal stub to the SDK TurnContext type expected by the cache +const asTurnContext = (stub: TurnContextStub): import('@microsoft/agents-hosting').TurnContext => { + return stub as unknown as import('@microsoft/agents-hosting').TurnContext; +}; + +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`; +} + +function makeAuthorizationMock(sequence: SequenceStep[]): AuthorizationStub { + let call = 0; + const authLike: AuthorizationStub = { + exchangeToken: async () => { + const current = sequence[Math.min(call, sequence.length - 1)]; + call++; + if (current.error) throw current.error; + return { token: current.token || '' }; + }, + getToken: async () => ({ token: 'unused' }), + signOut: async () => {}, + onSignInSuccess: () => {}, + onSignInFailure: () => {} + }; + return authLike; +} + +describe('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', + asTurnContext(makeTurnContext()), + auth as any, + ['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: SequenceStep[] = [ + { error: retriableErr }, + { token } + ]; + let call = 0; + const exchangeFn = jest.fn(async () => { + const current = sequence[Math.min(call, sequence.length - 1)]; + call++; + if (current.error) throw current.error; + return { token: current.token }; + }); + const auth: AuthorizationStub = { + exchangeToken: exchangeFn, + getToken: async () => ({ token: 'unused' }), + signOut: async () => {}, + onSignInSuccess: () => {}, + onSignInFailure: () => {} + }; + const p = AgenticTokenCacheInstance.RefreshObservabilityToken( + 'agentB', + 'tenantB', + asTurnContext(makeTurnContext()), + auth as any, + ['scope.read'] + ); + await (jest as any).advanceTimersByTimeAsync?.(1000) || jest.advanceTimersByTime(1000); + await p; + const tokenReturned = AgenticTokenCacheInstance.getObservabilityToken('agentB', 'tenantB'); + expect(tokenReturned).not.toBeNull(); + expect(tokenReturned).toBe(token); + expect(exchangeFn).toHaveBeenCalledTimes(2); + }); + + it('stops on non-retriable error and leaves token null', async () => { + const nonRetriableErr = { status: 400, message: 'bad request' }; + const auth = makeAuthorizationMock([ + { error: nonRetriableErr }, + { token: makeJwtWithExp(300) } // should not be used + ]); + await AgenticTokenCacheInstance.RefreshObservabilityToken( + 'agentC', + 'tenantC', + asTurnContext(makeTurnContext()), + auth as any, + ['scope.read'] + ); + const token = AgenticTokenCacheInstance.getObservabilityToken('agentC', 'tenantC'); + expect(token).toBeNull(); + }); + + it('treats near-expiry token as expired (skew refresh)', async () => { + const auth = makeAuthorizationMock([{ token: makeJwtWithExp(30) }]); + await AgenticTokenCacheInstance.RefreshObservabilityToken( + 'agentD', + 'tenantD', + asTurnContext(makeTurnContext()), + auth as any, + ['scope.read'] + ); + const token = AgenticTokenCacheInstance.getObservabilityToken('agentD', 'tenantD'); + expect(token).toBeNull(); + }); + + it('returns cached token before expiry then invalid after advancing time', async () => { + const auth = makeAuthorizationMock([{ token: makeJwtWithExp(120) }]); + await AgenticTokenCacheInstance.RefreshObservabilityToken( + 'agentE', + 'tenantE', + asTurnContext(makeTurnContext()), + auth as any, + ['scope.read'] + ); + const tokenBefore = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE'); + expect(tokenBefore).not.toBeNull(); + jest.advanceTimersByTime(61_000); + const tokenAfter = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE'); + expect(tokenAfter).toBeNull(); + }); +}); diff --git a/tests/package.json b/tests/package.json index d79e7ad9..cf227924 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": [ "**/*"