Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -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;

Expand Down Expand Up @@ -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);
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
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<string, string> = {
'content-type': 'application/json'
Expand All @@ -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);
Expand Down
95 changes: 64 additions & 31 deletions packages/agents-a365-observability/src/tracing/util.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,64 @@
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'
);
};
// ------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// ------------------------------------------------------------------------------

import logger from '../utils/logging';
import { OpenTelemetryConstants } from './constants';
Comment thread
fpfp100 marked this conversation as resolved.
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 and clusterCategory is 'prod', exporter will send traces to agent365.svc.cloud.microsoft
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
* 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'
Comment thread
fpfp100 marked this conversation as resolved.
);
};

/**
* Resolve the Agent365 service endpoint base URI for a given cluster category.
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
*/
export function resolveAgent365Endpoint(clusterCategory: ClusterCategory): string {
switch (clusterCategory) {
case 'prod':
Comment thread
fpfp100 marked this conversation as resolved.
return 'https://agent365.svc.cloud.microsoft';
default:
logger.info(`[Agent365Exporter] Using custom prod Agent365 service endpoint for cluster category: ${clusterCategory}`);
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
return 'https://agent365.svc.cloud.microsoft';
}
Comment thread
fpfp100 marked this conversation as resolved.
}
42 changes: 42 additions & 0 deletions tests/observability/core/agent365-exporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,48 @@ describe('Agent365Exporter', () => {
expect(exportedSpan.attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_CLIENT_IP_KEY]).toBe('1.0.0.5');
});

it.each([
{ cluster: 'prod', customDomainEnabled: true, expectedCustomDomainUrl: 'https://agent365.svc.cloud.microsoft', token: 'tok-prod' },
{ cluster: 'preprod', customDomainEnabled: true, expectedCustomDomainUrl: 'https://preprod.agent365.svc.cloud.dev.microsoft', token: 'tok-preprod' },
{ cluster: 'prod', customDomainEnabled: false, expectedCustomDomainUrl: 'https://${endpoint}/maven/agent365/agents/${agentId}/traces?api-version=1', token: 'tok-prod-disabled' }
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
])('exports with custom domain flag=%s cluster=%s', async ({ cluster, customDomainEnabled, expectedCustomDomainUrl, token }) => {
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
mockFetchSequence([200]);
if (customDomainEnabled) {
process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN = 'true';
} else {
delete process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN;
}
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;
if (customDomainEnabled) {
// Exact custom domain URL expected
expect(urlArg).toBe(expectedCustomDomainUrl);
expect(headersArg['x-ms-tenant-id']).toBe(tenantId);
} else {
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
// Default discovery URL should include path with /maven/agent365/agents/{agentId}/traces
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}`);
delete process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN;
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
});
Comment thread
fpfp100 marked this conversation as resolved.

it('requires a tokenResolver and fails export when missing', async () => {
const opts = new Agent365ExporterOptions();
opts.clusterCategory = 'local';
Expand Down
Loading