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..b9cb1e91 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,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; + 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); + }) });