Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
996b5e7
use Agent365ExporterOptions for exporter and create AgenticTokenCache…
jsl517 Nov 10, 2025
9c50c72
tests
jsl517 Nov 11, 2025
51b4d86
copilot comment
jsl517 Nov 11, 2025
a0de073
configure batch span processor
jsl517 Nov 11, 2025
88605eb
copilot comment
jsl517 Nov 11, 2025
ca17b96
checkpoint to use cached context and authorization to exchange token.
jsl517 Nov 12, 2025
1b89510
only do refresh token in message handler, unit tests
jsl517 Nov 13, 2025
fa95349
sample update
jsl517 Nov 14, 2025
1c3940e
comments
jsl517 Nov 14, 2025
36c5966
comments
jsl517 Nov 14, 2025
3d174b3
observability always use prod
jsl517 Nov 14, 2025
9ca9f6a
Merge branch 'main' into users/pefan/exportoption
jsl517 Nov 14, 2025
7271de5
should be lowercase
jsl517 Nov 14, 2025
ea0cfc6
Revert "observability always use prod"
jsl517 Nov 15, 2025
5221582
Merge branch 'main' into users/pefan/exportoption
jsl517 Nov 15, 2025
64728ca
default to prod
jsl517 Nov 15, 2025
f1d9e61
expose Agent365ExporterOptions
jsl517 Nov 18, 2025
e07318b
expose Agent365ExporterOptions
jsl517 Nov 18, 2025
057aae7
move azure token cache to its own package
jsl517 Nov 19, 2025
20133be
readme update
jsl517 Nov 19, 2025
d322096
Merge branch 'main' into users/pefan/exportoption
jsl517 Nov 19, 2025
6058c84
fix package.json error
jsl517 Nov 19, 2025
1149e09
fix logging
jsl517 Nov 19, 2025
5e3d93b
lint,jest config
jsl517 Nov 19, 2025
55b5536
cleanup
jsl517 Nov 19, 2025
cf31254
comment
jsl517 Nov 19, 2025
142ec28
rename
jsl517 Nov 19, 2025
54ddd3e
lint
jsl517 Nov 19, 2025
5303929
comment
jsl517 Nov 19, 2025
0b11ffa
Merge branch 'main' into users/pefan/exportoption
fpfp100 Nov 19, 2025
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
29 changes: 17 additions & 12 deletions packages/agents-a365-observability/src/ObservabilityBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ConsoleSpanExporter, BatchSpanProcessor } from '@opentelemetry/sdk-trac
import { SpanProcessor } from './tracing/processors/SpanProcessor';
import { isAgent365ExporterEnabled } from './tracing/util';
import { Agent365Exporter, TokenResolver } from './tracing/exporter/Agent365Exporter';
import { Agent365ExporterOptions } from './tracing/exporter/Agent365ExporterOptions';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
import { trace } from '@opentelemetry/api';
Expand Down Expand Up @@ -67,20 +68,24 @@ export class ObservabilityBuilder {
return this;
}

private getTraceExporter() {
if (isAgent365ExporterEnabled()){
if (!this.options.tokenResolver) {
throw new Error('tokenResolver must be provided when Agent365 exporter is enabled');
}
return new Agent365Exporter(
this.options.tokenResolver,
this.options.clusterCategory || 'prod'
);
} else {
return new ConsoleSpanExporter();
private createBatchProcessor(): BatchSpanProcessor {
if (!isAgent365ExporterEnabled()) {
return new BatchSpanProcessor(new ConsoleSpanExporter());
}
const opts = new Agent365ExporterOptions();
Comment thread
fpfp100 marked this conversation as resolved.
opts.clusterCategory = this.options.clusterCategory || 'prod';
if (this.options.tokenResolver) {
opts.tokenResolver = this.options.tokenResolver;
}
return new BatchSpanProcessor(new Agent365Exporter(opts), {
maxQueueSize: opts.maxQueueSize,
scheduledDelayMillis: opts.scheduledDelayMilliseconds,
exportTimeoutMillis: opts.exporterTimeoutMilliseconds,
maxExportBatchSize: opts.maxExportBatchSize
});
}


Comment thread
fpfp100 marked this conversation as resolved.
Outdated
private createResource() {
const serviceName = this.options.serviceVersion
? `${this.options.serviceName}-${this.options.serviceVersion}`
Expand All @@ -104,7 +109,7 @@ export class ObservabilityBuilder {
const spanProcessor = new SpanProcessor();

// 2. batch processor that actually ships spans out
const batchProcessor = new BatchSpanProcessor(this.getTraceExporter());
const batchProcessor = this.createBatchProcessor();

const globalProvider: any = trace.getTracerProvider();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base';

import { PowerPlatformApiDiscovery, ClusterCategory } from '@microsoft/agents-a365-runtime';
import { partitionByIdentity, parseIdentityKey, hexTraceId, hexSpanId, kindName, statusName } from './utils';
import logger, {formatError} from '../../utils/logging';
import logger, { formatError } from '../../utils/logging';
import { AgenticTokenCacheInstance } from '../../utils/AgenticTokenCache';
import { Agent365ExporterOptions } from './Agent365ExporterOptions';

const DEFAULT_HTTP_TIMEOUT_SECONDS = 30000; // 30 seconds in ms
const DEFAULT_MAX_RETRIES = 3;
Expand All @@ -14,7 +16,7 @@ interface OTLPExportRequest {

interface ResourceSpan {
resource: {
attributes: Record<string, any> | null;
attributes: Record<string, unknown> | null;
};
scopeSpans: ScopeSpan[];
}
Expand All @@ -35,7 +37,7 @@ interface OTLPSpan {
kind: string;
startTimeUnixNano: number;
endTimeUnixNano: number;
attributes: Record<string, any> | null;
attributes: Record<string, unknown> | null;
events?: OTLPEvent[] | null;
links?: OTLPLink[] | null;
status: OTLPStatus;
Expand All @@ -44,13 +46,13 @@ interface OTLPSpan {
interface OTLPEvent {
timeUnixNano: number;
name: string;
attributes?: Record<string, any> | null;
attributes?: Record<string, unknown> | null;
}

interface OTLPLink {
traceId: string;
spanId: string;
attributes?: Record<string, any> | null;
attributes?: Record<string, unknown> | null;
}

interface OTLPStatus {
Expand All @@ -71,20 +73,32 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null
* - Adds Bearer token via token_resolver(agentId, tenantId)
*/
export class Agent365Exporter implements SpanExporter {
private readonly tokenResolver: TokenResolver;
private readonly clusterCategory: ClusterCategory;
private closed = false;
private readonly options: Agent365ExporterOptions;
Comment thread
fpfp100 marked this conversation as resolved.

constructor(
tokenResolver: TokenResolver,
clusterCategory: ClusterCategory = 'prod'
) {
if (!tokenResolver) {
logger.error('[Agent365Exporter] token_resolver is not provided');
throw new Error('token_resolver must be provided.');
/**
* Initialize exporter with a fully constructed options instance.
* If tokenResolver is missing, installs cache-backed resolver.
*/
constructor(options: Agent365ExporterOptions) {

if (!options.tokenResolver) {
options.tokenResolver = (agentId: string, tenantId: string): string | null => {
const key = AgenticTokenCacheInstance.createCacheKey(agentId, tenantId);
const cached = AgenticTokenCacheInstance.get(key);
if (!cached) {
logger.warn('Token cache miss', { agentId, tenantId });
} else {
logger.info('Token cache hit', { agentId, tenantId });
}
return cached;
};
logger.info('Agent365Exporter initialized with cache-backed tokenResolver', `clusterCategory=${options.clusterCategory}`);
} else {
logger.info('Agent365Exporter initialized with custom tokenResolver', `clusterCategory=${options.clusterCategory}`);
}
this.tokenResolver = tokenResolver;
this.clusterCategory = clusterCategory;

this.options = options;
}

/**
Expand Down Expand Up @@ -140,7 +154,7 @@ export class Agent365Exporter implements SpanExporter {
const body = JSON.stringify(payload);

// Resolve endpoint + token
const discovery = new PowerPlatformApiDiscovery(this.clusterCategory);
const discovery = new PowerPlatformApiDiscovery(this.options.clusterCategory as ClusterCategory);
const endpoint = discovery.getTenantIslandClusterEndpoint(tenantId);
const url = `https://${endpoint}/maven/agent365/agents/${agentId}/traces?api-version=1`;
logger.info(`[Agent365Exporter] Resolved endpoint: ${endpoint}`);
Expand All @@ -149,7 +163,7 @@ export class Agent365Exporter implements SpanExporter {
'content-type': 'application/json'
};

const tokenResult = this.tokenResolver(agentId, tenantId);
const tokenResult = this.options.tokenResolver!(agentId, tenantId);
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
const token = tokenResult instanceof Promise ? await tokenResult : tokenResult;
if (token) {
headers['authorization'] = `Bearer ${token}`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// ------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// ------------------------------------------------------------------------------

import { ClusterCategory } from '@microsoft/agents-a365-runtime';
import { TokenResolver } from './Agent365Exporter';

/**
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
* Configuration for Agent365Exporter.
* Only ClusterCategory and TokenResolver are required for core operation.
*/
export class Agent365ExporterOptions {
/**
* Environment / cluster category
*/
public clusterCategory: ClusterCategory | string = 'preprod';

/**
* Resolver used to resolve the auth token. Optional - falls back to AgenticTokenCache when not provided.
*/
public tokenResolver?: TokenResolver;

/**
* When true, uses the service-to-service (S2S) endpoint path: /maven/agent365/service/agents/{agentId}/traces
* When false (default), uses the standard endpoint path: /maven/agent365/agents/{agentId}/traces
*/
public useS2SEndpoint: boolean = false;
Comment thread
fpfp100 marked this conversation as resolved.
Outdated

/**
* Maximum queue size for the batch processor.
*/
public maxQueueSize: number = 2048;

/**
* Delay in milliseconds between export batches.
*/
public scheduledDelayMilliseconds: number = 5000;

/**
* Timeout in milliseconds for the export operation.
*/
public exporterTimeoutMilliseconds: number = 30000;

/**
* Maximum batch size for export operations.
*/
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
public maxExportBatchSize: number = 512;
}
68 changes: 68 additions & 0 deletions packages/agents-a365-observability/src/utils/AgenticTokenCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// ------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------------------------
Comment thread
fpfp100 marked this conversation as resolved.
Outdated

/**
* Options for configuring the AgenticTokenCache.
*/
export interface AgenticTokenCacheOptions {
/** Default time-to-live in milliseconds applied when set() is called without an explicit ttl. */
defaultTtlMs?: number;
}

interface CacheEntry {
token: string;
/** Expiration timestamp in epoch milliseconds. */
expiresAt: number;
/** Creation timestamp. */
createdAt: number;
}

/**
* Lightweight in-memory TTL cache for agent tokens.
* Minimal parity with C# version: set/get with expiration and cache key helper.
*/
export class AgenticTokenCache {
private readonly options: Required<AgenticTokenCacheOptions>;
private readonly store: Map<string, CacheEntry> = new Map();

constructor(options?: AgenticTokenCacheOptions) {
this.options = {
defaultTtlMs: options?.defaultTtlMs ?? 50 * 60 * 1000
};
}

/** Create a cache key from agent/tenant identifiers. */
createCacheKey(agentId: string, tenantId?: string): string {
return tenantId ? `${agentId}:${tenantId}` : agentId;
}

/** Set a token value with optional TTL override. */
set(key: string, token: string, ttlMs?: number): void {
const now = Date.now();
const ttl = ttlMs ?? this.options.defaultTtlMs;
const expiresAt = now + Math.max(0, ttl);

this.store.set(key, { token, expiresAt, createdAt: now });
}

/** Retrieve a token if present and not expired; otherwise returns null. */
get(key: string): string | null {
const entry = this.store.get(key);
if (!entry) {
return null;
}
if (entry.expiresAt <= Date.now()) {
this.store.delete(key);
return null;
}
return entry.token;
}

/** Clear all cache entries (primarily for test isolation). */
clear(): void {
this.store.clear();
}
}

export const AgenticTokenCacheInstance = new AgenticTokenCache();
33 changes: 29 additions & 4 deletions tests-agent/basic-agent-sdk-sample/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ a365Observability.start();

// Mock authentication middleware for development
// This is only required when running from agents playground
try {
app.use((req: Request, res: Response, next: NextFunction) => {
// Create a mock identity when JWT is disabled
req.user = {
Expand All @@ -27,12 +28,36 @@ app.use((req: Request, res: Response, next: NextFunction) => {
}
next()
})
} catch (err) {
console.warn('Skipping mock authentication middleware:', err);
}

app.post('/api/messages', async (req: Request, res: Response) => {
await adapter.process(req, res, async (context) => {
const app = agentApplication;
await app.run(context);
});
try {
await adapter.process(req, res, async (context) => {
const app = agentApplication;
await app.run(context);
});
} catch (err) {
// Enhanced diagnostic logging for token acquisition / adapter failures
const anyErr = err as any;
const status = anyErr?.status || anyErr?.response?.status;
const data = anyErr?.response?.data;
const message = anyErr?.message || 'Unknown error';
// Axios style nested config
const aadError = data?.error || data?.error_description || data;
console.error('[diagnostic] adapter.process failed', {
message,
status,
aadError,
url: anyErr?.config?.url,
scope: anyErr?.config?.data,
Comment thread
fpfp100 marked this conversation as resolved.
});
// Surface minimal info to caller while keeping internals in log
if (!res.headersSent) {
res.status(500).json({ error: 'internal_error', detail: status ? `upstream status ${status}` : message });
Comment thread
fpfp100 marked this conversation as resolved.
}
}
});

const port = process.env.PORT || 3978;
Expand Down
Loading
Loading