From fbee9507b2f1f252f2a02bd3ccaac64ac465a89b Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 19 Dec 2025 14:59:10 -0800 Subject: [PATCH 1/2] support S2S option for exporter --- .../src/tracing/exporter/Agent365Exporter.ts | 17 +++++++---- .../exporter/Agent365ExporterOptions.ts | 4 +++ .../core/agent365-exporter.test.ts | 30 ++++++++++++++++++- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index 9862bf3e..2b49ccb3 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -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'; @@ -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 { @@ -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 = { diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index 547b4e47..749fa396 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -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). @@ -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; diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index 022bc65b..fe43ac7d 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -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}`); }); @@ -164,4 +164,32 @@ 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; + expect(headersArg['authorization']).toBe(`Bearer ${token}`); +}); }); From a700d4cf18a1e22c3ed7ee5d5d0c36f47a5e9859 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Fri, 19 Dec 2025 15:14:09 -0800 Subject: [PATCH 2/2] more test --- .../core/agent365-exporter.test.ts | 83 +++++++++++++------ 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index fe43ac7d..b9cb1e91 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -165,31 +165,60 @@ describe('Agent365Exporter', () => { 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; - expect(headersArg['authorization']).toBe(`Bearer ${token}`); -}); + 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; + 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; + expect(headersArg['authorization']).toBe(`Bearer ${token}`); + expect(headersArg['x-ms-tenant-id']).toBe(tenantId); + }) });