Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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 @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const headers: Record<string, string> = {};

// 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
*/
Expand Down
28 changes: 28 additions & 0 deletions tests/observability/core/agent365-exporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }).headers;
expect(headersArg['x-ms-channel-id']).toBe('chat');
expect(headersArg['x-ms-subchannel-id']).toBe('thread-123');
});
});