diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index 007c7633..9862bf3e 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -10,6 +10,8 @@ import { PowerPlatformApiDiscovery, ClusterCategory } from '@microsoft/agents-a3 import { partitionByIdentity, parseIdentityKey, hexTraceId, hexSpanId, kindName, statusName } from './utils'; import logger, { formatError } from '../../utils/logging'; import { Agent365ExporterOptions } from './Agent365ExporterOptions'; +import { useCustomDomainForObservability, resolveAgent365Endpoint } from '../util'; + const DEFAULT_HTTP_TIMEOUT_SECONDS = 30000; // 30 seconds in ms const DEFAULT_MAX_RETRIES = 3; @@ -143,11 +145,19 @@ export class Agent365Exporter implements SpanExporter { const payload = this.buildExportRequest(spans); const body = JSON.stringify(payload); - // Resolve endpoint + token - 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}`); + const usingCustomServiceEndpoint = useCustomDomainForObservability(); + + let url: string; + if (usingCustomServiceEndpoint) { + url = resolveAgent365Endpoint(this.options.clusterCategory as ClusterCategory); + 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}`); + } const headers: Record = { 'content-type': 'application/json' @@ -166,6 +176,10 @@ export class Agent365Exporter implements SpanExporter { logger.error('[Agent365Exporter] No token resolved'); } + // Add tenant id to headers when using custom domain + if (usingCustomServiceEndpoint) { + headers['x-ms-tenant-id'] = tenantId; + } // Basic retry loop const ok = await this.postWithRetries(url, body, headers); diff --git a/packages/agents-a365-observability/src/tracing/util.ts b/packages/agents-a365-observability/src/tracing/util.ts index 079d475d..e8568fed 100644 --- a/packages/agents-a365-observability/src/tracing/util.ts +++ b/packages/agents-a365-observability/src/tracing/util.ts @@ -1,31 +1,61 @@ -import { OpenTelemetryConstants } from './constants'; - - -/** - * Check if exporter is enabled via environment variables - */ -export const isAgent365ExporterEnabled: () => boolean = (): boolean => { - const enableA365Exporter = process.env[OpenTelemetryConstants.ENABLE_A365_OBSERVABILITY_EXPORTER]?.toLowerCase(); - - return ( - enableA365Exporter === 'true' || - enableA365Exporter === '1' || - enableA365Exporter === 'yes' || - enableA365Exporter === 'on' - ); -}; - -/** - * Gets the enable telemetry configuration value - */ -export const isAgent365TelemetryEnabled: () => boolean = (): boolean => { - const enableObservability = process.env[OpenTelemetryConstants.ENABLE_OBSERVABILITY]?.toLowerCase(); - const enableA365 = process.env[OpenTelemetryConstants.ENABLE_A365_OBSERVABILITY]?.toLowerCase(); - - return ( - enableObservability === 'true' || - enableObservability === '1' || - enableA365 === 'true' || - enableA365 === '1' - ); -}; \ No newline at end of file +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------------------------ + +import { OpenTelemetryConstants } from './constants'; +import { ClusterCategory } from '@microsoft/agents-a365-runtime'; +/** + * Check if exporter is enabled via environment variables + */ +export const isAgent365ExporterEnabled: () => boolean = (): boolean => { + const enableA365Exporter = process.env[OpenTelemetryConstants.ENABLE_A365_OBSERVABILITY_EXPORTER]?.toLowerCase(); + + return ( + enableA365Exporter === 'true' || + enableA365Exporter === '1' || + enableA365Exporter === 'yes' || + enableA365Exporter === 'on' + ); +}; + +/** + * Gets the enable telemetry configuration value + */ +export const isAgent365TelemetryEnabled: () => boolean = (): boolean => { + const enableObservability = process.env[OpenTelemetryConstants.ENABLE_OBSERVABILITY]?.toLowerCase(); + const enableA365 = process.env[OpenTelemetryConstants.ENABLE_A365_OBSERVABILITY]?.toLowerCase(); + + return ( + enableObservability === 'true' || + enableObservability === '1' || + enableA365 === 'true' || + enableA365 === '1' + ); +}; + +/** + * Single toggle to use custom domain for observability export. + * When true exporter will send traces to custom Agent365 service endpoint + * and include x-ms-tenant-id in headers. + */ +export const useCustomDomainForObservability = (): boolean => { + const value = process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN?.toLowerCase(); + return ( + value === 'true' || + value === '1' || + value === 'yes' || + value === 'on' + ); +}; + +/** + * Resolve the Agent365 service endpoint base URI for a given cluster category. + */ +export function resolveAgent365Endpoint(clusterCategory: ClusterCategory): string { + switch (clusterCategory) { + case 'prod': + default: + return 'https://agent365.svc.cloud.microsoft'; + } +} diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index 8a043666..022bc65b 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -54,6 +54,7 @@ describe('Agent365Exporter', () => { jest.clearAllTimers(); jest.useRealTimers(); global.fetch = originalFetch; + delete process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN; }); it('returns success immediately with no spans', async () => { @@ -102,6 +103,61 @@ describe('Agent365Exporter', () => { expect(exportedSpan.attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_CLIENT_IP_KEY]).toBe('1.0.0.5'); }); + it.each([ + { cluster: 'prod', expectedUrl: 'https://agent365.svc.cloud.microsoft', token: 'tok-prod' }, + { cluster: 'preprod', expectedUrl: 'https://agent365.svc.cloud.microsoft', token: 'tok-preprod' } + ])('exports to custom domain when enabled (cluster=%s)', async ({ cluster, expectedUrl, token }) => { + mockFetchSequence([200]); + process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN = 'true'; + const opts = new Agent365ExporterOptions(); + opts.clusterCategory = cluster; + opts.tokenResolver = () => token; + const exporter = new Agent365Exporter(opts); + const spans = [ + makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId + }) + ]; + 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]; + const headersArg = fetchCalls[0][1].headers; + expect(urlArg).toBe(expectedUrl); + expect(headersArg['x-ms-tenant-id']).toBe(tenantId); + expect(headersArg['authorization']).toBe(`Bearer ${token}`); + }); + + it('exports to discovery endpoint when custom domain disabled', async () => { + mockFetchSequence([200]); + delete process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN; + const token = 'tok-prod-disabled'; + const opts = new Agent365ExporterOptions(); + opts.clusterCategory = 'prod'; + opts.tokenResolver = () => token; + const exporter = new Agent365Exporter(opts); + const spans = [ + makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId + }) + ]; + 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]; + const headersArg = fetchCalls[0][1].headers; + const discoveryRegex = new RegExp(`^https://[\\w.-]+/maven/agent365/agents/${agentId}/traces\\?api-version=1$`, 'i'); + expect(urlArg).toMatch(discoveryRegex); + expect(headersArg['x-ms-tenant-id']).toBeUndefined(); + expect(headersArg['authorization']).toBe(`Bearer ${token}`); + }); + it('requires a tokenResolver and fails export when missing', async () => { const opts = new Agent365ExporterOptions(); opts.clusterCategory = 'local';