Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability.

### Breaking Changes (`@microsoft/agents-a365-observability`)

- **OBS exports always use `/observabilityService`** - The `useS2SEndpoint` option is
deprecated and ignored, even when `false`. Batch and per-request exports no longer
select or fall back to `/observability`. Provide an app-only OBS token independently
of your agent's workload auth; the S2S service rejects delegated `scp` tokens.
- **Per-request OBS requires the configured app-only resolver** - Both export modes
use `withTokenResolver(...)` or `exporterOptions.tokenResolver`, with the builder
method taking precedence. `Agent365Exporter` no longer reads tokens from
`runWithExportToken`/`updateExportToken`. Missing resolvers fail configuration;
empty tokens or acquisition failures fail export without delegated fallback.
The exporter invokes the resolver on every export batch, so resolvers must
cache the acquired token and refresh only near expiry.
Workload OBO and custom-exporter context helpers are otherwise unchanged.
- **Hosting OBS token cache requires an app-only resolver** -
`RefreshObservabilityToken(agentId, tenantId, tokenResolver)` replaces the
`TurnContext`/`Authorization` overload, which now throws without exchanging a
user token. Token acquisition failures propagate instead of appearing successful.
- **`InvokeAgentDetails` renamed to `InvokeAgentScopeDetails`** — Now contains only scope-level config (`endpoint`). Agent identity (`AgentDetails`) is a separate parameter. `sessionId` moved to `Request`.
- **`InvokeAgentScope.start()` — new signature.** `start(request, invokeScopeDetails, agentDetails, callerDetails?, spanDetails?)`. Tenant ID is derived from `agentDetails.tenantId` (required). `userDetails` and `callerAgentDetails` are wrapped in `CallerDetails`. Span options grouped in `SpanDetails`.
- **`InferenceScope.start()` — new signature.** `start(request, details, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required).
Expand Down
51 changes: 46 additions & 5 deletions packages/agents-a365-observability-hosting/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,18 +107,59 @@ const agentPairs = getTargetAgentBaggagePairs(turnContext);

### AgenticTokenCacheInstance ([AgenticTokenCache.ts](../src/caching/AgenticTokenCache.ts))

Token caching for improved performance:
Cache app-only OBS tokens independently of the workload's AI Teammate or OBO
authorization. The former overload accepting `TurnContext` and `Authorization`
now throws rather than acquiring a delegated token incompatible with S2S.

```typescript
import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-hosting';

// Cache token with key
AgenticTokenCacheInstance.set('cache-key', 'token-value', ttlMs);
// acquireAppOnlyObsToken is your app-only token acquisition callback.
// It receives (agentId, tenantId, scopes) and returns the final OBS access token.
await AgenticTokenCacheInstance.RefreshObservabilityToken(
agentId, tenantId, acquireAppOnlyObsToken
);

// Retrieve cached token
const token = AgenticTokenCacheInstance.get('cache-key');
const token = AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId);
```

For a blueprint-backed agent, acquire a blueprint exchange assertion with
`fmi_path=agentId`, then use it as `client_assertion` in an instance
`client_credentials` request for the OBS `/.default` scope. Do not send the
intermediate assertion, a blueprint token, or a `user_fic`/OBO token to OBS.
The final token's application identity must match `agentId`, its tenant must
match `tenantId`, and its audience must be OBS. An eligible Agent 365-registered
instance can use a roleless app token when service policy permits; an
`Agent365.Observability.OtelWrite` grant is not a universal prerequisite. Entra
identity creation alone does not establish instance registration or service access.
The resolver must validate app-only identity (explicit `idtyp=app` for a roleless
token), reject delegated `scp` tokens, and check audience and lifetime before
returning a token. The cache does not perform token authentication or authorization.
Acquisition failures propagate to the caller and never trigger delegated authentication.

When migrating, replace only the OBS refresh call, not workload MCP/Graph/OBO
authorization. Configure an OBS resolver in both batch and per-request modes.
It should refresh the app-only cache at export time before returning its token,
so long-running requests do not depend on a token acquired at turn start:

```typescript
builder.withTokenResolver(async (agentId, tenantId) => {
await AgenticTokenCacheInstance.RefreshObservabilityToken(
agentId, tenantId, acquireAppOnlyObsToken
);
return AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId);
});
```

`Agent365Exporter` ignores tokens in `runWithExportToken`; those context helpers
remain available for custom exporters, not as an OBS authentication fallback.
An enabled exporter without an explicit resolver fails configuration.
Both modes use the S2S OTLP
route even if the deprecated `useS2SEndpoint` option is false. Missing tokens and
failed acquisition report export failure without sending a request; HTTP
401/403/404 never select an OBO fallback. Check instance registration and service
policy rather than adding OBS permissions automatically.

## Tenant ID Resolution

The package extracts tenant ID from multiple sources:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,19 @@
// 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 type { TurnContext, Authorization } from '@microsoft/agents-hosting';
import {
logger, formatError, defaultObservabilityConfigurationProvider,
type ObservabilityConfiguration, type TokenResolver,
} from '@microsoft/agents-a365-observability';
import type { IConfigurationProvider } from '@microsoft/agents-a365-runtime';

/** Acquires an app-only OBS token; must not perform user_fic or OBO authentication. */
export type ObservabilityTokenResolver = (
agentId: string,
tenantId: string,
scopes: readonly string[]
) => ReturnType<TokenResolver>;

interface CacheEntry {
scopes: string[];
Expand Down Expand Up @@ -64,28 +74,48 @@ export class AgenticTokenCache {
return entry.token;
}

/**
* Refreshes an app-only OBS token independently of the current user's authorization.
* The resolver receives the configured OBS scopes and must acquire a token for
* the exporting agent identity, not its blueprint or the workload's user.
*/
public async RefreshObservabilityToken(
agentId: string,
tenantId: string,
tokenResolver: ObservabilityTokenResolver
): Promise<void>;

/** @deprecated User token exchange cannot authenticate S2S OBS. Pass an app-only token resolver instead. */
public async RefreshObservabilityToken(
agentId: string,
tenantId: string,
turnContext: TurnContext,
authorization: Authorization,
scopes: string[],
authHandlerName: string = 'agentic'
authHandlerName?: string
): Promise<void>;

public async RefreshObservabilityToken(
agentId: string,
tenantId: string,
resolverOrContext: ObservabilityTokenResolver | TurnContext,
_authorization?: Authorization,
_scopes?: string[],
_authHandlerName?: string
): Promise<void> {
const key = AgenticTokenCache.makeKey(agentId, tenantId);
if (!authorization) {
throw new Error('[AgenticTokenCache] Authorization not set');
if (typeof resolverOrContext !== 'function') {
throw new Error('[AgenticTokenCache] S2S OBS requires an app-only token resolver. Use RefreshObservabilityToken(agentId, tenantId, tokenResolver); delegated user token exchange is no longer supported.');
}
if (!turnContext) {
throw new Error('[AgenticTokenCache] TurnContext not set');
if (!agentId?.trim() || !tenantId?.trim()) {
throw new Error('[AgenticTokenCache] Agent and tenant IDs are required');
}
const key = AgenticTokenCache.makeKey(agentId, tenantId);
return this.withKeyLock<void>(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) {
Expand All @@ -97,8 +127,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)) {
Expand All @@ -107,21 +136,19 @@ export class AgenticTokenCache {

const maxRetries = 2;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
logger.info(`[AgenticTokenCache] Exchanging token attempt ${attempt + 1}/${maxRetries + 1}`);
logger.info(`[AgenticTokenCache] Acquiring app-only token attempt ${attempt + 1}/${maxRetries + 1}`);
try {
const tokenResponse = await authorization.exchangeToken(turnContext, authHandlerName, { scopes: entry.scopes });
if (!tokenResponse?.token) {
logger.error('[AgenticTokenCache] Undefined token returned');
entry.token = undefined;
entry.expiresOn = undefined;
break;
const token = await resolverOrContext(agentId, tenantId, [...entry.scopes]);
if (!token?.trim()) {
throw new Error('[AgenticTokenCache] App-only token resolver returned no token');
}
entry.token = tokenResponse.token;
entry.token = token;
entry.acquiredOn = Date.now();
const oboExp = this.decodeExp(entry.token);
if (oboExp) {
entry.expiresOn = oboExp * 1000;
const exp = this.decodeExp(token);
if (exp) {
entry.expiresOn = exp * 1000;
} else {
entry.expiresOn = undefined;
logger.warn('[AgenticTokenCache] No exp claim, fallback TTL');
}
logger.info('[AgenticTokenCache] Token cached');
Expand All @@ -136,7 +163,8 @@ export class AgenticTokenCache {
logger.error('[AgenticTokenCache] Non-retriable failure', formatError(e));
entry.token = undefined;
entry.expiresOn = undefined;
break;
entry.acquiredOn = undefined;
throw e;
}
}
});
Expand Down
1 change: 1 addition & 0 deletions packages/agents-a365-observability-hosting/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
59 changes: 59 additions & 0 deletions packages/agents-a365-observability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,65 @@ 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)`. The old
`TurnContext`/`Authorization` overload throws rather than acquiring a delegated OBS token.
S2S ingestion may remove unverified user attribution; routing a workload through S2S
does not establish that its caller identity is trusted.

### Migrating per-request authentication

Batch and per-request exports both call the configured `tokenResolver` with the
exporting agent and tenant IDs. Configure it with `withTokenResolver(...)` or
`exporterOptions.tokenResolver`; the explicit builder method takes precedence.
Comment on lines +42 to +44
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:
Expand Down
8 changes: 7 additions & 1 deletion packages/agents-a365-observability/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
});

Expand All @@ -66,6 +66,12 @@ const instance = ObservabilityManager.getInstance();
await ObservabilityManager.shutdown();
```

The configured app-only OBS resolver is required in both batch and per-request
modes. The builder merges resolver options consistently, with `withTokenResolver`
taking precedence over `exporterOptions.tokenResolver`. Request context is retained
for tracing, but its token is not consumed by `Agent365Exporter`. See the
[per-request migration guide](../README.md#migrating-per-request-authentication).
Comment on lines +69 to +73

### ObservabilityBuilder ([ObservabilityBuilder.ts](../src/ObservabilityBuilder.ts))

Fluent API for configuring telemetry:
Expand Down
26 changes: 12 additions & 14 deletions packages/agents-a365-observability/src/ObservabilityBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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));
}

Expand Down
1 change: 1 addition & 0 deletions packages/agents-a365-observability/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading