diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index 9862bf3e..33aabbe1 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -7,7 +7,7 @@ import { ExportResult,ExportResultCode } from '@opentelemetry/core'; import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; import { PowerPlatformApiDiscovery, ClusterCategory } from '@microsoft/agents-a365-runtime'; -import { partitionByIdentity, parseIdentityKey, hexTraceId, hexSpanId, kindName, statusName } from './utils'; +import { buildAdditionalHttpRequestHeadersFromSpans, partitionByIdentity, parseIdentityKey, hexTraceId, hexSpanId, kindName, statusName } from './utils'; import logger, { formatError } from '../../utils/logging'; import { Agent365ExporterOptions } from './Agent365ExporterOptions'; import { useCustomDomainForObservability, resolveAgent365Endpoint } from '../util'; @@ -163,6 +163,7 @@ export class Agent365Exporter implements SpanExporter { 'content-type': 'application/json' }; + Object.assign(headers, buildAdditionalHttpRequestHeadersFromSpans(spans)); if (!this.options.tokenResolver) { logger.error('[Agent365Exporter] tokenResolver is undefined, skip exporting'); return; diff --git a/packages/agents-a365-observability/src/tracing/exporter/utils.ts b/packages/agents-a365-observability/src/tracing/exporter/utils.ts index 1a63dfc1..011e2de6 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/utils.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/utils.ts @@ -78,6 +78,40 @@ export function statusName(code: SpanStatusCode): string { } } +/** + * Build additional HTTP request headers from span attributes. + * + * Extracts channel metadata when present on any of the provided spans: + * - `x-ms-channel-id` is sourced from `GEN_AI_EXECUTION_SOURCE_NAME_KEY`. + * - `x-ms-subchannel-id` is sourced from `GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY`. + */ +export function buildAdditionalHttpRequestHeadersFromSpans( + spans: ReadableSpan[] +): Record { + const headers: Record = {}; + + // Find the first span that has channel metadata + for (const span of spans) { + const attrs = span.attributes || {}; + const channelId = asStr(attrs[OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_NAME_KEY]); + const subchannelId = asStr(attrs[OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY]); + + if (channelId) { + headers['x-ms-channel-id'] = channelId; + } + if (subchannelId) { + headers['x-ms-subchannel-id'] = subchannelId; + } + + // If both are set, we can stop early + if (headers['x-ms-channel-id'] && headers['x-ms-subchannel-id']) { + break; + } + } + + return headers; +} + /** * Partition spans by (tenantId, agentId) identity pairs */ diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index 022bc65b..9d487546 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -164,4 +164,32 @@ describe('Agent365Exporter', () => { // Intentionally omit tokenResolver expect(() => new Agent365Exporter(opts)).toThrow(/tokenResolver must be provided/); }); + + it('adds channel headers from span attributes', async () => { + mockFetchSequence([200]); + const token = 'tok-headers'; + 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, + [OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_NAME_KEY]: 'chat', + [OpenTelemetryConstants.GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY]: 'thread-123' + }) + ]; + + const callback = jest.fn(); + await exporter.export(spans, callback); + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + + const fetchCalls = (global.fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls; + expect(fetchCalls.length).toBe(1); + const headersArg = (fetchCalls[0][1] as { headers: Record }).headers; + expect(headersArg['x-ms-channel-id']).toBe('chat'); + expect(headersArg['x-ms-subchannel-id']).toBe('thread-123'); + }); });