Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Licensed under the MIT License.
// ------------------------------------------------------------------------------

import { ExportResult,ExportResultCode } from '@opentelemetry/core';
import { ExportResult, ExportResultCode } from '@opentelemetry/core';
import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base';

import { PowerPlatformApiDiscovery, ClusterCategory } from '@microsoft/agents-a365-runtime';
Expand Down Expand Up @@ -65,12 +65,12 @@ interface OTLPStatus {
message?: string;
}


/**
* Observability span exporter for Agent365:
* - Partitions spans by (tenantId, agentId)
* - Builds OTLP-like JSON: resourceSpans -> scopeSpans -> spans
* - POSTs per group to https://{endpoint}/maven/agent365/agents/{agentId}/traces?api-version=1
* or, when useS2SEndpoint is true, https://{endpoint}/maven/agent365/service/agents/{agentId}/traces?api-version=1
* - Adds Bearer token via token_resolver(agentId, tenantId)
*/
export class Agent365Exporter implements SpanExporter {
Expand Down Expand Up @@ -147,16 +147,23 @@ export class Agent365Exporter implements SpanExporter {

const usingCustomServiceEndpoint = useCustomDomainForObservability();

// Select endpoint path based on S2S flag
const endpointPath =
this.options.useS2SEndpoint
? `/maven/agent365/service/agents/${agentId}/traces`
: `/maven/agent365/agents/${agentId}/traces`;

let url: string;
if (usingCustomServiceEndpoint) {
url = resolveAgent365Endpoint(this.options.clusterCategory as ClusterCategory);
const base = resolveAgent365Endpoint(this.options.clusterCategory as ClusterCategory);
url = `${base}${endpointPath}?api-version=1`;
logger.info(`[Agent365Exporter] Using custom domain endpoint: ${url}`);
} else {
// Default behavior: discover PPAPI gateway endpoint per-tenant
const discovery = new PowerPlatformApiDiscovery(this.options.clusterCategory as ClusterCategory);
const endpoint = discovery.getTenantIslandClusterEndpoint(tenantId);
url = `https://${endpoint}/maven/agent365/agents/${agentId}/traces?api-version=1`;
logger.info(`[Agent365Exporter] Resolved endpoint: ${endpoint}`);
url = `https://${endpoint}${endpointPath}?api-version=1`;
logger.info(`[Agent365Exporter] Resolved endpoint: ${url}`);
}

const headers: Record<string, string> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null
* @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. "preprod", "prod", default to "prod").
* @property {TokenResolver} [tokenResolver] Optional delegate to obtain an auth token. If omitted the exporter will
* fall back to reading the cached token (AgenticTokenCacheInstance.getObservabilityToken).
* @property {boolean} [useS2SEndpoint] When true, exporter will POST to the S2S path (/maven/agent365/service/agents/{agentId}/traces).
* @property {number} maxQueueSize Maximum span queue size before drops occur (passed to BatchSpanProcessor).
* @property {number} scheduledDelayMilliseconds Delay between automatic batch flush attempts.
* @property {number} exporterTimeoutMilliseconds Per-export timeout (abort if exceeded).
Expand All @@ -32,6 +33,9 @@ export class Agent365ExporterOptions {
/** Optional delegate to resolve auth token used by exporter */
public tokenResolver?: TokenResolver; // Optional if ENABLE_A365_OBSERVABILITY_EXPORTER is false

/** When true, use S2S endpoint path for export. */
public useS2SEndpoint: boolean = false;

/** Maximum span queue size before new spans are dropped. */
public maxQueueSize: number = 2048;

Expand Down
59 changes: 58 additions & 1 deletion tests/observability/core/agent365-exporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe('Agent365Exporter', () => {
expect(fetchCalls.length).toBe(1);
const urlArg = fetchCalls[0][0];
const headersArg = fetchCalls[0][1].headers;
expect(urlArg).toBe(expectedUrl);
expect(urlArg).toBe(`${expectedUrl}/maven/agent365/agents/${agentId}/traces?api-version=1`);
expect(headersArg['x-ms-tenant-id']).toBe(tenantId);
expect(headersArg['authorization']).toBe(`Bearer ${token}`);
});
Expand Down Expand Up @@ -164,4 +164,61 @@ describe('Agent365Exporter', () => {
// Intentionally omit tokenResolver
expect(() => new Agent365Exporter(opts)).toThrow(/tokenResolver must be provided/);
});
it('uses S2S endpoint path when useS2SEndpoint is true (discovery flow)', async () => {
mockFetchSequence([200]);
const token = 'tok-s2s';
const opts = new Agent365ExporterOptions();
opts.clusterCategory = 'prod';
opts.tokenResolver = () => token;
opts.useS2SEndpoint = true;

const exporter = new Agent365Exporter(opts);
const spans = [
makeSpan({
[OpenTelemetryConstants.TENANT_ID_KEY]: tenantId,
[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId
}, 's2s-span')
];

const callback = jest.fn();
await exporter.export(spans, callback);

expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS });
const fetchCalls = (global.fetch as unknown as { mock: { calls: any[] } }).mock.calls;
expect(fetchCalls.length).toBe(1);

const urlArg = fetchCalls[0][0] as string;
expect(urlArg).toMatch(`/maven/agent365/service/agents/${agentId}/traces?api-version=1`);
const headersArg = fetchCalls[0][1].headers as Record<string, string>;
expect(headersArg['authorization']).toBe(`Bearer ${token}`);
});

it('uses S2S endpoint path with custom domain and sets x-ms-tenant-id', async () => {
mockFetchSequence([200]);
process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN = 'true';
const token = 'tok-s2s-custom';
const opts = new Agent365ExporterOptions();
opts.clusterCategory = 'prod';
opts.tokenResolver = () => token;
opts.useS2SEndpoint = true;

const exporter = new Agent365Exporter(opts);
const spans = [
makeSpan({
[OpenTelemetryConstants.TENANT_ID_KEY]: tenantId,
[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId
}, 's2s-custom-span')
];

const callback = jest.fn();
await exporter.export(spans, callback);

const fetchCalls = (global.fetch as unknown as { mock: { calls: any[] } }).mock.calls;
expect(fetchCalls.length).toBe(1);
const urlArg = fetchCalls[0][0] as string;
expect(urlArg).toMatch(`/maven/agent365/service/agents/${agentId}/traces?api-version=1`);
const headersArg = fetchCalls[0][1].headers as Record<string, string>;
expect(headersArg['authorization']).toBe(`Bearer ${token}`);
expect(headersArg['x-ms-tenant-id']).toBe(tenantId);
})
});