diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e22443d..166d66f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,45 +5,38 @@ All notable changes to the Agent365 TypeScript SDK will be documented in this fi
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [0.2.0] - 2026-04-15
-
-### Breaking Changes (`@microsoft/agents-a365-observability`)
-
-- **New permission required: `Agent365.Observability.OtelWrite`** — The observability exporter now requires this scope as both a delegated and application permission on your agent blueprint. See [Upgrade Instructions](#upgrade-instructions-observability-permission-for-existing-agents) below.
-
----
-
-
-
-### Upgrade Instructions: Observability Permission for Existing Agents
-
-Existing agent blueprints need `Agent365.Observability.OtelWrite` granted as both a **delegated permission** and an **application permission**. Choose either option below.
-
-#### Option A — Agent 365 CLI (requires both config files)
-
-Requires `a365.config.json` and `a365.generated.config.json` in your config directory, a Global Administrator account, and [Agent 365 CLI v1.1.139-preview](https://www.nuget.org/packages/Microsoft.Agents.A365.DevTools.Cli/1.1.139-preview) or later.
-
-```bash
-a365 setup admin --config-dir ""
-```
-
-This grants all missing permissions including the new Observability scopes.
-
-#### Option B — Entra Portal (no config files required)
-
-Requires Global Administrator access to the blueprint app registration.
-
-1. Go to **Entra portal** > **App registrations** > select your Blueprint app
-2. Go to **API permissions** > **Add a permission** > **APIs my organization uses** > search for `9b975845-388f-4429-889e-eab1ef63949c`
-3. Select **Delegated permissions** > check `Agent365.Observability.OtelWrite` > **Add permissions**
-4. Repeat step 2–3, this time select **Application permissions** > check `Agent365.Observability.OtelWrite` > **Add permissions**
-5. Click **Grant admin consent** and confirm
-
-Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability.OtelWrite` (Application) should show `Granted` status.
-
----
-
-## [Unreleased]
+## [2.0.0] - Unreleased
+
+### Breaking Changes (`@microsoft/agents-a365-observability`, `@microsoft/agents-a365-observability-hosting`)
+
+- **OBS exports always use `/observabilityService`** - The `useS2SEndpoint` option is
+ deprecated and ignored, even when `false`. Batch and per-request exports no longer
+ select or fall back to `/observability`. Provide an app-only OBS token independently
+ of your agent's workload auth; the S2S service rejects delegated `scp` tokens.
+- **Per-request OBS requires the configured app-only resolver** - Both export modes
+ use `withTokenResolver(...)` or `exporterOptions.tokenResolver`, with the builder
+ method taking precedence. `Agent365Exporter` no longer reads tokens from
+ `runWithExportToken`/`updateExportToken`. Missing resolvers fail configuration;
+ empty tokens or acquisition failures fail export without delegated fallback.
+ The exporter invokes the resolver on every export batch, so resolvers must
+ cache the acquired token and refresh only near expiry.
+ `ObservabilityManager.start(options)` now forwards `options.exporterOptions`,
+ which it previously ignored.
+ Workload OBO and custom-exporter context helpers are otherwise unchanged.
+- **Hosting OBS token cache requires an app-only resolver** -
+ `RefreshObservabilityToken(agentId, tenantId, tokenResolver)` replaces the
+ `TurnContext`/`Authorization` overload, which is removed. JavaScript callers that
+ still pass it get a one-time error log and no token instead of an exception.
+ `RefreshObservabilityToken` throws when acquisition fails; call it from the
+ exporter's `tokenResolver` or wrap it in try/catch on the request path.
+
+### Fixed (`@microsoft/agents-a365-observability`)
+
+- **`@opentelemetry/core` is now a declared dependency** - The exporter imports it at
+ runtime, so installs where npm did not hoist another copy failed with
+ `MODULE_NOT_FOUND` when loading the exporter.
+
+## [1.0.0] - 2026-04-30
### Breaking Changes (`@microsoft/agents-a365-tooling`)
@@ -164,6 +157,44 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability.
- `EnhancedAgentDetails` is now an alias of `AgentDetails` and marked as deprecated. Existing imports continue to work without breaking changes; migrate to `AgentDetails` when convenient.
+## [0.2.0] - 2026-04-15
+
+### Breaking Changes (`@microsoft/agents-a365-observability`)
+
+- **New permission required: `Agent365.Observability.OtelWrite`** — The observability exporter now requires this scope as both a delegated and application permission on your agent blueprint. See [Upgrade Instructions](#upgrade-instructions-observability-permission-for-existing-agents) below.
+
+---
+
+
+
+### Upgrade Instructions: Observability Permission for Existing Agents
+
+Existing agent blueprints need `Agent365.Observability.OtelWrite` granted as both a **delegated permission** and an **application permission**. Choose either option below.
+
+#### Option A — Agent 365 CLI (requires both config files)
+
+Requires `a365.config.json` and `a365.generated.config.json` in your config directory, a Global Administrator account, and [Agent 365 CLI v1.1.139-preview](https://www.nuget.org/packages/Microsoft.Agents.A365.DevTools.Cli/1.1.139-preview) or later.
+
+```bash
+a365 setup admin --config-dir ""
+```
+
+This grants all missing permissions including the new Observability scopes.
+
+#### Option B — Entra Portal (no config files required)
+
+Requires Global Administrator access to the blueprint app registration.
+
+1. Go to **Entra portal** > **App registrations** > select your Blueprint app
+2. Go to **API permissions** > **Add a permission** > **APIs my organization uses** > search for `9b975845-388f-4429-889e-eab1ef63949c`
+3. Select **Delegated permissions** > check `Agent365.Observability.OtelWrite` > **Add permissions**
+4. Repeat step 2–3, this time select **Application permissions** > check `Agent365.Observability.OtelWrite` > **Add permissions**
+5. Click **Grant admin consent** and confirm
+
+Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability.OtelWrite` (Application) should show `Granted` status.
+
+---
+
## [0.1.0] - 2025-01-03
### Added
diff --git a/packages/agents-a365-observability-hosting/docs/design.md b/packages/agents-a365-observability-hosting/docs/design.md
index c422f227..d6001dda 100644
--- a/packages/agents-a365-observability-hosting/docs/design.md
+++ b/packages/agents-a365-observability-hosting/docs/design.md
@@ -107,18 +107,63 @@ const agentPairs = getTargetAgentBaggagePairs(turnContext);
### AgenticTokenCacheInstance ([AgenticTokenCache.ts](../src/caching/AgenticTokenCache.ts))
-Token caching for improved performance:
+Cache app-only OBS tokens independently of the workload's AI Teammate or OBO
+authorization. The `TurnContext`/`Authorization` overload was removed in 2.0.0:
+TypeScript callers get a compile error, and untyped callers get a one-time error log
+and no token instead of a delegated token that S2S rejects. `RefreshObservabilityToken`
+throws when acquisition fails; call it from the exporter's `tokenResolver` or wrap it in
+try/catch on the request path.
```typescript
import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-hosting';
-// Cache token with key
-AgenticTokenCacheInstance.set('cache-key', 'token-value', ttlMs);
+// acquireAppOnlyObsToken is your app-only token acquisition callback.
+// It receives (agentId, tenantId, scopes) and returns the final OBS access token.
+await AgenticTokenCacheInstance.RefreshObservabilityToken(
+ agentId, tenantId, acquireAppOnlyObsToken
+);
-// Retrieve cached token
-const token = AgenticTokenCacheInstance.get('cache-key');
+const token = AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId);
```
+For a blueprint-backed agent, acquire a blueprint exchange assertion with
+`fmi_path=agentId`, then use it as `client_assertion` in an instance
+`client_credentials` request for the OBS `/.default` scope. Do not send the
+intermediate assertion, a blueprint token, or a `user_fic`/OBO token to OBS.
+The final token's application identity must match `agentId`, its tenant must
+match `tenantId`, and its audience must be OBS. An eligible Agent 365-registered
+instance can use a roleless app token when service policy permits; an
+`Agent365.Observability.OtelWrite` grant is not a universal prerequisite. Entra
+identity creation alone does not establish instance registration or service access.
+The resolver must validate app-only identity: accept `idtyp=app`, or, when `idtyp` is absent,
+either a non-empty `roles` array or a non-empty `oid` equal to `sub`; reject any other `idtyp`
+value. It must also reject delegated `scp` tokens, and check audience and lifetime before
+returning a token. The cache does not perform token authentication or authorization.
+Acquisition failures propagate to the caller and never trigger delegated authentication.
+
+When migrating, replace only the OBS refresh call, not workload MCP/Graph/OBO
+authorization. Configure an OBS resolver in both batch and per-request modes.
+It should refresh the app-only cache at export time before returning its token,
+so long-running requests do not depend on a token acquired at turn start:
+
+```typescript
+builder.withTokenResolver(async (agentId, tenantId) => {
+ await AgenticTokenCacheInstance.RefreshObservabilityToken(
+ agentId, tenantId, acquireAppOnlyObsToken
+ );
+ return AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId);
+});
+```
+
+`Agent365Exporter` ignores tokens in `runWithExportToken`; those context helpers
+remain available for custom exporters, not as an OBS authentication fallback.
+An enabled exporter without an explicit resolver fails configuration.
+Both modes use the S2S OTLP
+route even if the deprecated `useS2SEndpoint` option is false. Missing tokens and
+failed acquisition report export failure without sending a request; HTTP
+401/403/404 never select an OBO fallback. Check instance registration and service
+policy rather than adding OBS permissions automatically.
+
## Tenant ID Resolution
The package extracts tenant ID from multiple sources:
diff --git a/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts b/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts
index 62a4b3d7..5ae1befa 100644
--- a/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts
+++ b/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts
@@ -3,9 +3,18 @@
// Licensed under the MIT License.
// ------------------------------------------------------------------------------
-import { TurnContext, Authorization } from '@microsoft/agents-hosting';
-import { logger, formatError, ObservabilityConfiguration, defaultObservabilityConfigurationProvider } from '@microsoft/agents-a365-observability';
-import { IConfigurationProvider } from '@microsoft/agents-a365-runtime';
+import {
+ logger, formatError, defaultObservabilityConfigurationProvider,
+ type ObservabilityConfiguration, type TokenResolver,
+} from '@microsoft/agents-a365-observability';
+import type { IConfigurationProvider } from '@microsoft/agents-a365-runtime';
+
+/** Acquires an app-only OBS token; must not perform user_fic or OBO authentication. */
+export type ObservabilityTokenResolver = (
+ agentId: string,
+ tenantId: string,
+ scopes: readonly string[]
+) => ReturnType;
interface CacheEntry {
scopes: string[];
@@ -32,6 +41,7 @@ export class AgenticTokenCache {
private readonly _maxCacheSize = 10_000;
private readonly _maxExpSeconds = 86_400; // 24 hours
private readonly _keyLocks = new Map>();
+ private _removedOverloadLogged = false;
private readonly _configProvider: IConfigurationProvider;
/**
@@ -64,28 +74,35 @@ export class AgenticTokenCache {
return entry.token;
}
+ /**
+ * Refreshes an app-only OBS token independently of the current user's authorization.
+ * The resolver receives the configured OBS scopes and must acquire a token for
+ * the exporting agent identity, not its blueprint or the workload's user.
+ *
+ * @throws When the resolver fails or returns no token. Call this from the exporter's
+ * `tokenResolver`, where a failure fails that export, or wrap it in try/catch on the
+ * request path.
+ */
public async RefreshObservabilityToken(
agentId: string,
tenantId: string,
- turnContext: TurnContext,
- authorization: Authorization,
- scopes: string[],
- authHandlerName: string = 'agentic'
+ tokenResolver: ObservabilityTokenResolver
): Promise {
- const key = AgenticTokenCache.makeKey(agentId, tenantId);
- if (!authorization) {
- throw new Error('[AgenticTokenCache] Authorization not set');
+ if (typeof tokenResolver !== 'function') {
+ // Untyped callers can still pass the TurnContext/Authorization overload removed in 2.0.0.
+ this.logRemovedOverloadOnce();
+ return;
}
- if (!turnContext) {
- throw new Error('[AgenticTokenCache] TurnContext not set');
+ if (!agentId?.trim() || !tenantId?.trim()) {
+ throw new Error('[AgenticTokenCache] Agent and tenant IDs are required');
}
+ const key = AgenticTokenCache.makeKey(agentId, tenantId);
return this.withKeyLock(key, async () => {
let entry = this._map.get(key);
if (!entry) {
- const effectiveScopes = (scopes && scopes.length > 0) ? scopes : [...this._configProvider.getConfiguration().observabilityAuthenticationScopes];
+ const effectiveScopes = [...this._configProvider.getConfiguration().observabilityAuthenticationScopes];
if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) {
- logger.error('[AgenticTokenCache] No valid scopes');
- return;
+ throw new Error('[AgenticTokenCache] No valid scopes');
}
entry = { scopes: effectiveScopes };
if (this._map.size >= this._maxCacheSize) {
@@ -97,8 +114,7 @@ export class AgenticTokenCache {
this._map.set(key, entry);
}
if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) {
- logger.error('[AgenticTokenCache] Entry has invalid scopes');
- return;
+ throw new Error('[AgenticTokenCache] Entry has invalid scopes');
}
if (entry.token && !this.isExpired(entry)) {
@@ -107,21 +123,19 @@ export class AgenticTokenCache {
const maxRetries = 2;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
- logger.info(`[AgenticTokenCache] Exchanging token attempt ${attempt + 1}/${maxRetries + 1}`);
+ logger.info(`[AgenticTokenCache] Acquiring app-only token attempt ${attempt + 1}/${maxRetries + 1}`);
try {
- const tokenResponse = await authorization.exchangeToken(turnContext, authHandlerName, { scopes: entry.scopes });
- if (!tokenResponse?.token) {
- logger.error('[AgenticTokenCache] Undefined token returned');
- entry.token = undefined;
- entry.expiresOn = undefined;
- break;
+ const token = await tokenResolver(agentId, tenantId, [...entry.scopes]);
+ if (!token?.trim()) {
+ throw new Error('[AgenticTokenCache] App-only token resolver returned no token');
}
- entry.token = tokenResponse.token;
+ entry.token = token;
entry.acquiredOn = Date.now();
- const oboExp = this.decodeExp(entry.token);
- if (oboExp) {
- entry.expiresOn = oboExp * 1000;
+ const exp = this.decodeExp(token);
+ if (exp) {
+ entry.expiresOn = exp * 1000;
} else {
+ entry.expiresOn = undefined;
logger.warn('[AgenticTokenCache] No exp claim, fallback TTL');
}
logger.info('[AgenticTokenCache] Token cached');
@@ -136,7 +150,8 @@ export class AgenticTokenCache {
logger.error('[AgenticTokenCache] Non-retriable failure', formatError(e));
entry.token = undefined;
entry.expiresOn = undefined;
- break;
+ entry.acquiredOn = undefined;
+ throw e;
}
}
});
@@ -205,6 +220,14 @@ export class AgenticTokenCache {
return false;
}
+ private logRemovedOverloadOnce(): void {
+ if (this._removedOverloadLogged) {
+ return;
+ }
+ this._removedOverloadLogged = true;
+ logger.error('[AgenticTokenCache] RefreshObservabilityToken(agentId, tenantId, turnContext, authorization, ...) was removed in 2.0.0 and does nothing; S2S OBS needs an app-only token. Call RefreshObservabilityToken(agentId, tenantId, tokenResolver) instead.');
+ }
+
private sleep(ms: number): Promise {
return new Promise(resolve => setTimeout(resolve, ms));
}
diff --git a/packages/agents-a365-observability-hosting/src/index.ts b/packages/agents-a365-observability-hosting/src/index.ts
index ed0173a3..76943828 100644
--- a/packages/agents-a365-observability-hosting/src/index.ts
+++ b/packages/agents-a365-observability-hosting/src/index.ts
@@ -7,6 +7,7 @@ export * from './utils/BaggageBuilderUtils';
export * from './utils/ScopeUtils';
export * from './utils/TurnContextUtils';
export { AgenticTokenCache, AgenticTokenCacheInstance } from './caching/AgenticTokenCache';
+export type { ObservabilityTokenResolver } from './caching/AgenticTokenCache';
export { BaggageMiddleware } from './middleware/BaggageMiddleware';
export { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY, A365_AUTH_TOKEN_KEY } from './middleware/OutputLoggingMiddleware';
export { ObservabilityHostingManager } from './middleware/ObservabilityHostingManager';
diff --git a/packages/agents-a365-observability/README.md b/packages/agents-a365-observability/README.md
index 5ddb1410..c6a9af5f 100644
--- a/packages/agents-a365-observability/README.md
+++ b/packages/agents-a365-observability/README.md
@@ -15,6 +15,68 @@ npm install @microsoft/agents-a365-observability
For detailed usage examples and implementation guidance, see the [Microsoft Agent 365 Observability Documentation](https://learn.microsoft.com/microsoft-agent-365/developer/observability?tabs=nodejs).
+### OBS endpoint
+
+All exports use `/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1`,
+including batch and per-request exports from AI Teammate and OBO workloads. The exporter
+never falls back to `/observability`. The `useS2SEndpoint` option is deprecated and ignored,
+including when set to `false`; domain overrides change the host, not this route.
+
+Endpoint selection does not acquire or convert tokens. Supply an **app-only** OBS
+resolver for the exporting tenant and agent identity. Eligible Agent 365-registered
+instances can use roleless app tokens when service policy permits; an
+`Agent365.Observability.OtelWrite` grant is not a universal prerequisite.
+The S2S service rejects delegated (`scp`) tokens, including
+AI Teammate user tokens. Keep workload authentication
+(such as OBO for MCP or Microsoft Graph) separate from OBS authentication. An authorization
+failure is not a reason to retry telemetry on the OBO route.
+
+When using the hosting token cache, call
+`RefreshObservabilityToken(agentId, tenantId, appOnlyTokenResolver)`. It throws when the
+resolver fails or returns no token, so call it from your exporter `tokenResolver` or wrap
+it in try/catch on the request path. The `TurnContext`/`Authorization` overload was removed
+in 2.0.0; JavaScript callers that still pass it get a one-time error log and no token.
+S2S ingestion may remove unverified user attribution; routing a workload through S2S
+does not establish that its caller identity is trusted.
+
+### Migrating per-request authentication
+
+Batch and per-request exports both call the configured `tokenResolver` with the
+exporting agent and tenant IDs. Configure it with `withTokenResolver(...)` or
+`exporterOptions.tokenResolver`; both also work through `ObservabilityManager.start(options)`,
+and `withTokenResolver`/`tokenResolver` takes precedence.
+The callback must acquire or refresh an app-only OBS token independently of
+workload authentication.
+
+```typescript
+import { ObservabilityManager, type TokenResolver } from '@microsoft/agents-a365-observability';
+
+function startObservability(resolveAppOnlyObsToken: TokenResolver): void {
+ ObservabilityManager.configure(builder => {
+ builder.withService('my-agent').withTokenResolver(resolveAppOnlyObsToken);
+ }).start();
+}
+```
+
+Enabling `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` changes span buffering,
+not credential selection. `runWithExportToken`, `updateExportToken`, and
+`getExportToken` remain available for custom export integrations, but
+`Agent365Exporter` never uses their context token, even when it looks app-only.
+Existing callers must provide the OBS resolver instead of relying on a workload
+token in context.
+
+**Resolvers must cache.** The exporter invokes the resolver on every export
+batch, and once per identity group when spans partition across tenants or
+agents. In per-request mode that is roughly one call per request. Resolvers
+should cache the acquired app-only token and refresh only as it approaches
+expiry. `AgenticTokenCache` in `@microsoft/agents-a365-observability-hosting`
+implements this pattern; the sample `observability-token-service.ts` files show
+a minimal single-identity variant.
+
+An enabled Agent 365 exporter without a resolver fails configuration. A resolver
+failure or empty token fails export without an HTTP request or delegated fallback.
+Console-only configuration does not require an OBS resolver.
+
## Support
For issues, questions, or feedback:
diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md
index 7d947eb3..de2519ef 100644
--- a/packages/agents-a365-observability/docs/design.md
+++ b/packages/agents-a365-observability/docs/design.md
@@ -47,7 +47,7 @@ import { ObservabilityManager } from '@microsoft/agents-a365-observability';
ObservabilityManager.start({
serviceName: 'my-agent',
serviceVersion: '1.0.0',
- tokenResolver: async (agentId, tenantId) => getAuthToken(),
+ tokenResolver: async (agentId, tenantId) => getAppOnlyObsToken(agentId, tenantId),
clusterCategory: 'prod'
});
@@ -66,6 +66,13 @@ const instance = ObservabilityManager.getInstance();
await ObservabilityManager.shutdown();
```
+The configured app-only OBS resolver is required in both batch and per-request
+modes. The builder merges resolver options consistently, with `withTokenResolver`
+taking precedence over `exporterOptions.tokenResolver`. `ObservabilityManager.start(options)`
+forwards both `tokenResolver` and `exporterOptions` to the builder. Request context is retained
+for tracing, but its token is not consumed by `Agent365Exporter`. See the
+[per-request migration guide](../README.md#migrating-per-request-authentication).
+
### ObservabilityBuilder ([ObservabilityBuilder.ts](../src/ObservabilityBuilder.ts))
Fluent API for configuring telemetry:
diff --git a/packages/agents-a365-observability/package.json b/packages/agents-a365-observability/package.json
index 978fca47..5230c650 100644
--- a/packages/agents-a365-observability/package.json
+++ b/packages/agents-a365-observability/package.json
@@ -40,6 +40,7 @@
"dependencies": {
"@microsoft/agents-a365-runtime": "workspace:*",
"@opentelemetry/api": "catalog:",
+ "@opentelemetry/core": "catalog:",
"@opentelemetry/exporter-trace-otlp-http": "catalog:",
"@opentelemetry/instrumentation": "catalog:",
"@opentelemetry/resources": "catalog:",
diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts
index d09484d2..9150b338 100644
--- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts
+++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts
@@ -150,12 +150,7 @@ export class ObservabilityBuilder {
return this;
}
- private createBatchProcessor(): BatchSpanProcessor {
- if (!isAgent365ExporterEnabled(this.options.configProvider)) {
- logger.info('[ObservabilityBuilder] Agent 365 exporter not enabled. Using ConsoleSpanExporter for BatchSpanProcessor.');
- return new BatchSpanProcessor(new ConsoleSpanExporter());
- }
-
+ private createExporterOptions(): Agent365ExporterOptions {
const opts = new Agent365ExporterOptions();
if (this.options.exporterOptions) {
Object.assign(opts, this.options.exporterOptions);
@@ -164,6 +159,16 @@ export class ObservabilityBuilder {
if (this.options.tokenResolver) {
opts.tokenResolver = this.options.tokenResolver;
}
+ return opts;
+ }
+
+ private createBatchProcessor(): BatchSpanProcessor {
+ if (!isAgent365ExporterEnabled(this.options.configProvider)) {
+ logger.info('[ObservabilityBuilder] Agent 365 exporter not enabled. Using ConsoleSpanExporter for BatchSpanProcessor.');
+ return new BatchSpanProcessor(new ConsoleSpanExporter());
+ }
+
+ const opts = this.createExporterOptions();
return new BatchSpanProcessor(new Agent365Exporter(opts, this.options.configProvider), {
maxQueueSize: opts.maxQueueSize,
scheduledDelayMillis: opts.scheduledDelayMilliseconds,
@@ -178,14 +183,7 @@ export class ObservabilityBuilder {
return new PerRequestSpanProcessor(new ConsoleSpanExporter());
}
- const opts = new Agent365ExporterOptions();
- if (this.options.exporterOptions) {
- Object.assign(opts, this.options.exporterOptions);
- }
- opts.clusterCategory = this.options.clusterCategory || opts.clusterCategory || ClusterCategory.prod;
-
- // For per-request export, token is retrieved from OTel Context by Agent365Exporter
- // using getExportToken(), so no tokenResolver is needed here
+ const opts = this.createExporterOptions();
return new PerRequestSpanProcessor(new Agent365Exporter(opts, this.options.configProvider));
}
diff --git a/packages/agents-a365-observability/src/ObservabilityManager.ts b/packages/agents-a365-observability/src/ObservabilityManager.ts
index 7b96c44d..225a29f6 100644
--- a/packages/agents-a365-observability/src/ObservabilityManager.ts
+++ b/packages/agents-a365-observability/src/ObservabilityManager.ts
@@ -43,6 +43,10 @@ export class ObservabilityManager {
builder.withTokenResolver(options.tokenResolver);
}
+ if (options?.exporterOptions) {
+ builder.withExporterOptions(options.exporterOptions);
+ }
+
if (options?.clusterCategory) {
builder.withClusterCategory(options.clusterCategory);
}
diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts
index b1f78544..ad00731d 100644
--- a/packages/agents-a365-observability/src/index.ts
+++ b/packages/agents-a365-observability/src/index.ts
@@ -5,6 +5,7 @@
export { ObservabilityManager } from './ObservabilityManager';
export { ObservabilityBuilder as Builder, BuilderOptions } from './ObservabilityBuilder';
export { Agent365ExporterOptions } from './tracing/exporter/Agent365ExporterOptions';
+export type { TokenResolver } from './tracing/exporter/Agent365ExporterOptions';
// Tracing constants
export { OpenTelemetryConstants } from './tracing/constants';
export { ExporterEventNames } from './tracing/exporter/ExporterEventNames';
diff --git a/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts b/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts
index 6e5d005f..2ef12777 100644
--- a/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts
+++ b/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts
@@ -19,7 +19,7 @@ type TraceBuffer = {
spans: ReadableSpan[];
openCount: number;
rootEnded: boolean;
- rootCtx?: Context; // holds the request Context (with token in ALS)
+ rootCtx?: Context; // preserves request-local baggage and custom exporter state
startedAtMs: number;
rootEndedAtMs?: number;
droppedSpans: number;
@@ -29,8 +29,8 @@ type FlushReason = 'trace_completed' | 'root_ended_grace' | 'max_trace_age' | 'f
/**
* Buffers spans per trace and exports once the request completes.
- * Token is not stored; we export under the saved request Context so that getExportToken()
- * can read the token from the active OpenTelemetry Context at export time.
+ * Exports under the saved request Context to preserve request-local state.
+ * Agent365Exporter acquires credentials through its own app-only resolver, not this Context.
*/
export class PerRequestSpanProcessor implements SpanProcessor {
private traces = new Map();
@@ -101,7 +101,7 @@ export class PerRequestSpanProcessor implements SpanProcessor {
// Capture a context to export under.
// - Use the first seen context as a fallback.
- // - If/when the root span starts, prefer its context (contains token via ALS).
+ // - If/when the root span starts, prefer its request-local context.
if (isRootSpan(span)) {
buf.rootCtx = ctx;
} else {
@@ -231,7 +231,7 @@ export class PerRequestSpanProcessor implements SpanProcessor {
`[PerRequestSpanProcessor] Flushing trace traceId=${traceId} reason=${reason} spans=${spans.length} rootEnded=${trace.rootEnded}`
);
- // Must have captured the root context to access the token
+ // Restore the original request context for baggage and custom exporters.
if (!trace.rootCtx) {
logger.error(`[PerRequestSpanProcessor] Missing rootCtx for trace ${traceId}, cannot export spans`);
return;
@@ -240,7 +240,7 @@ export class PerRequestSpanProcessor implements SpanProcessor {
await this.acquireExportSlot();
try {
- // Export under the original request Context so exporter can read the token from context.active()
+ // Credentials are selected by the exporter, independently of this request context.
await new Promise((resolve) => {
try {
context.with(trace.rootCtx as Context, () => {
diff --git a/packages/agents-a365-observability/src/tracing/context/token-context.ts b/packages/agents-a365-observability/src/tracing/context/token-context.ts
index f8521f6e..cdcf7e7b 100644
--- a/packages/agents-a365-observability/src/tracing/context/token-context.ts
+++ b/packages/agents-a365-observability/src/tracing/context/token-context.ts
@@ -19,6 +19,8 @@ interface TokenHolder {
/**
* Run a function within a Context that carries the per-request export token.
* This keeps the token only in OTel Context (ALS), never in any registry.
+ * These helpers remain available for custom export integrations. Agent365Exporter
+ * does not consume this token; configure its app-only tokenResolver in every mode.
*
* The token can be updated later via `updateExportToken()` before the trace
* is flushed — useful when the callback is long-running and the original
diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts
index fffc5648..7b58c557 100644
--- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts
+++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts
@@ -17,12 +17,10 @@ import {
statusName,
resolveAgent365Endpoint,
getAgent365ObservabilityDomainOverride,
- isPerRequestExportEnabled,
truncateSpan,
estimateSpanBytes,
chunkBySize,
} from './utils';
-import { getExportToken } from '../context/token-context';
import logger, { formatError } from '../../utils/logging';
import { Agent365ExporterOptions } from './Agent365ExporterOptions';
import { ExporterEventNames } from './ExporterEventNames';
@@ -90,8 +88,7 @@ interface MappedSpan {
* Observability span exporter for Agent365:
* - Partitions spans by (tenantId, agentId)
* - Builds OTLP-like JSON: resourceSpans -> scopeSpans -> spans
- * - POSTs per group to https://{endpoint}/observability/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1
- * or, when useS2SEndpoint is true, https://{endpoint}/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1
+ * - POSTs per group to https://{endpoint}/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1
* - Adds Bearer token via token_resolver(agentId, tenantId)
*/
export class Agent365Exporter implements SpanExporter {
@@ -110,8 +107,14 @@ export class Agent365Exporter implements SpanExporter {
throw new Error('Agent365ExporterOptions must be provided (was null/undefined)');
}
- if (!isPerRequestExportEnabled() && !options.tokenResolver) {
- throw new Error('Agent365Exporter tokenResolver must be provided for batch export');
+ if (typeof options.tokenResolver !== 'function') {
+ throw new Error(
+ 'Agent365Exporter requires an app-only OBS tokenResolver. '
+ + 'Per-request export now requires withTokenResolver(...) or '
+ + 'Agent365ExporterOptions.tokenResolver; it no longer reads tokens from '
+ + 'runWithExportToken. Resolvers should cache the acquired token; see '
+ + 'AgenticTokenCache in @microsoft/agents-a365-observability-hosting.',
+ );
}
this.options = options;
this.configProvider = configProvider;
@@ -189,9 +192,8 @@ export class Agent365Exporter implements SpanExporter {
logger.info(`[Agent365Exporter] Split ${spans.length} spans into ${chunks.length} chunks for tenantId: ${tenantId}, agentId: ${agentId}`);
}
- // Select endpoint path based on S2S flag (includes tenantId in path)
- const servicePrefix = this.options.useS2SEndpoint ? '/observabilityService' : '/observability';
- const endpointRelativePath = `${servicePrefix}/tenants/${encodeURIComponent(tenantId)}/otlp/agents/${encodeURIComponent(agentId)}/traces`;
+ // OBS routing is independent of the agent's workload authentication flow.
+ const endpointRelativePath = `/observabilityService/tenants/${encodeURIComponent(tenantId)}/otlp/agents/${encodeURIComponent(agentId)}/traces`;
let url: string;
const domainOverride = getAgent365ObservabilityDomainOverride(this.configProvider);
@@ -207,36 +209,20 @@ export class Agent365Exporter implements SpanExporter {
'content-type': 'application/json'
};
- let token: string | null = null;
- let tokenNotResolvedReason: string | null = null;
- if (isPerRequestExportEnabled()) {
- // For per-request export, get token from OTel Context
- token = getExportToken() ?? null;
- if (!token) {
- tokenNotResolvedReason = 'No token available in OTel Context for per-request export';
- }
- } else {
- // For batch export, use tokenResolver
- if (!this.options.tokenResolver) {
- tokenNotResolvedReason = 'tokenResolver is undefined';
- } else {
- const tokenResult = this.options.tokenResolver(agentId, tenantId);
- token = tokenResult instanceof Promise ? await tokenResult : tokenResult;
- if (token) {
- logger.info('[Agent365Exporter] Token resolved successfully via tokenResolver');
- } else {
- tokenNotResolvedReason = 'No token resolved via tokenResolver';
- }
- }
+ if (typeof this.options.tokenResolver !== 'function') {
+ // Defensive: constructor already rejects a missing resolver; this catches mutation of options after construction.
+ throw new Error('Agent365Exporter tokenResolver was cleared after construction');
}
+ const token = await this.options.tokenResolver(agentId, tenantId);
- if (token) {
+ if (token?.trim()) {
+ logger.info('[Agent365Exporter] Token resolved successfully via app-only tokenResolver');
headers['authorization'] = `Bearer ${token}`;
}
else {
- const skipReason = tokenNotResolvedReason || 'Token not resolved for export request';
+ const skipReason = 'No token resolved via app-only tokenResolver';
logger.event(ExporterEventNames.EXPORT_GROUP, false, 0, `skip exporting: ${skipReason}`, { tenantId, agentId });
- return;
+ throw new Error(skipReason);
}
// Always include tenant id header
diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts
index 340f462a..9b1e7c97 100644
--- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts
+++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts
@@ -6,9 +6,11 @@
import { ClusterCategory } from '@microsoft/agents-a365-runtime';
/**
- * A function that resolves and returns an authentication token for the given agent and tenant.
+ * A function that resolves an app-only OBS token for the given agent and tenant.
+ * Delegated (scp) tokens are not accepted by the S2S service.
* Implementations may perform synchronous lookup (e.g., in-memory cache) or asynchronous network calls.
- * Return null if a token cannot be provided; exporter will log and proceed without an authorization header.
+ * Used in both batch and per-request modes, independently of workload token context.
+ * Return null if a token cannot be provided; export fails without sending an HTTP request.
*/
export type TokenResolver = (agentId: string, tenantId: string) => string | null | Promise;
@@ -19,9 +21,9 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null
* defaults so callers can usually construct without arguments and override selectively.
*
* @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. ClusterCategory.preprod, ClusterCategory.prod, default to ClusterCategory.prod).
- * @property {TokenResolver} [tokenResolver] Optional delegate to obtain an auth token. If omitted the exporter will
- * fall back to reading the cached token (AgenticTokenCacheInstance.getObservabilityToken).
- * @property {boolean} [useS2SEndpoint] When true, exporter will POST to the S2S path (/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces).
+ * @property {TokenResolver} [tokenResolver] App-only OBS token resolver required when constructing Agent365Exporter.
+ * There is no implicit cache lookup or request-context token fallback.
+ * @property {boolean} [useS2SEndpoint] Deprecated compatibility option. Export always uses the S2S path, even when false.
* @property {number} maxQueueSize Maximum span queue size before drops occur (passed to BatchSpanProcessor).
* @property {number} scheduledDelayMilliseconds Delay between automatic batch flush attempts.
* @property {number} exporterTimeoutMilliseconds Maximum time (ms) the BatchSpanProcessor waits for the entire export() call to complete before giving up. Covers partitioning, token resolution, and all HTTP retries.
@@ -32,11 +34,11 @@ export class Agent365ExporterOptions {
/** Environment / cluster category (e.g. ClusterCategory.preprod, ClusterCategory.prod). */
public clusterCategory: ClusterCategory | string = ClusterCategory.prod;
- /** Optional delegate to resolve auth token used by exporter */
- public tokenResolver?: TokenResolver; // Optional if ENABLE_A365_OBSERVABILITY_EXPORTER is false
+ /** Required by Agent365Exporter in every mode; a console-only builder may omit it. */
+ public tokenResolver?: TokenResolver;
- /** When true, use S2S endpoint path for export. */
- public useS2SEndpoint: boolean = false;
+ /** @deprecated Export always uses /observabilityService. This option is ignored. */
+ public useS2SEndpoint: boolean = true;
/** Maximum span queue size before new spans are dropped. */
public maxQueueSize: number = 2048;
diff --git a/packages/agents-a365-observability/src/tracing/exporter/utils.ts b/packages/agents-a365-observability/src/tracing/exporter/utils.ts
index 857a8ab8..e13eaca5 100644
--- a/packages/agents-a365-observability/src/tracing/exporter/utils.ts
+++ b/packages/agents-a365-observability/src/tracing/exporter/utils.ts
@@ -166,7 +166,7 @@ export function isAgent365ExporterEnabled(
* Check if per-request export is enabled.
* Precedence: internal overrides > configuration provider > environment variable.
* When enabled, the PerRequestSpanProcessor is used instead of BatchSpanProcessor.
- * The token is passed via OTel Context (async local storage) at export time.
+ * Credential selection is unchanged: Agent365Exporter uses its app-only token resolver.
* @param configProvider Optional configuration provider. Defaults to defaultPerRequestSpanProcessorConfigurationProvider if not specified.
*/
export function isPerRequestExportEnabled(
diff --git a/packages/agents-a365-tooling/package.json b/packages/agents-a365-tooling/package.json
index 092d4bc3..7d4364c8 100644
--- a/packages/agents-a365-tooling/package.json
+++ b/packages/agents-a365-tooling/package.json
@@ -36,6 +36,7 @@
"@microsoft/agents-a365-runtime": "workspace:*",
"@microsoft/agents-hosting": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
+ "axios": "catalog:",
"express": "catalog:",
"hono": "catalog:"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9356688a..143bd78f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -226,6 +226,9 @@ importers:
'@opentelemetry/api':
specifier: 'catalog:'
version: 1.9.1
+ '@opentelemetry/core':
+ specifier: 'catalog:'
+ version: 2.7.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-trace-otlp-http':
specifier: 'catalog:'
version: 0.213.0(@opentelemetry/api@1.9.1)
@@ -495,6 +498,9 @@ importers:
'@modelcontextprotocol/sdk':
specifier: 'catalog:'
version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
+ axios:
+ specifier: ^1.16.0
+ version: 1.20.0
express:
specifier: 'catalog:'
version: 5.2.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 13eacac6..3ff90f26 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -10,6 +10,9 @@ catalog:
# Azure packages
"@azure/identity": "^4.12.1"
+ # HTTP client
+ "axios": "^1.16.0"
+
# Express framework
"express": "^5.2.0"
diff --git a/tests/jest.config.cjs b/tests/jest.config.cjs
index 3627b5ff..8511a6d0 100644
--- a/tests/jest.config.cjs
+++ b/tests/jest.config.cjs
@@ -69,6 +69,7 @@ module.exports = {
moduleNameMapper: {
'^@microsoft/agents-a365-runtime$': '/packages/agents-a365-runtime/src',
'^@microsoft/agents-a365-observability$': '/packages/agents-a365-observability/src',
+ '^@microsoft/agents-a365-observability-hosting$': '/packages/agents-a365-observability-hosting/src',
'^@microsoft/agents-a365-observability-extensions-langchain$': '/packages/agents-a365-observability-extensions-langchain/src',
'^@microsoft/agents-a365-observability-extensions-openai$': '/packages/agents-a365-observability-extensions-openai/src',
'^@microsoft/agents-a365-observability-tokencache$': '/packages/agents-a365-observability-tokencache/src',
diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts
index 724eaccc..8c3027ee 100644
--- a/tests/observability/core/agent365-exporter.test.ts
+++ b/tests/observability/core/agent365-exporter.test.ts
@@ -14,6 +14,7 @@ import { truncateSpan } from '@microsoft/agents-a365-observability/src/tracing/e
import { runWithExportToken } from '@microsoft/agents-a365-observability/src/tracing/context/token-context';
import { context as otelContext } from '@opentelemetry/api';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
+import { AgenticTokenCache } from '@microsoft/agents-a365-observability-hosting';
// Minimal mock span factory
function makeSpan(attrs: Record, name = 'test'): ReadableSpan {
@@ -42,6 +43,10 @@ function makeSpan(attrs: Record, name = 'test'): ReadableSpan {
const tenantId = 'tenant-11111111-1111-1111-1111-111111111111';
const agentId = 'agent-22222222-2222-2222-2222-222222222222';
+function makeToken(claims: Record): string {
+ return `${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.offline-signature`;
+}
+
// Patch global fetch
const originalFetch = global.fetch;
@@ -138,7 +143,7 @@ describe('Agent365Exporter', () => {
expect(fetchCalls.length).toBe(1);
const urlArg = fetchCalls[0][0];
const headersArg = fetchCalls[0][1].headers;
- expect(urlArg).toBe(`${expectedUrl}/observability/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`);
+ expect(urlArg).toBe(`${expectedUrl}/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`);
expect(headersArg['x-ms-tenant-id']).toBe(tenantId);
expect(headersArg['authorization']).toBe(`Bearer ${token}`);
});
@@ -193,7 +198,7 @@ describe('Agent365Exporter', () => {
const urlArg = fetchCalls[0][0] as string;
const headersArg = fetchCalls[0][1].headers as Record;
- expect(urlArg).toBe(`${expectedBaseUrl}/observability/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`);
+ expect(urlArg).toBe(`${expectedBaseUrl}/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`);
expect(headersArg['x-ms-tenant-id']).toBe(tenantId);
expect(headersArg['authorization']).toBe(`Bearer ${token}`);
});
@@ -218,7 +223,7 @@ describe('Agent365Exporter', () => {
expect(fetchCalls.length).toBe(1);
const urlArg = fetchCalls[0][0];
const headersArg = fetchCalls[0][1].headers;
- expect(urlArg).toBe(`https://agent365.svc.cloud.microsoft/observability/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`);
+ expect(urlArg).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`);
expect(headersArg['x-ms-tenant-id']).toBe(tenantId);
expect(headersArg['authorization']).toBe(`Bearer ${token}`);
});
@@ -227,15 +232,96 @@ describe('Agent365Exporter', () => {
const opts = new Agent365ExporterOptions();
opts.clusterCategory = 'local';
// Intentionally omit tokenResolver
- expect(() => new Agent365Exporter(opts)).toThrow(/tokenResolver must be provided/);
+ expect(() => new Agent365Exporter(opts))
+ .toThrow(/requires an app-only OBS tokenResolver[\s\S]*withTokenResolver/);
+ });
+
+ it.each([null, '', ' '])('reports failed export without sending an empty token (%j)', async (token) => {
+ mockFetchSequence([200]);
+ const opts = new Agent365ExporterOptions();
+ opts.tokenResolver = () => token;
+ const exporter = new Agent365Exporter(opts);
+ const callback = jest.fn();
+
+ await exporter.export([makeSpan({
+ [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId,
+ [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId,
+ })], callback);
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED });
+ expect(getFetchCalls()).toHaveLength(0);
+ });
+
+ it('reports resolver acquisition failure without sending a request or falling back', async () => {
+ mockFetchSequence([200]);
+ const opts = new Agent365ExporterOptions();
+ const resolver = jest.fn(async (_agentId: string, _tenantId: string) => {
+ throw new Error('app-only token acquisition failed');
+ });
+ opts.tokenResolver = resolver;
+ const exporter = new Agent365Exporter(opts);
+ const callback = jest.fn();
+
+ await exporter.export([makeSpan({
+ [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId,
+ [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId,
+ })], callback);
+
+ expect(resolver).toHaveBeenCalledTimes(1);
+ expect(resolver).toHaveBeenCalledWith(agentId, tenantId);
+ expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED });
+ expect(getFetchCalls()).toHaveLength(0);
});
- it('uses S2S endpoint path when useS2SEndpoint is true', async () => {
+
+ it.each([
+ { description: 'absent', roles: undefined },
+ { description: 'empty', roles: [] },
+ ])('exports an app token with $description roles from the hosting resolver', async ({ roles }) => {
+ mockFetchSequence([200]);
+ const claims = {
+ idtyp: 'app', tid: tenantId, azp: agentId, roles,
+ aud: '9b975845-388f-4429-889e-eab1ef63949c',
+ exp: Math.floor(Date.now() / 1000) + 300,
+ };
+ const token = makeToken(claims);
+ const cache = new AgenticTokenCache();
+ const resolver = jest.fn(async (_agentId: string, _tenantId: string, _scopes: readonly string[]) => token);
+ await cache.RefreshObservabilityToken(agentId, tenantId, resolver);
+ const opts = new Agent365ExporterOptions();
+ opts.useS2SEndpoint = false;
+ opts.tokenResolver = (agent, tenant) => cache.getObservabilityToken(agent, tenant);
+ const exporter = new Agent365Exporter(opts);
+ const callback = jest.fn();
+
+ await exporter.export([makeSpan({
+ [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId,
+ [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId,
+ })], callback);
+
+ expect(resolver).toHaveBeenCalledWith(agentId, tenantId, [
+ 'api://9b975845-388f-4429-889e-eab1ef63949c/.default',
+ ]);
+ expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS });
+ const calls = getFetchCalls();
+ expect(calls).toHaveLength(1);
+ expect(calls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`);
+ expect(calls[0][1].headers['authorization']).toBe(`Bearer ${token}`);
+ });
+
+ it('defaults the legacy endpoint option to S2S', () => {
+ expect(new Agent365ExporterOptions().useS2SEndpoint).toBe(true);
+ });
+
+ it.each([undefined, false, true])('always uses S2S when useS2SEndpoint is %s', async (useS2SEndpoint) => {
mockFetchSequence([200]);
const token = 'tok-s2s';
const opts = new Agent365ExporterOptions();
opts.clusterCategory = 'prod';
opts.tokenResolver = () => token;
- opts.useS2SEndpoint = true;
+ if (useS2SEndpoint !== undefined) {
+ opts.useS2SEndpoint = useS2SEndpoint;
+ }
const exporter = new Agent365Exporter(opts);
const spans = [
@@ -259,14 +345,14 @@ describe('Agent365Exporter', () => {
expect(headersArg['x-ms-tenant-id']).toBe(tenantId);
});
- it('uses S2S endpoint path with domain override and sets x-ms-tenant-id', async () => {
+ it.each([false, true])('uses S2S with a domain override and legacy option %s', async (useS2SEndpoint) => {
mockFetchSequence([200]);
process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://custom.domain';
const token = 'tok-s2s-custom';
const opts = new Agent365ExporterOptions();
opts.clusterCategory = 'prod';
opts.tokenResolver = () => token;
- opts.useS2SEndpoint = true;
+ opts.useS2SEndpoint = useS2SEndpoint;
const exporter = new Agent365Exporter(opts);
const spans = [
@@ -289,6 +375,25 @@ describe('Agent365Exporter', () => {
});
+ it.each([401, 403, 404])('does not fall back to OBO when S2S returns %s', async (status) => {
+ mockFetchSequence([status]);
+ const opts = new Agent365ExporterOptions();
+ opts.tokenResolver = () => 'test-obs-token';
+ opts.useS2SEndpoint = false;
+ const exporter = new Agent365Exporter(opts);
+ const callback = jest.fn();
+
+ await exporter.export([makeSpan({
+ [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId,
+ [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId,
+ })], callback);
+
+ expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED });
+ const calls = getFetchCalls();
+ expect(calls).toHaveLength(1);
+ expect(calls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`);
+ });
+
it('passes httpRequestTimeoutMilliseconds to fetch AbortSignal.timeout', async () => {
const customTimeout = 12345;
mockFetchSequence([200]);
@@ -1012,7 +1117,7 @@ describe('Agent365Exporter', () => {
});
});
- describe('per-request export (token from OTel Context)', () => {
+ describe('per-request app-only export', () => {
let contextManager: AsyncLocalStorageContextManager | undefined;
beforeEach(() => {
@@ -1028,12 +1133,23 @@ describe('Agent365Exporter', () => {
contextManager = undefined;
});
- it('acquires export token from OTel Context when per-request export is enabled', async () => {
+ it.each([
+ { useS2SEndpoint: false, roles: undefined },
+ { useS2SEndpoint: true, roles: [] },
+ ])('uses its app-only resolver with legacy option $useS2SEndpoint and ignores delegated context', async ({ useS2SEndpoint, roles }) => {
mockFetchSequence([200]);
process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true';
const opts = new Agent365ExporterOptions();
opts.clusterCategory = 'local';
+ opts.useS2SEndpoint = useS2SEndpoint;
+ const exportToken = makeToken({
+ idtyp: 'app', tid: tenantId, azp: agentId, roles,
+ aud: '9b975845-388f-4429-889e-eab1ef63949c',
+ exp: Math.floor(Date.now() / 1000) + 300,
+ });
+ const resolver = jest.fn(async (_agentId: string, _tenantId: string) => exportToken);
+ opts.tokenResolver = resolver;
const exporter = new Agent365Exporter(opts);
const spans = [
@@ -1044,18 +1160,94 @@ describe('Agent365Exporter', () => {
];
const callback = jest.fn();
- const exportToken = 'tok-from-context';
- await runWithExportToken(exportToken, async () => exporter.export(spans, callback));
+ const delegatedToken = makeToken({ scp: 'User.Read', idtyp: 'user', tid: tenantId });
+ await runWithExportToken(delegatedToken, async () => exporter.export(spans, callback));
expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS });
+ expect(resolver).toHaveBeenCalledTimes(1);
+ expect(resolver).toHaveBeenCalledWith(agentId, tenantId);
- // Verify export was attempted (should be greater than 0 when enabled)
const fetchCalls = getFetchCalls();
- expect(fetchCalls.length).toBeGreaterThan(0);
+ expect(fetchCalls).toHaveLength(1);
+ expect(fetchCalls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`);
- // Verify token came from OTel Context (per-request mode)
+ // The workload's delegated context token must not become an OBS credential.
const headersArg = fetchCalls[0][1].headers as Record;
expect(headersArg['authorization']).toBe(`Bearer ${exportToken}`);
});
+
+ it.each([401, 403, 404])('does not fall back from per-request S2S after HTTP %s', async (status) => {
+ mockFetchSequence([status]);
+ process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true';
+ const opts = new Agent365ExporterOptions();
+ opts.useS2SEndpoint = false;
+ const resolver = jest.fn(async () => makeToken({ idtyp: 'app', tid: tenantId, azp: agentId }));
+ opts.tokenResolver = resolver;
+ const exporter = new Agent365Exporter(opts);
+ const callback = jest.fn();
+
+ await runWithExportToken(makeToken({ scp: 'User.Read', idtyp: 'user' }), async () => exporter.export([makeSpan({
+ [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId,
+ [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId,
+ })], callback));
+
+ expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED });
+ expect(resolver).toHaveBeenCalledTimes(1);
+ const calls = getFetchCalls();
+ expect(calls).toHaveLength(1);
+ expect(calls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`);
+ });
+
+ it('exports with the app-only resolver when no context token exists', async () => {
+ mockFetchSequence([200]);
+ process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true';
+ const opts = new Agent365ExporterOptions();
+ const resolver = jest.fn(() => makeToken({ idtyp: 'app', tid: tenantId, azp: agentId }));
+ opts.tokenResolver = resolver;
+ const exporter = new Agent365Exporter(opts);
+ const callback = jest.fn();
+
+ await exporter.export([makeSpan({
+ [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId,
+ [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId,
+ })], callback);
+
+ expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS });
+ expect(resolver).toHaveBeenCalledTimes(1);
+ expect(getFetchCalls()).toHaveLength(1);
+ });
+
+ it('requires an app-only resolver even when a context token is present', () => {
+ mockFetchSequence([200]);
+ process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true';
+
+ runWithExportToken(makeToken({ scp: 'User.Read' }), () => {
+ expect(() => new Agent365Exporter(new Agent365ExporterOptions()))
+ .toThrow(/requires an app-only OBS tokenResolver[\s\S]*withTokenResolver/);
+ });
+ expect(getFetchCalls()).toHaveLength(0);
+ });
+
+ it.each(['missing', 'failed'])('does not use a delegated context token after %s app-only acquisition', async (failure) => {
+ mockFetchSequence([200]);
+ process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true';
+ const opts = new Agent365ExporterOptions();
+ const resolver = jest.fn(async () => {
+ if (failure === 'failed') throw new Error('App-only acquisition failed');
+ return null;
+ });
+ opts.tokenResolver = resolver;
+ const exporter = new Agent365Exporter(opts);
+ const callback = jest.fn();
+
+ await runWithExportToken(makeToken({ scp: 'User.Read' }), async () => exporter.export([makeSpan({
+ [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId,
+ [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId,
+ })], callback));
+
+ expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED });
+ expect(resolver).toHaveBeenCalledTimes(1);
+ expect(getFetchCalls()).toHaveLength(0);
+ });
});
});
diff --git a/tests/observability/core/observabilityBuilder-per-request-auth.test.ts b/tests/observability/core/observabilityBuilder-per-request-auth.test.ts
new file mode 100644
index 00000000..04022b8d
--- /dev/null
+++ b/tests/observability/core/observabilityBuilder-per-request-auth.test.ts
@@ -0,0 +1,138 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
+import { context, trace } from '@opentelemetry/api';
+import { ObservabilityBuilder } from '@microsoft/agents-a365-observability/src/ObservabilityBuilder';
+import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability/src/tracing/constants';
+import { runWithExportToken } from '@microsoft/agents-a365-observability/src/tracing/context/token-context';
+import type { TokenResolver } from '@microsoft/agents-a365-observability';
+
+const tenantId = '11111111-1111-1111-1111-111111111111';
+const agentId = '22222222-2222-2222-2222-222222222222';
+const originalFetch = global.fetch;
+
+function token(claims: Record): string {
+ return `${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.offline-signature`;
+}
+
+function appToken(agent = agentId, tenant = tenantId): string {
+ return token({
+ idtyp: 'app', azp: agent, tid: tenant,
+ aud: '9b975845-388f-4429-889e-eab1ef63949c',
+ exp: Math.floor(Date.now() / 1000) + 300,
+ });
+}
+
+describe('ObservabilityBuilder per-request OBS authentication', () => {
+ let originalEnv: NodeJS.ProcessEnv;
+ let builder: ObservabilityBuilder;
+ let requests: Request[];
+
+ beforeEach(() => {
+ originalEnv = { ...process.env };
+ process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true';
+ process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true';
+ delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE;
+ requests = [];
+ global.fetch = jest.fn(async (input, init) => {
+ requests.push(new Request(input, init));
+ return new Response('{}', { status: 200 });
+ });
+ builder = new ObservabilityBuilder().withService('per-request-auth-regression');
+ });
+
+ afterEach(async () => {
+ await builder.shutdown();
+ trace.disable();
+ context.disable();
+ process.env = originalEnv;
+ global.fetch = originalFetch;
+ });
+
+ async function invoke(agent = agentId, tenant = tenantId, contextToken?: string): Promise {
+ const emit = () => {
+ trace.getTracer('per-request-auth-regression').startSpan('invoke_agent offline', {
+ attributes: {
+ [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: 'invoke_agent',
+ [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agent,
+ [OpenTelemetryConstants.TENANT_ID_KEY]: tenant,
+ },
+ }).end();
+ };
+ if (contextToken === undefined) emit();
+ else runWithExportToken(contextToken, emit);
+ // Span completion schedules asynchronous token resolution and export.
+ await new Promise(resolve => setImmediate(resolve));
+ }
+
+ it.each(['builder', 'exporterOptions', 'builderOverride'])('uses the %s app resolver through the real processor and exporter', async (source) => {
+ const expectedToken = appToken();
+ const resolver = jest.fn(async () => expectedToken);
+ const overridden = jest.fn(async () => 'must-not-be-used');
+ builder.withExporterOptions({ useS2SEndpoint: false });
+ if (source !== 'builder') {
+ builder.withExporterOptions({
+ useS2SEndpoint: false,
+ tokenResolver: source === 'exporterOptions' ? resolver : overridden,
+ });
+ }
+ if (source !== 'exporterOptions') builder.withTokenResolver(resolver);
+ builder.start();
+
+ await invoke(agentId, tenantId, token({ idtyp: 'user', scp: 'User.Read', tid: tenantId }));
+
+ expect(resolver).toHaveBeenCalledWith(agentId, tenantId);
+ expect(overridden).not.toHaveBeenCalled();
+ expect(requests).toHaveLength(1);
+ expect(requests[0].url).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`);
+ expect(requests[0].headers.get('authorization')).toBe(`Bearer ${expectedToken}`);
+ });
+
+ it('does not require a request-context credential', async () => {
+ const resolver = jest.fn(async () => appToken());
+ builder.withTokenResolver(resolver).start();
+
+ await invoke();
+
+ expect(resolver).toHaveBeenCalledWith(agentId, tenantId);
+ expect(requests).toHaveLength(1);
+ });
+
+ it('resolves separate app identities for concurrent workload contexts', async () => {
+ const otherAgent = '33333333-3333-3333-3333-333333333333';
+ const otherTenant = '44444444-4444-4444-4444-444444444444';
+ const tokens = new Map([
+ [`${agentId}:${tenantId}`, appToken()],
+ [`${otherAgent}:${otherTenant}`, appToken(otherAgent, otherTenant)],
+ ]);
+ const resolver = jest.fn(async (agent, tenant) => tokens.get(`${agent}:${tenant}`) ?? null);
+ builder.withTokenResolver(resolver).start();
+
+ await Promise.all([
+ invoke(agentId, tenantId, token({ scp: 'User.Read', oid: 'first-user' })),
+ invoke(otherAgent, otherTenant, token({ scp: 'User.Read', oid: 'second-user' })),
+ ]);
+
+ expect(resolver).toHaveBeenCalledTimes(2);
+ expect(resolver).toHaveBeenCalledWith(agentId, tenantId);
+ expect(resolver).toHaveBeenCalledWith(otherAgent, otherTenant);
+ expect(requests).toHaveLength(2);
+ for (const [agent, tenant] of [[agentId, tenantId], [otherAgent, otherTenant]]) {
+ const request = requests.find(candidate => candidate.headers.get('x-ms-tenant-id') === tenant);
+ expect(request?.headers.get('authorization')).toBe(`Bearer ${tokens.get(`${agent}:${tenant}`)}`);
+ }
+ });
+
+ it('fails configuration without an app resolver when OBS export is enabled', () => {
+ expect(() => builder.start())
+ .toThrow(/requires an app-only OBS tokenResolver[\s\S]*withTokenResolver/);
+ expect(requests).toHaveLength(0);
+ });
+
+ it('keeps console-only configuration usable without an app resolver', () => {
+ process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'false';
+ expect(() => builder.start()).not.toThrow();
+ expect(requests).toHaveLength(0);
+ });
+});
diff --git a/tests/observability/core/observabilityManager-exporter-options.test.ts b/tests/observability/core/observabilityManager-exporter-options.test.ts
new file mode 100644
index 00000000..9cd2d9da
--- /dev/null
+++ b/tests/observability/core/observabilityManager-exporter-options.test.ts
@@ -0,0 +1,92 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
+import { context, trace } from '@opentelemetry/api';
+import { ObservabilityManager } from '@microsoft/agents-a365-observability/src/ObservabilityManager';
+import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability/src/tracing/constants';
+import type { TokenResolver } from '@microsoft/agents-a365-observability';
+
+const tenantId = '11111111-1111-1111-1111-111111111111';
+const agentId = '22222222-2222-2222-2222-222222222222';
+const originalFetch = global.fetch;
+
+function appToken(): string {
+ const claims = {
+ idtyp: 'app', azp: agentId, tid: tenantId,
+ aud: '9b975845-388f-4429-889e-eab1ef63949c',
+ exp: Math.floor(Date.now() / 1000) + 300,
+ };
+ return `${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.offline-signature`;
+}
+
+describe('ObservabilityManager.start exporter options', () => {
+ let originalEnv: NodeJS.ProcessEnv;
+ let requests: Request[];
+
+ beforeEach(() => {
+ originalEnv = { ...process.env };
+ process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true';
+ process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true';
+ delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE;
+ requests = [];
+ global.fetch = jest.fn(async (input, init) => {
+ requests.push(new Request(input, init));
+ return new Response('{}', { status: 200 });
+ });
+ });
+
+ afterEach(async () => {
+ await ObservabilityManager.shutdown();
+ trace.disable();
+ context.disable();
+ process.env = originalEnv;
+ global.fetch = originalFetch;
+ });
+
+ async function emitSpan(): Promise {
+ trace.getTracer('manager-exporter-options').startSpan('invoke_agent offline', {
+ attributes: {
+ [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: 'invoke_agent',
+ [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId,
+ [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId,
+ },
+ }).end();
+ // Span completion schedules asynchronous token resolution and export.
+ await new Promise(resolve => setImmediate(resolve));
+ }
+
+ it('forwards exporterOptions.tokenResolver so the documented migration path starts and exports', async () => {
+ const expectedToken = appToken();
+ const resolver = jest.fn(async () => expectedToken);
+
+ // Without forwarding, start() throws because the exporter requires an app-only resolver.
+ expect(() => ObservabilityManager.start({
+ serviceName: 'manager-exporter-options',
+ exporterOptions: { tokenResolver: resolver },
+ })).not.toThrow();
+ await emitSpan();
+
+ expect(resolver).toHaveBeenCalledWith(agentId, tenantId);
+ expect(requests).toHaveLength(1);
+ expect(requests[0].url).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`);
+ expect(requests[0].headers.get('authorization')).toBe(`Bearer ${expectedToken}`);
+ });
+
+ it('keeps the top-level tokenResolver ahead of exporterOptions.tokenResolver', async () => {
+ const expectedToken = appToken();
+ const resolver = jest.fn(async () => expectedToken);
+ const overridden = jest.fn(async () => 'must-not-be-used');
+
+ ObservabilityManager.start({
+ serviceName: 'manager-exporter-options',
+ tokenResolver: resolver,
+ exporterOptions: { tokenResolver: overridden },
+ });
+ await emitSpan();
+
+ expect(resolver).toHaveBeenCalledWith(agentId, tenantId);
+ expect(overridden).not.toHaveBeenCalled();
+ expect(requests[0].headers.get('authorization')).toBe(`Bearer ${expectedToken}`);
+ });
+});
diff --git a/tests/observability/extension/hosting/agentic-token-cache.test.ts b/tests/observability/extension/hosting/agentic-token-cache.test.ts
index bacdcb6e..9cccf58d 100644
--- a/tests/observability/extension/hosting/agentic-token-cache.test.ts
+++ b/tests/observability/extension/hosting/agentic-token-cache.test.ts
@@ -1,265 +1,241 @@
-// ------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-// ------------------------------------------------------------------------------
-import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-hosting';
+import { AgenticTokenCache, ObservabilityTokenResolver } from '@microsoft/agents-a365-observability-hosting';
+import { ObservabilityConfiguration, resetLogger, setLogger } from '@microsoft/agents-a365-observability';
+import type { Authorization, TurnContext } from '@microsoft/agents-hosting';
-interface TurnContextStub { activity: { id: string } }
-interface AuthorizationStub {
- exchangeToken: (...args: any[]) => Promise<{ token: string | undefined }>
- getToken: (...args: any[]) => Promise<{ token: string }>
- signOut: () => Promise | void
- onSignInSuccess: () => void
- onSignInFailure: () => void
-}
-interface SequenceStep { token?: string; error?: unknown }
-
-const makeTurnContext = (): TurnContextStub => ({ activity: { id: 'a1' } });
-
-// Helper to cast our minimal stub to the SDK TurnContext type expected by the cache
-const asTurnContext = (stub: TurnContextStub): import('@microsoft/agents-hosting').TurnContext => {
- return stub as unknown as import('@microsoft/agents-hosting').TurnContext;
-};
+const obsScopes = ['api://9b975845-388f-4429-889e-eab1ef63949c/.default'];
-function makeJwtWithExp(expSecondsFromNow: number): string {
+function makeJwtWithExp(expSecondsFromNow: number, claims: Record = {}): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
- const exp = Math.floor(Date.now() / 1000) + expSecondsFromNow;
- const payload = Buffer.from(JSON.stringify({ exp })).toString('base64url');
- return `${header}.${payload}.sig`;
+ const payload = Buffer.from(JSON.stringify({
+ exp: Math.floor(Date.now() / 1000) + expSecondsFromNow,
+ idtyp: 'app',
+ ...claims,
+ })).toString('base64url');
+ return `${header}.${payload}.test-signature`;
}
-function makeAuthorizationMock(sequence: SequenceStep[]): AuthorizationStub {
- let call = 0;
- const authLike: AuthorizationStub = {
- exchangeToken: async () => {
- const current = sequence[Math.min(call, sequence.length - 1)];
- call++;
- if (current.error) throw current.error;
- return { token: current.token || '' };
- },
- getToken: async () => ({ token: 'unused' }),
- signOut: async () => {},
- onSignInSuccess: () => {},
- onSignInFailure: () => {}
- };
- return authLike;
-}
+describe('AgenticTokenCache app-only OBS authentication', () => {
+ let cache: AgenticTokenCache;
-describe('AgenticTokenCacheInstance', () => {
beforeEach(() => {
- AgenticTokenCacheInstance.invalidateAll();
+ cache = new AgenticTokenCache();
jest.useFakeTimers();
});
+
afterEach(() => {
jest.useRealTimers();
});
it('returns null when no entry exists', () => {
- const token = AgenticTokenCacheInstance.getObservabilityToken('agentX', 'tenantY');
- expect(token).toBeNull();
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull();
});
- it('exchanges and caches token on first call', async () => {
+ it('passes exporting identity and configured OBS scopes to an app-only resolver', async () => {
const token = makeJwtWithExp(300);
- const auth = makeAuthorizationMock([{ token }]);
- await AgenticTokenCacheInstance.RefreshObservabilityToken(
- 'agentA',
- 'tenantA',
- asTurnContext(makeTurnContext()),
- auth as any,
- ['scope.read']
- );
- const tokenReturned = AgenticTokenCacheInstance.getObservabilityToken('agentA', 'tenantA');
- expect(tokenReturned).not.toBeNull();
- expect(tokenReturned).toBe(token);
+ const resolver = jest.fn, Parameters>(() => token);
+
+ await cache.RefreshObservabilityToken('agent', 'tenant', resolver);
+
+ expect(resolver).toHaveBeenCalledTimes(1);
+ expect(resolver).toHaveBeenCalledWith('agent', 'tenant', obsScopes);
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token);
+ });
+
+ it('uses a configuration provider for app-only scopes', async () => {
+ const scopes = ['api://custom-obs/.default'];
+ cache = new AgenticTokenCache({
+ getConfiguration: () => new ObservabilityConfiguration({
+ observabilityAuthenticationScopes: () => scopes,
+ }),
+ });
+ const resolver = jest.fn(() => makeJwtWithExp(300));
+
+ await cache.RefreshObservabilityToken('agent', 'tenant', resolver);
+
+ expect(resolver).toHaveBeenCalledWith('agent', 'tenant', scopes);
+ });
+
+ it.each([
+ { description: 'absent', roles: undefined },
+ { description: 'empty', roles: [] },
+ ])('caches an explicitly app-only token with $description roles', async ({ roles }) => {
+ const token = makeJwtWithExp(300, { roles });
+ await cache.RefreshObservabilityToken('agent', 'tenant', () => token);
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token);
});
- it('retries on retriable error then succeeds', async () => {
+ it('deduplicates concurrent roleless-token acquisitions for the same identity', 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 resolver = jest.fn(async () => token);
+ await Promise.all(Array.from({ length: 8 }, () =>
+ cache.RefreshObservabilityToken('agent', 'tenant', resolver)));
+ expect(resolver).toHaveBeenCalledTimes(1);
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token);
+ });
+
+ it('fails for an empty scope configuration without requesting a token', async () => {
+ cache = new AgenticTokenCache({
+ getConfiguration: () => new ObservabilityConfiguration({
+ observabilityAuthenticationScopes: () => [],
+ }),
});
- const auth: AuthorizationStub = {
- exchangeToken: exchangeFn,
- getToken: async () => ({ token: 'unused' }),
- signOut: async () => {},
- onSignInSuccess: () => {},
- onSignInFailure: () => {}
+ const resolver = jest.fn(() => makeJwtWithExp(300));
+ await expect(cache.RefreshObservabilityToken('agent', 'tenant', resolver)).rejects.toThrow('No valid scopes');
+ expect(resolver).not.toHaveBeenCalled();
+ });
+
+ it.each(['agentic', 'obo'])('ignores the removed %s user-authorization overload without exchanging a token', async (handler) => {
+ const exchangeToken = jest.fn();
+ const authorization: Authorization = {
+ exchangeToken,
+ getToken: jest.fn(),
+ signOut: jest.fn(),
+ onSignInSuccess: jest.fn(),
+ onSignInFailure: jest.fn(),
};
- const p = AgenticTokenCacheInstance.RefreshObservabilityToken(
- 'agentB',
- 'tenantB',
- asTurnContext(makeTurnContext()),
- auth as any,
- ['scope.read']
- );
- await (jest as any).advanceTimersByTimeAsync?.(1000) || jest.advanceTimersByTime(1000);
- await p;
- const tokenReturned = AgenticTokenCacheInstance.getObservabilityToken('agentB', 'tenantB');
- expect(tokenReturned).not.toBeNull();
- expect(tokenReturned).toBe(token);
- expect(exchangeFn).toHaveBeenCalledTimes(2);
+ const context = {} as TurnContext;
+ const error = jest.fn();
+ setLogger({ info: jest.fn(), warn: jest.fn(), error, event: jest.fn() });
+ // Untyped (JavaScript) callers can still reach the removed overload at runtime.
+ const untypedRefresh = cache.RefreshObservabilityToken.bind(cache) as unknown as (...args: unknown[]) => Promise;
+
+ try {
+ await expect(untypedRefresh('agent', 'tenant', context, authorization, obsScopes, handler)).resolves.toBeUndefined();
+ await expect(untypedRefresh('agent', 'tenant', context, authorization, obsScopes, handler)).resolves.toBeUndefined();
+
+ expect(exchangeToken).not.toHaveBeenCalled();
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull();
+ expect(error.mock.calls.filter(([message]) => String(message).includes('was removed in 2.0.0'))).toHaveLength(1);
+ } finally {
+ resetLogger();
+ }
+ });
+
+ it('no longer types the removed TurnContext/Authorization overload', async () => {
+ setLogger({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), event: jest.fn() });
+ try {
+ // @ts-expect-error The TurnContext/Authorization overload was removed in 2.0.0; callers must pass a token resolver.
+ await cache.RefreshObservabilityToken('agent', 'tenant', {} as TurnContext, {} as Authorization, obsScopes);
+ } finally {
+ resetLogger();
+ }
});
- it('stops on non-retriable error and leaves token null', async () => {
- const nonRetriableErr = { status: 400, message: 'bad request' };
- const auth = makeAuthorizationMock([
- { error: nonRetriableErr },
- { token: makeJwtWithExp(300) } // should not be used
- ]);
- await AgenticTokenCacheInstance.RefreshObservabilityToken(
- 'agentC',
- 'tenantC',
- asTurnContext(makeTurnContext()),
- auth as any,
- ['scope.read']
- );
- const token = AgenticTokenCacheInstance.getObservabilityToken('agentC', 'tenantC');
- expect(token).toBeNull();
+ it.each([['', 'tenant'], ['agent', ' ']])('rejects empty identity (%s, %s)', async (agent, tenant) => {
+ const resolver = jest.fn(() => makeJwtWithExp(300));
+ await expect(cache.RefreshObservabilityToken(agent, tenant, resolver)).rejects.toThrow('Agent and tenant IDs');
+ expect(resolver).not.toHaveBeenCalled();
});
- it('treats near-expiry token as expired (skew refresh)', async () => {
- const auth = makeAuthorizationMock([{ token: makeJwtWithExp(30) }]);
- await AgenticTokenCacheInstance.RefreshObservabilityToken(
- 'agentD',
- 'tenantD',
- asTurnContext(makeTurnContext()),
- auth as any,
- ['scope.read']
- );
- const token = AgenticTokenCacheInstance.getObservabilityToken('agentD', 'tenantD');
- expect(token).toBeNull();
+ it('retries a transient acquisition failure then caches the app-only token', async () => {
+ const token = makeJwtWithExp(300);
+ const resolver = jest.fn()
+ .mockRejectedValueOnce({ status: 500, message: 'service unavailable' })
+ .mockResolvedValueOnce(token);
+
+ const pending = cache.RefreshObservabilityToken('agent', 'tenant', resolver);
+ await jest.advanceTimersByTimeAsync(1000);
+ await pending;
+
+ expect(resolver).toHaveBeenCalledTimes(2);
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token);
});
- it('returns cached token before expiry then invalid after advancing time', async () => {
- const auth = makeAuthorizationMock([{ token: makeJwtWithExp(120) }]);
- await AgenticTokenCacheInstance.RefreshObservabilityToken(
- 'agentE',
- 'tenantE',
- asTurnContext(makeTurnContext()),
- auth as any,
- ['scope.read']
- );
- const tokenBefore = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE');
- expect(tokenBefore).not.toBeNull();
+ it('surfaces a permanent acquisition failure and clears stale tokens', async () => {
+ await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(120));
jest.advanceTimersByTime(61_000);
- const tokenAfter = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE');
- expect(tokenAfter).toBeNull();
+ const error = new Error('permission denied');
+ const resolver = jest.fn().mockRejectedValue(error);
+
+ await expect(cache.RefreshObservabilityToken('agent', 'tenant', resolver)).rejects.toBe(error);
+
+ expect(resolver).toHaveBeenCalledTimes(1);
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull();
});
- it('evicts oldest entry when cache exceeds max size', async () => {
- const { AgenticTokenCache } = require('@microsoft/agents-a365-observability-hosting');
- const cache = new AgenticTokenCache();
- const map = (cache as any)._map as Map;
+ it('surfaces exhausted transient failures without caching a token', async () => {
+ const error = { status: 503, message: 'service unavailable' };
+ const resolver = jest.fn().mockRejectedValue(error);
+ const pending = expect(cache.RefreshObservabilityToken('agent', 'tenant', resolver)).rejects.toBe(error);
+ await jest.advanceTimersByTimeAsync(1000);
+ await pending;
+ expect(resolver).toHaveBeenCalledTimes(3);
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull();
+ });
- // Pre-fill the map to capacity
- const MAX = (cache as any)._maxCacheSize as number;
- for (let i = 0; i < MAX; i++) {
- map.set(`agent-${i}:tenant-${i}`, { scopes: ['s'], token: `t-${i}`, acquiredOn: Date.now() });
- }
- expect(map.size).toBe(MAX);
+ it.each([null, '', ' '])('surfaces an empty resolver result (%s)', async (token) => {
+ await expect(cache.RefreshObservabilityToken('agent', 'tenant', () => token)).rejects.toThrow('returned no token');
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull();
+ });
- // Insert one more via RefreshObservabilityToken
- const token = makeJwtWithExp(300);
- const auth = makeAuthorizationMock([{ token }]);
- await cache.RefreshObservabilityToken(
- 'agent-new',
- 'tenant-new',
- asTurnContext(makeTurnContext()),
- auth as any,
- ['scope.read']
- );
-
- // Size should still be at MAX (oldest evicted, new one added)
- expect(map.size).toBe(MAX);
- // First entry should have been evicted
- expect(map.has('agent-0:tenant-0')).toBe(false);
- // New entry should exist
- expect(map.has('agent-new:tenant-new')).toBe(true);
+ it.each([-30, 0, 30])('does not return a token expiring in %s seconds', async (seconds) => {
+ await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(seconds));
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull();
});
- it('passes authHandlerName to exchangeToken when provided', async () => {
- const token = makeJwtWithExp(300);
- const exchangeFn = jest.fn(async (..._args: any[]) => ({ token }));
- const auth: AuthorizationStub = {
- exchangeToken: exchangeFn,
- getToken: async () => ({ token: 'unused' }),
- signOut: async () => {},
- onSignInSuccess: () => {},
- onSignInFailure: () => {}
- };
- await AgenticTokenCacheInstance.RefreshObservabilityToken(
- 'agentHandler',
- 'tenantHandler',
- asTurnContext(makeTurnContext()),
- auth as any,
- ['scope.read'],
- 'custom-handler'
- );
- expect(exchangeFn).toHaveBeenCalledTimes(1);
- expect(exchangeFn.mock.calls[0][1]).toBe('custom-handler');
+ it('reuses a cached token and refreshes after expiry skew', async () => {
+ const token = makeJwtWithExp(120);
+ const resolver = jest.fn(() => token);
+ await cache.RefreshObservabilityToken('agent', 'tenant', resolver);
+ await cache.RefreshObservabilityToken('agent', 'tenant', resolver);
+ expect(resolver).toHaveBeenCalledTimes(1);
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token);
+ jest.advanceTimersByTime(61_000);
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull();
+ resolver.mockReturnValue(makeJwtWithExp(300));
+ await cache.RefreshObservabilityToken('agent', 'tenant', resolver);
+ expect(resolver).toHaveBeenCalledTimes(2);
+ });
+
+ it('isolates tokens by agent and tenant', async () => {
+ const resolver = (agent: string, tenant: string) => `${agent}-${tenant}`;
+ await cache.RefreshObservabilityToken('one', 'tenant-a', resolver);
+ await cache.RefreshObservabilityToken('two', 'tenant-a', resolver);
+ await cache.RefreshObservabilityToken('one', 'tenant-b', resolver);
+ expect(cache.getObservabilityToken('one', 'tenant-a')).toBe('one-tenant-a');
+ expect(cache.getObservabilityToken('two', 'tenant-a')).toBe('two-tenant-a');
+ expect(cache.getObservabilityToken('one', 'tenant-b')).toBe('one-tenant-b');
+ });
+
+ it('uses a fresh fallback TTL when an opaque token replaces an expired JWT', async () => {
+ await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(120));
+ jest.advanceTimersByTime(61_000);
+ await cache.RefreshObservabilityToken('agent', 'tenant', () => 'opaque-app-only-token');
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBe('opaque-app-only-token');
+ jest.advanceTimersByTime(3_600_000);
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull();
});
- it('defaults authHandlerName to "agentic" when not provided', async () => {
+ it('evicts the oldest token when the cache reaches capacity', async () => {
const token = makeJwtWithExp(300);
- const exchangeFn = jest.fn(async (..._args: any[]) => ({ token }));
- const auth: AuthorizationStub = {
- exchangeToken: exchangeFn,
- getToken: async () => ({ token: 'unused' }),
- signOut: async () => {},
- onSignInSuccess: () => {},
- onSignInFailure: () => {}
- };
- await AgenticTokenCacheInstance.RefreshObservabilityToken(
- 'agentDefault',
- 'tenantDefault',
- asTurnContext(makeTurnContext()),
- auth as any,
- ['scope.read']
- );
- expect(exchangeFn).toHaveBeenCalledTimes(1);
- expect(exchangeFn.mock.calls[0][1]).toBe('agentic');
+ const capacity = cache['_maxCacheSize'];
+ const resolver = () => token;
+ for (let i = 0; i <= capacity; i++) {
+ await cache.RefreshObservabilityToken(`agent-${i}`, 'tenant', resolver);
+ }
+ expect(cache.getObservabilityToken('agent-0', 'tenant')).toBeNull();
+ expect(cache.getObservabilityToken('agent-1', 'tenant')).toBe(token);
+ expect(cache.getObservabilityToken(`agent-${capacity}`, 'tenant')).toBe(token);
});
- it('caps JWT exp claim to 24 hours', async () => {
- const { AgenticTokenCache } = require('@microsoft/agents-a365-observability-hosting');
- const cache = new AgenticTokenCache();
-
- // Create JWT with exp 48 hours from now
- const farFutureExp = Math.floor(Date.now() / 1000) + (48 * 60 * 60);
- const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
- const payload = Buffer.from(JSON.stringify({ exp: farFutureExp })).toString('base64url');
- const farFutureToken = `${header}.${payload}.sig`;
-
- const auth = makeAuthorizationMock([{ token: farFutureToken }]);
- await cache.RefreshObservabilityToken(
- 'agent-exp',
- 'tenant-exp',
- asTurnContext(makeTurnContext()),
- auth as any,
- ['scope.read']
- );
-
- const map = (cache as any)._map as Map;
- const entry = map.get('agent-exp:tenant-exp');
- expect(entry).toBeDefined();
- expect(entry.expiresOn).toBeDefined();
-
- // The expiresOn should be capped to ~24 hours from now (not 48 hours)
- const maxAllowed = Date.now() + (24 * 60 * 60 * 1000) + 5000; // 24h + small tolerance
- expect(entry.expiresOn).toBeLessThanOrEqual(maxAllowed);
- // And should be well below the 48-hour uncapped value
- const uncapped = farFutureExp * 1000;
- expect(entry.expiresOn).toBeLessThan(uncapped);
+ it('caps token lifetime to 24 hours', async () => {
+ const token = makeJwtWithExp(48 * 60 * 60);
+ await cache.RefreshObservabilityToken('agent', 'tenant', () => token);
+ jest.advanceTimersByTime(24 * 60 * 60 * 1000);
+ expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull();
+ });
+
+ it('invalidates one token independently, then all tokens', async () => {
+ const token = makeJwtWithExp(300);
+ await cache.RefreshObservabilityToken('one', 'tenant', () => token);
+ await cache.RefreshObservabilityToken('two', 'tenant', () => token);
+ cache.invalidateToken('one', 'tenant');
+ expect(cache.getObservabilityToken('one', 'tenant')).toBeNull();
+ expect(cache.getObservabilityToken('two', 'tenant')).toBe(token);
+ cache.invalidateAll();
+ expect(cache.getObservabilityToken('two', 'tenant')).toBeNull();
});
});
diff --git a/tests/tooling/package-dependencies.test.ts b/tests/tooling/package-dependencies.test.ts
new file mode 100644
index 00000000..3ceb8873
--- /dev/null
+++ b/tests/tooling/package-dependencies.test.ts
@@ -0,0 +1,15 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+describe('tooling runtime dependencies', () => {
+ it('declares axios directly rather than relying on workspace hoisting', () => {
+ const manifest = JSON.parse(readFileSync(
+ join(__dirname, '../../packages/agents-a365-tooling/package.json'), 'utf8',
+ ));
+
+ expect(manifest.dependencies).toHaveProperty('axios', 'catalog:');
+ });
+});
diff --git a/version.json b/version.json
index 2a0ab815..0294a76c 100644
--- a/version.json
+++ b/version.json
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
- "version": "1.1.0-preview.{height}",
+ "version": "2.0.0-preview.{height}",
"publicReleaseRefSpec": [
"^refs/heads/main$",
"^refs/heads/master$",