diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index ee2e6752..6eb8ea4a 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -29,9 +29,7 @@ const invokeScope = InvokeAgentScope.start( requestParameters: { model: "gpt-4o", outputType: "json", - systemInstructions: [ - { type: "text", content: "You are a helpful assistant." }, - ], + systemInstructions: [{ type: "text", content: "You are a helpful assistant." }], }, }, { agentId: "agent-1", tenantId: "tenant-1", providerName: "openai" }, @@ -39,13 +37,13 @@ const invokeScope = InvokeAgentScope.start( invokeScope.run(async () => { const toolScope = ExecuteToolScope.start( - { conversationId: "conv-123" }, + { conversationId: "conv-123", sessionId: "session-456" }, { toolName: "Search", input: { query: "hello" } }, { agentId: "agent-1", tenantId: "tenant-1" }, ); const inferenceScope = InferenceScope.start( - { conversationId: "conv-123" }, + { conversationId: "conv-123", sessionId: "session-456" }, { operationName: InferenceOperationType.ChatCompletion }, { agentId: "agent-1", tenantId: "tenant-1" }, ); @@ -64,29 +62,34 @@ invokeScope.recordResponseParameters({ invokeScope.dispose(); ``` +`InvokeAgentScope`, `InferenceScope`, and `ExecuteToolScope` accept `request.sessionId`. +When you provide it, those scopes write `microsoft.session.id` directly on the created +span instead of relying on later baggage enrichment. `OutputScope` does not currently +propagate `request.sessionId` directly. + `InvokeAgentScope.start()` captures request parameters immediately, while `recordResponseParameters()` captures response and usage values after the agent completes. -| Input field | Emitted attribute key | -| --- | --- | -| `requestParameters.model` | `gen_ai.request.model` | -| `requestParameters.seed` | `gen_ai.request.seed` | -| `requestParameters.choiceCount` | `gen_ai.request.choice.count` | -| `requestParameters.frequencyPenalty` | `gen_ai.request.frequency_penalty` | -| `requestParameters.maxTokens` | `gen_ai.request.max_tokens` | -| `requestParameters.presencePenalty` | `gen_ai.request.presence_penalty` | -| `requestParameters.stopSequences` | `gen_ai.request.stop_sequences` | -| `requestParameters.temperature` | `gen_ai.request.temperature` | -| `requestParameters.topP` | `gen_ai.request.top_p` | -| `requestParameters.dataSourceId` | `gen_ai.data_source.id` | -| `requestParameters.outputType` | `gen_ai.output.type` | -| `requestParameters.systemInstructions` | `gen_ai.system_instructions` (JSON-serialized parts array) | -| `responseParameters.finishReasons` | `gen_ai.response.finish_reasons` | -| `responseParameters.inputTokens` | `gen_ai.usage.input_tokens` | -| `responseParameters.outputTokens` | `gen_ai.usage.output_tokens` | -| `responseParameters.cacheWriteInputTokens` | `gen_ai.usage.cache_write.input_tokens` | -| `responseParameters.cacheReadInputTokens` | `gen_ai.usage.cache_read.input_tokens` | -| `agentDetails.providerName` | `gen_ai.provider.name` | +| Input field | Emitted attribute key | +| ------------------------------------------ | ---------------------------------------------------------- | +| `requestParameters.model` | `gen_ai.request.model` | +| `requestParameters.seed` | `gen_ai.request.seed` | +| `requestParameters.choiceCount` | `gen_ai.request.choice.count` | +| `requestParameters.frequencyPenalty` | `gen_ai.request.frequency_penalty` | +| `requestParameters.maxTokens` | `gen_ai.request.max_tokens` | +| `requestParameters.presencePenalty` | `gen_ai.request.presence_penalty` | +| `requestParameters.stopSequences` | `gen_ai.request.stop_sequences` | +| `requestParameters.temperature` | `gen_ai.request.temperature` | +| `requestParameters.topP` | `gen_ai.request.top_p` | +| `requestParameters.dataSourceId` | `gen_ai.data_source.id` | +| `requestParameters.outputType` | `gen_ai.output.type` | +| `requestParameters.systemInstructions` | `gen_ai.system_instructions` (JSON-serialized parts array) | +| `responseParameters.finishReasons` | `gen_ai.response.finish_reasons` | +| `responseParameters.inputTokens` | `gen_ai.usage.input_tokens` | +| `responseParameters.outputTokens` | `gen_ai.usage.output_tokens` | +| `responseParameters.cacheWriteInputTokens` | `gen_ai.usage.cache_write.input_tokens` | +| `responseParameters.cacheReadInputTokens` | `gen_ai.usage.cache_read.input_tokens` | +| `agentDetails.providerName` | `gen_ai.provider.name` | System instructions may contain sensitive content. Only capture them when you intend to store prompt text and have reviewed downstream access controls. @@ -103,6 +106,11 @@ const baggageScope = new BaggageBuilder() .agentId("agent-1") .conversationId("conv-123") .sessionId("session-456") + .customAttribute("deployment.ring", "firstrelease") + .customAttributes({ + "feature.name": "grounded-chat", + "customer.segment": "internal", + }) .build(); baggageScope.run(() => { @@ -111,6 +119,29 @@ baggageScope.run(() => { }); ``` +- Baggage may cross process and service boundaries when you inject/extract context. Treat it like + inbound and outbound metadata: `_internal.custom_keys` registration metadata can arrive through + inbound baggage headers, applications must reject or sanitize untrusted baggage headers at the + edge, and you must never put secrets, access tokens, or PII in baggage keys or values. +- `customAttribute()` and `customAttributes()` trim keys and values before storing them. Blank + keys/values are dropped, keys containing commas are rejected, and the reserved + `_internal.custom_keys` metadata key cannot be set directly. +- Custom baggage enrichment is opt-in. Only keys registered through `customAttribute()` or + `customAttributes()` are copied from baggage onto spans; plain `setPairs()` entries stay in + baggage only. +- Automatic baggage-to-span enrichment only runs for recognized GenAI spans whose + `gen_ai.operation.name` is `invoke_agent`, `execute_tool`, `output_messages`, + `apply_guardrail`, `chat`, `Chat`, `TextCompletion`, or `GenerateContent`. +- For the built-in LangChain and OpenAI Agents instrumentations, enrichment also recognizes + their exact instrumentation scope names when the final GenAI operation is not available at + span start. Configured custom tracer names, scope prefixes, and unrelated child scopes are not + matched. +- Invoke-agent-only baggage keys stay invoke-agent-only even when registered through + `_internal.custom_keys`; unknown or non-`invoke_agent` GenAI spans never receive those caller + agent attributes. +- Explicit span attributes win over baggage. If a span already has a value for a registered custom + key, the span value is preserved. + ## Hosting Use `configureA365Hosting` to register the A365 middleware on an adapter. @@ -179,17 +210,17 @@ network-only delivery. It applies only to the A365 HTTP exporter, so set ### Durable Delivery Defaults -| Option | Default | Notes | -| ------------------------------------ | ------------------------- | --------------------------------------------------------------------------------- | -| `enabled` | `true` | Durable delivery stays on unless you explicitly disable it | +| Option | Default | Notes | +| ------------------------------------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `true` | Durable delivery stays on unless you explicitly disable it | | `storageDirectory` | auto | Uses the configured directory, or creates a secure platform-specific default root plus a stable per-application `app-` partition | -| `maxStorageBytes` | `50 * 1024 * 1024` | Bounds pending, quarantined, active leased, and non-stale temporary records within the current `app-` partition only | -| `maxRecordAgeMilliseconds` | `2 * 24 * 60 * 60 * 1000` | Expired records are pruned before capacity eviction, within the current `app-` partition only | -| `replayIntervalMilliseconds` | `2 * 60 * 1000` | Scheduled replay cadence | -| `maxReplayBatchSize` | `10` | Maximum records claimed per replay pass | -| `leaseDurationMilliseconds` | `2 * 60 * 1000` | Reclaims stale replay leases | -| `shutdownTimeoutMilliseconds` | `10_000` | Shared shutdown budget for accepted live exports and admitted durable handoff completion | -| `tokenResolutionTimeoutMilliseconds` | `30_000` | Timeout per replay token-resolution attempt | +| `maxStorageBytes` | `50 * 1024 * 1024` | Bounds pending, quarantined, active leased, and non-stale temporary records within the current `app-` partition only | +| `maxRecordAgeMilliseconds` | `2 * 24 * 60 * 60 * 1000` | Expired records are pruned before capacity eviction, within the current `app-` partition only | +| `replayIntervalMilliseconds` | `2 * 60 * 1000` | Scheduled replay cadence | +| `maxReplayBatchSize` | `10` | Maximum records claimed per replay pass | +| `leaseDurationMilliseconds` | `2 * 60 * 1000` | Reclaims stale replay leases | +| `shutdownTimeoutMilliseconds` | `10_000` | Shared shutdown budget for accepted live exports and admitted durable handoff completion | +| `tokenResolutionTimeoutMilliseconds` | `30_000` | Timeout per replay token-resolution attempt | ### Operational Notes diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c455e1..2384515b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Features Added +- Add manual `sessionId` propagation to `ExecuteToolScope` and `InferenceScope`, plus opt-in custom baggage enrichment for recognized GenAI spans through `BaggageBuilder.customAttribute()` and `customAttributes()`. [#242](https://github.com/microsoft/opentelemetry-distro-javascript/pull/242) - Add GenAI v1.42 InvokeAgent request, response, cache-token, and provider attribute capture for manual A365 scopes. [#239](https://github.com/microsoft/opentelemetry-distro-javascript/pull/239) ### Other Changes diff --git a/src/a365/constants.ts b/src/a365/constants.ts index 9255703a..a57b3c0a 100644 --- a/src/a365/constants.ts +++ b/src/a365/constants.ts @@ -3,6 +3,9 @@ import { MICROSOFT_OPENTELEMETRY_VERSION } from "../types.js"; +/** Internal baggage metadata key for registered custom attribute names. */ +export const INTERNAL_CUSTOM_KEYS_METADATA_KEY = "_internal.custom_keys"; + /** * OpenTelemetry constants for A365 observability. * diff --git a/src/a365/middleware/BaggageBuilder.ts b/src/a365/middleware/BaggageBuilder.ts index cf62ed61..83efa55f 100644 --- a/src/a365/middleware/BaggageBuilder.ts +++ b/src/a365/middleware/BaggageBuilder.ts @@ -12,7 +12,64 @@ import { propagation, context as otelContext } from "@opentelemetry/api"; import type { Context } from "@opentelemetry/api"; -import { OpenTelemetryConstants } from "../constants.js"; +import { INTERNAL_CUSTOM_KEYS_METADATA_KEY, OpenTelemetryConstants } from "../constants.js"; + +function getPairEntries( + pairs: Record | Iterable<[string, T]>, +): Iterable<[string, T]> { + if (Symbol.iterator in Object(pairs)) { + return pairs as Iterable<[string, T]>; + } + + return Object.entries(pairs); +} + +function normalizeValue(value: string | null | undefined): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeCustomKey(key: string): string | undefined { + const trimmed = key.trim(); + if (!trimmed || trimmed.includes(",") || trimmed === INTERNAL_CUSTOM_KEYS_METADATA_KEY) { + return undefined; + } + + return trimmed; +} + +function parseCustomKeys(value: string | undefined): Set { + const keys = new Set(); + if (!value) { + return keys; + } + + for (const rawKey of value.split(",")) { + const key = normalizeCustomKey(rawKey); + if (key) { + keys.add(key); + } + } + + return keys; +} + +function serializeCustomKeys(customKeys: Iterable): string | undefined { + const normalizedKeys = new Set(); + for (const customKey of customKeys) { + const normalizedKey = normalizeCustomKey(customKey); + if (normalizedKey) { + normalizedKeys.add(normalizedKey); + } + } + + const sortedKeys = [...normalizedKeys].sort((left, right) => left.localeCompare(right)); + return sortedKeys.length > 0 ? sortedKeys.join(",") : undefined; +} /** * Fluent builder for setting OpenTelemetry baggage values. @@ -31,6 +88,7 @@ import { OpenTelemetryConstants } from "../constants.js"; */ export class BaggageBuilder { private pairs: Map = new Map(); + private customKeys: Set = new Set(); /** Set the operation source baggage value (e.g., ATG, ACF). */ operationSource(value: string | null | undefined): BaggageBuilder { @@ -177,26 +235,53 @@ export class BaggageBuilder { * Set multiple baggage pairs from a dictionary or iterable. * @param pairs Dictionary or iterable of key-value pairs */ - setPairs( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- preserve source compatibility for interface/class-typed callers pairs: Record | Iterable<[string, any]> | null | undefined, ): BaggageBuilder { if (!pairs) { return this; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let entries: Iterable<[string, any]>; - if (Symbol.iterator in Object(pairs)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - entries = pairs as Iterable<[string, any]>; - } else { - entries = Object.entries(pairs); + for (const [key, value] of getPairEntries(pairs)) { + if (value !== null && value !== undefined) { + this.set(key, String(value)); + } + } + + return this; + } + + /** + * Set a single custom baggage pair and register its key for metadata propagation. + */ + customAttribute(key: string, value: string | null | undefined): BaggageBuilder { + const normalizedKey = normalizeCustomKey(key); + const normalizedValue = normalizeValue(value); + + if (normalizedKey && normalizedValue) { + this.pairs.set(normalizedKey, normalizedValue); + this.customKeys.add(normalizedKey); + } + + return this; + } + + /** + * Set multiple custom baggage pairs and register their keys for metadata propagation. + * @param pairs Dictionary or iterable of key-value pairs + */ + customAttributes( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- preserve source compatibility for interface/class-typed callers + pairs: Record | Iterable<[string, any]> | null | undefined, + ): BaggageBuilder { + if (!pairs) { + return this; } - for (const [key, value] of entries) { + for (const [key, value] of getPairEntries(pairs)) { if (value !== null && value !== undefined) { - this.set(key, String(value)); + this.customAttribute(key, String(value)); } } @@ -208,18 +293,16 @@ export class BaggageBuilder { * @returns A BaggageScope that can run callbacks under the baggage context */ build(): BaggageScope { - return new BaggageScope(this.pairs); + return new BaggageScope(this.pairs, this.customKeys); } /** * Add a baggage key/value if the value is not null or whitespace. */ private set(key: string, value: string | null | undefined): void { - if (value !== null && value !== undefined) { - const trimmed = value.trim(); - if (trimmed) { - this.pairs.set(key, trimmed); - } + const trimmed = normalizeValue(value); + if (trimmed) { + this.pairs.set(key, trimmed); } } @@ -244,18 +327,34 @@ export class BaggageScope { /** @internal Exposed for testing. */ readonly contextWithBaggage: Context; - constructor(pairs: Map) { + constructor(pairs: Map, customKeys: ReadonlySet = new Set()) { // 1. Start from current active context const currentCtx = otelContext.active(); // 2. Build merged baggage let bag = propagation.getBaggage(currentCtx) ?? propagation.createBaggage({}); + const mergedCustomKeys = parseCustomKeys( + bag.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value, + ); + for (const [key, value] of pairs.entries()) { if (value && value.trim()) { bag = bag.setEntry(key, { value }); } } + for (const customKey of customKeys) { + const normalizedKey = normalizeCustomKey(customKey); + if (normalizedKey) { + mergedCustomKeys.add(normalizedKey); + } + } + + const customKeysMetadata = serializeCustomKeys(mergedCustomKeys); + bag = customKeysMetadata + ? bag.setEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY, { value: customKeysMetadata }) + : bag.removeEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY); + // 3. Create a new context that carries that baggage this.contextWithBaggage = propagation.setBaggage(currentCtx, bag); } diff --git a/src/a365/processors/A365SpanProcessor.ts b/src/a365/processors/A365SpanProcessor.ts index 792b4c82..0da2f41e 100644 --- a/src/a365/processors/A365SpanProcessor.ts +++ b/src/a365/processors/A365SpanProcessor.ts @@ -17,10 +17,55 @@ import type { SpanProcessor as BaseSpanProcessor, ReadableSpan, } from "@opentelemetry/sdk-trace-base"; -import { OpenTelemetryConstants } from "../constants.js"; +import { INTERNAL_CUSTOM_KEYS_METADATA_KEY, OpenTelemetryConstants } from "../constants.js"; import { GEN_AI_OPERATION_NAMES } from "../exporter/utils.js"; import { GENERIC_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES } from "./util.js"; +const DEFAULT_GEN_AI_INSTRUMENTATION_SCOPE_NAMES: readonly string[] = [ + "microsoft-otel-langchain", + "microsoft-otel-openai-agents", +]; + +const INVOKE_AGENT_ATTRIBUTE_NAMES = new Set(INVOKE_AGENT_ATTRIBUTES); + +function getOperationFromSpanName(spanName: unknown): string | undefined { + if (typeof spanName !== "string") { + return undefined; + } + + for (const operationName of GEN_AI_OPERATION_NAMES) { + if (spanName === operationName || spanName.startsWith(`${operationName} `)) { + return operationName; + } + } + + return undefined; +} + +function getRegisteredCustomKeys(value: string | undefined): string[] { + if (!value) { + return []; + } + + return value + .split(",") + .map((key) => key.trim()) + .filter((key) => key && key !== INTERNAL_CUSTOM_KEYS_METADATA_KEY); +} + +function shouldCopyRegisteredCustomKey(key: string, isInvokeAgent: boolean): boolean { + return isInvokeAgent || !INVOKE_AGENT_ATTRIBUTE_NAMES.has(key); +} + +function getSpanAttributeValue(key: string, value: string): string | number | undefined { + if (key !== OpenTelemetryConstants.SERVER_PORT_KEY) { + return value; + } + + const port = Number(value); + return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : undefined; +} + /** * Copies relevant baggage entries to span attributes on span start. * @@ -29,6 +74,10 @@ import { GENERIC_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES } from "./util.js"; * without explicitly creating scopes. */ export class A365SpanProcessor implements BaseSpanProcessor { + private readonly genAiInstrumentationScopeNames = new Set( + DEFAULT_GEN_AI_INSTRUMENTATION_SCOPE_NAMES, + ); + /** * Called when a span is started. * Copies relevant baggage entries to span attributes. @@ -42,16 +91,16 @@ export class A365SpanProcessor implements BaseSpanProcessor { return; } + const spanRecord = span as Span & { + attributes?: Record; + name?: string; + instrumentationScope?: { name?: string }; + }; + // Get existing span attributes const existingAttrs = new Set(); - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const spanRecord = span as any; - if (spanRecord.attributes) { - Object.keys(spanRecord.attributes).forEach((key) => existingAttrs.add(key)); - } - } catch { - // Ignore errors accessing span attributes + if (spanRecord.attributes) { + Object.keys(spanRecord.attributes).forEach((key) => existingAttrs.add(key)); } // Get all baggage entries @@ -60,13 +109,20 @@ export class A365SpanProcessor implements BaseSpanProcessor { return; } - // Only process GenAI spans — those with a known gen_ai.operation.name - // span attribute (invoke_agent, execute_tool, chat, output_messages). - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const operationNameAttr = (span as any).attributes?.[ - OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY - ]; - if (!GEN_AI_OPERATION_NAMES.has(operationNameAttr)) { + const explicitOperation = + spanRecord.attributes?.[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]; + const recognizedExplicitOperation = + typeof explicitOperation === "string" && GEN_AI_OPERATION_NAMES.has(explicitOperation) + ? explicitOperation + : undefined; + const inferredOperation = + explicitOperation === undefined ? getOperationFromSpanName(spanRecord.name) : undefined; + const operationName = recognizedExplicitOperation ?? inferredOperation; + const supportedScope = + typeof spanRecord.instrumentationScope?.name === "string" && + this.genAiInstrumentationScopeNames.has(spanRecord.instrumentationScope.name); + + if (!operationName && !supportedScope) { return; } @@ -78,17 +134,17 @@ export class A365SpanProcessor implements BaseSpanProcessor { }); // Determine if this is an invoke_agent operation - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const spanName = (span as any).name || ""; - const isInvokeAgent = - operationNameAttr === OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME || - spanName.startsWith(OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME); + const isInvokeAgent = operationName === OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME; // Build target key set const targetKeys = new Set(GENERIC_ATTRIBUTES); if (isInvokeAgent) { INVOKE_AGENT_ATTRIBUTES.forEach((key) => targetKeys.add(key)); } + getRegisteredCustomKeys(baggageMap.get(INTERNAL_CUSTOM_KEYS_METADATA_KEY)) + .filter((key) => shouldCopyRegisteredCustomKey(key, isInvokeAgent)) + .forEach((key) => targetKeys.add(key)); + targetKeys.delete(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY); // Set telemetry SDK attributes if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY)) { @@ -96,18 +152,21 @@ export class A365SpanProcessor implements BaseSpanProcessor { OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY, OpenTelemetryConstants.TELEMETRY_SDK_NAME_VALUE, ); + existingAttrs.add(OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY); } if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY)) { span.setAttribute( OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY, OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_VALUE, ); + existingAttrs.add(OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY); } if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY)) { span.setAttribute( OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY, OpenTelemetryConstants.TELEMETRY_SDK_VERSION_VALUE, ); + existingAttrs.add(OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY); } // Copy baggage to span attributes @@ -122,8 +181,13 @@ export class A365SpanProcessor implements BaseSpanProcessor { continue; } + const attributeValue = getSpanAttributeValue(key, value); + if (attributeValue === undefined) { + continue; + } + try { - span.setAttribute(key, value); + span.setAttribute(key, attributeValue); } catch { // Ignore errors setting attributes } diff --git a/src/a365/processors/util.ts b/src/a365/processors/util.ts index 60774737..a67ab761 100644 --- a/src/a365/processors/util.ts +++ b/src/a365/processors/util.ts @@ -53,4 +53,7 @@ export const INVOKE_AGENT_ATTRIBUTES: readonly string[] = [ consts.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, consts.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, consts.GEN_AI_CALLER_AGENT_VERSION_KEY, + // Server address/port for invoke agent target + consts.SERVER_ADDRESS_KEY, + consts.SERVER_PORT_KEY, ]; diff --git a/src/a365/scopes/ExecuteToolScope.ts b/src/a365/scopes/ExecuteToolScope.ts index 84b4b908..fbd2d076 100644 --- a/src/a365/scopes/ExecuteToolScope.ts +++ b/src/a365/scopes/ExecuteToolScope.ts @@ -72,6 +72,7 @@ export class ExecuteToolScope extends OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_DESCRIPTION_KEY, description); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); + this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request.sessionId); this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); diff --git a/src/a365/scopes/InferenceScope.ts b/src/a365/scopes/InferenceScope.ts index 821ce5ca..a53d7c58 100644 --- a/src/a365/scopes/InferenceScope.ts +++ b/src/a365/scopes/InferenceScope.ts @@ -75,6 +75,7 @@ export class InferenceScope extends OpenTelemetryScope { // Conversation and channel this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); + this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request.sessionId); this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); diff --git a/test/internal/functional/genai-distro.test.ts b/test/internal/functional/genai-distro.test.ts index 1dd3fbcb..93b3387d 100644 --- a/test/internal/functional/genai-distro.test.ts +++ b/test/internal/functional/genai-distro.test.ts @@ -9,7 +9,12 @@ import { BasicTracerProvider, } from "@opentelemetry/sdk-trace-base"; import { trace, type ProxyTracerProvider } from "@opentelemetry/api"; -import { useMicrosoftOpenTelemetry, shutdownMicrosoftOpenTelemetry } from "../../../src/index.js"; +import { + BaggageBuilder, + OpenTelemetryConstants, + useMicrosoftOpenTelemetry, + shutdownMicrosoftOpenTelemetry, +} from "../../../src/index.js"; import { LangChainTraceInstrumentor } from "../../../src/genai/instrumentations/langchain/langchainTraceInstrumentor.js"; import { ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_CHAT } from "../../../src/genai/index.js"; @@ -74,6 +79,10 @@ describe("GenAI distro integration", () => { useMicrosoftOpenTelemetry({ tracesPerSecond: 0, samplingRatio: 1, + a365: { + enabled: true, + tokenResolver: () => "token", + }, azureMonitor: { enabled: false }, enableConsoleExporters: false, spanProcessors: [new SimpleSpanProcessor(exporter)], @@ -97,8 +106,15 @@ describe("GenAI distro integration", () => { ) as any; const run = makeLangChainRun(); - await langChainTracer.onRunCreate(run); - await langChainTracer._endTrace(run); + const baggageScope = new BaggageBuilder() + .tenantId("tenant-123") + .customAttribute("custom.scope", "langchain") + .build(); + + await baggageScope.run(async () => { + await langChainTracer.onRunCreate(run); + await langChainTracer._endTrace(run); + }); await flushGlobalTracerProvider(); const spans = exporter.getFinishedSpans(); @@ -108,5 +124,7 @@ describe("GenAI distro integration", () => { ); expect(chatSpan).toBeDefined(); expect(chatSpan?.instrumentationScope.name).toBe("microsoft-otel-langchain"); + expect(chatSpan?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(chatSpan?.attributes["custom.scope"]).toBe("langchain"); }); }); diff --git a/test/internal/functional/genai-openai-distro.test.ts b/test/internal/functional/genai-openai-distro.test.ts index 32a491f4..d3bf585e 100644 --- a/test/internal/functional/genai-openai-distro.test.ts +++ b/test/internal/functional/genai-openai-distro.test.ts @@ -9,7 +9,12 @@ import { } from "@opentelemetry/sdk-trace-base"; import { trace, type ProxyTracerProvider } from "@opentelemetry/api"; import * as OpenAIAgents from "@openai/agents"; -import { useMicrosoftOpenTelemetry, shutdownMicrosoftOpenTelemetry } from "../../../src/index.js"; +import { + BaggageBuilder, + OpenTelemetryConstants, + useMicrosoftOpenTelemetry, + shutdownMicrosoftOpenTelemetry, +} from "../../../src/index.js"; import { OpenAIAgentsTraceInstrumentor } from "../../../src/genai/instrumentations/openai/openAIAgentsTraceInstrumentor.js"; import { ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_CHAT } from "../../../src/genai/index.js"; @@ -35,15 +40,22 @@ describe("OpenAI Agents distro integration", () => { vi.restoreAllMocks(); }); - it("wires OpenAI Agents via distro init and emits spans with microsoft-otel-openai-agents scope", async () => { + it("wires OpenAI Agents via distro init with the built-in instrumentation scope", async () => { useMicrosoftOpenTelemetry({ tracesPerSecond: 0, samplingRatio: 1, + a365: { + enabled: true, + tokenResolver: () => "token", + }, azureMonitor: { enabled: false }, enableConsoleExporters: false, spanProcessors: [new SimpleSpanProcessor(exporter)], instrumentationOptions: { - openaiAgents: { enabled: true, isContentRecordingEnabled: true }, + openaiAgents: { + enabled: true, + isContentRecordingEnabled: true, + }, langchain: { enabled: false }, }, }); @@ -57,10 +69,14 @@ describe("OpenAI Agents distro integration", () => { expect(OpenAIAgents.getCurrentTrace()).toBeNull(); }); - await vi.waitFor(async () => { - exporter.reset(); - OpenAIAgentsTraceInstrumentor.enable(); + exporter.reset(); + OpenAIAgentsTraceInstrumentor.enable(); + const baggageScope = new BaggageBuilder() + .tenantId("tenant-123") + .customAttribute("custom.scope", "openai") + .build(); + await baggageScope.run(async () => { await OpenAIAgents.withTrace("genai-openai-integration", async () => { await OpenAIAgents.withGenerationSpan( async () => { @@ -76,15 +92,17 @@ describe("OpenAI Agents distro integration", () => { } as any, ); }); - - await flushGlobalTracerProvider(); - const spans = exporter.getFinishedSpans(); - expect(spans.length).toBeGreaterThan(0); - const chatSpan = spans.find( - (s) => s.attributes[ATTR_GEN_AI_OPERATION_NAME] === GEN_AI_OPERATION_CHAT, - ); - expect(chatSpan).toBeDefined(); - expect(chatSpan?.instrumentationScope.name).toBe("microsoft-otel-openai-agents"); }); + + await flushGlobalTracerProvider(); + const spans = exporter.getFinishedSpans(); + expect(spans.length).toBeGreaterThan(0); + const chatSpan = spans.find( + (s) => s.attributes[ATTR_GEN_AI_OPERATION_NAME] === GEN_AI_OPERATION_CHAT, + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan?.instrumentationScope.name).toBe("microsoft-otel-openai-agents"); + expect(chatSpan?.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(chatSpan?.attributes["custom.scope"]).toBe("openai"); }); }); diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts index f64d3b88..d846cc31 100644 --- a/test/internal/unit/a365/a365SpanProcessor.test.ts +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -16,6 +16,8 @@ import { INVOKE_AGENT_ATTRIBUTES, } from "../../../../src/a365/index.js"; +const INTERNAL_CUSTOM_KEYS_METADATA_KEY = "_internal.custom_keys"; + /** * Helper: creates a baggage instance with the given entries. */ @@ -27,6 +29,37 @@ function createBaggage(entries: Record) { return baggage; } +function startSpan( + provider: BasicTracerProvider, + { + tracerName = "test", + spanName, + operationName, + baggage = {}, + }: { + tracerName?: string; + spanName: string; + operationName?: string; + baggage?: Record; + }, +) { + const ctx = propagation.setBaggage(context.active(), createBaggage(baggage)); + return provider.getTracer(tracerName).startSpan( + spanName, + { + kind: SpanKind.CLIENT, + ...(operationName + ? { + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: operationName, + }, + } + : {}), + }, + ctx, + ); +} + /** * Helper: starts a GenAI span with `gen_ai.operation.name` as a span attribute * and the given baggage entries in context. @@ -36,9 +69,8 @@ function startGenAiSpan( operationName: string, baggage: Record = {}, spanName?: string, + attributes: Record = {}, ) { - const bag = createBaggage(baggage); - const ctx = propagation.setBaggage(context.active(), bag); const tracer = provider.getTracer("test"); return tracer.startSpan( spanName ?? `${operationName} span`, @@ -46,9 +78,10 @@ function startGenAiSpan( kind: SpanKind.CLIENT, attributes: { [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: operationName, + ...attributes, }, }, - ctx, + propagation.setBaggage(context.active(), createBaggage(baggage)), ); } @@ -70,6 +103,233 @@ describe("A365SpanProcessor", () => { }); describe("GenAI span filtering", () => { + it.each(["microsoft-otel-langchain", "microsoft-otel-openai-agents"])( + "copies generic and registered custom baggage for supported scope %s without an initial operation", + (tracerName) => { + const span = startSpan(provider, { + tracerName, + spanName: "unmodeled operation", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-1", + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes["custom.one"]).toBe("value-1"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + }, + ); + + it.each(["microsoft-otel-langchain", "microsoft-otel-openai-agents"])( + "does not copy ambient gen_ai.operation.name baggage for supported scope %s without an initial operation", + (tracerName) => { + const span = startSpan(provider, { + tracerName, + spanName: "unmodeled operation", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: "chat", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-1", + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes["custom.one"]).toBe("value-1"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + expect(attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBeUndefined(); + }, + ); + + it("keeps exact tracer scope matching only", () => { + const span = startSpan(provider, { + tracerName: "microsoft-otel-langchain.child", + spanName: "unmodeled operation", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-1", + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); + expect(attributes["custom.one"]).toBeUndefined(); + }); + + it("recognizes span-name boundaries for invoke-agent operations only", () => { + const copied = startSpan(provider, { + tracerName: "test", + spanName: "invoke_agent planner", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + }, + }); + copied.end(); + + const copiedAttrs = memoryExporter.getFinishedSpans()[0].attributes; + expect(copiedAttrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(copiedAttrs[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBe("caller-123"); + + memoryExporter.reset(); + + const untouched = startSpan(provider, { + tracerName: "test", + spanName: "invoke_agent_toolbox", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-456", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-456", + }, + }); + untouched.end(); + + const untouchedAttrs = memoryExporter.getFinishedSpans()[0].attributes; + expect(untouchedAttrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); + expect(untouchedAttrs[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + }); + + it("does not copy ambient gen_ai.operation.name baggage for span-name inferred invoke_agent spans", () => { + const span = startSpan(provider, { + tracerName: "microsoft-otel-langchain", + spanName: "invoke_agent planner", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: "chat", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBe("caller-123"); + expect(attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBeUndefined(); + }); + + it("prefers explicit unknown operations over span-name inference", () => { + const span = startSpan(provider, { + tracerName: "microsoft-otel-langchain", + spanName: "invoke_agent planner", + operationName: "chain", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-1", + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes["custom.one"]).toBe("value-1"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + }); + + it.each([ + { + name: "supported-scope provisional operations", + tracerName: "microsoft-otel-openai-agents", + spanName: "mcp_tools listing", + operationName: "chain", + }, + { + name: "recognized non-invoke operations", + tracerName: "test", + spanName: "chat span", + operationName: OpenTelemetryConstants.CHAT_OPERATION_NAME, + }, + ])( + "does not copy invoke-agent-only baggage registered as custom for $name", + ({ tracerName, spanName, operationName }) => { + const span = startSpan(provider, { + tracerName, + spanName, + operationName, + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + [OpenTelemetryConstants.SERVER_ADDRESS_KEY]: "agent.example.com", + [OpenTelemetryConstants.SERVER_PORT_KEY]: "8443", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: [ + OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, + OpenTelemetryConstants.SERVER_ADDRESS_KEY, + OpenTelemetryConstants.SERVER_PORT_KEY, + ].join(","), + }, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + expect(attributes[OpenTelemetryConstants.SERVER_ADDRESS_KEY]).toBeUndefined(); + expect(attributes[OpenTelemetryConstants.SERVER_PORT_KEY]).toBeUndefined(); + }, + ); + + it("copies invoke-agent server baggage only onto invoke_agent spans", () => { + const span = startGenAiSpan(provider, OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, { + [OpenTelemetryConstants.SERVER_ADDRESS_KEY]: "agent.example.com", + [OpenTelemetryConstants.SERVER_PORT_KEY]: "8443", + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.SERVER_ADDRESS_KEY]).toBe("agent.example.com"); + expect(attributes[OpenTelemetryConstants.SERVER_PORT_KEY]).toBe(8443); + }); + + it.each(["not-a-port", "8443.5", "0", "65536"])( + "does not copy invalid invoke-agent server port baggage %s", + (port) => { + const span = startGenAiSpan(provider, OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, { + [OpenTelemetryConstants.SERVER_PORT_KEY]: port, + }); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.SERVER_PORT_KEY]).toBeUndefined(); + }, + ); + + it("copies generic and registered custom baggage for provisional chain spans from supported scopes", () => { + const span = startSpan(provider, { + tracerName: "microsoft-otel-openai-agents", + spanName: "mcp_tools listing", + operationName: "chain", + baggage: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.scope", + "custom.scope": "openai", + }, + }); + span.setAttribute( + OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY, + OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, + ); + span.end(); + + const attributes = memoryExporter.getFinishedSpans()[0].attributes; + expect(attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe( + OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, + ); + expect(attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(attributes["custom.scope"]).toBe("openai"); + expect(attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBeUndefined(); + }); + it("should not mutate spans without gen_ai.operation.name", () => { const baggageEntries = { [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", @@ -184,7 +444,9 @@ describe("A365SpanProcessor", () => { it("should not mutate spans with an unknown gen_ai.operation.name value", () => { const bag = createBaggage({ + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + "custom.one": "value-1", }); const ctx = propagation.setBaggage(context.active(), bag); @@ -204,6 +466,7 @@ describe("A365SpanProcessor", () => { const spans = memoryExporter.getFinishedSpans(); expect(spans).toHaveLength(1); const attrs = spans[0].attributes; + expect(attrs["custom.one"]).toBeUndefined(); expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBeUndefined(); expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBeUndefined(); }); @@ -361,6 +624,128 @@ describe("A365SpanProcessor", () => { }); }); + describe("registered custom baggage propagation", () => { + it("should copy registered custom baggage attributes from metadata", () => { + const testSpan = startGenAiSpan(provider, "chat", { + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one,custom.two", + "custom.one": "value-1", + "custom.two": "value-2", + "custom.three": "value-3", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + const attrs = spans[0].attributes; + expect(attrs["custom.one"]).toBe("value-1"); + expect(attrs["custom.two"]).toBe("value-2"); + expect(attrs["custom.three"]).toBeUndefined(); + expect(attrs[INTERNAL_CUSTOM_KEYS_METADATA_KEY]).toBeUndefined(); + }); + + it("should trim registered custom baggage metadata and ignore empty entries", () => { + const testSpan = startGenAiSpan(provider, "chat", { + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: " custom.one , , custom.two ,, ", + "custom.one": "value-1", + "custom.two": "value-2", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + const attrs = spans[0].attributes; + expect(attrs["custom.one"]).toBe("value-1"); + expect(attrs["custom.two"]).toBe("value-2"); + }); + + it("should not copy unmarked custom baggage attributes", () => { + const testSpan = startGenAiSpan(provider, "chat", { + "custom.one": "value-1", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0].attributes["custom.one"]).toBeUndefined(); + }); + + it("should keep existing span attributes when registered custom baggage collides", () => { + const testSpan = startGenAiSpan( + provider, + "chat", + { + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: "custom.one", + "custom.one": "value-from-baggage", + }, + undefined, + { + "custom.one": "value-existing", + }, + ); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0].attributes["custom.one"]).toBe("value-existing"); + }); + + it("should never copy the custom metadata attribute itself", () => { + const testSpan = startGenAiSpan(provider, "chat", { + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: `custom.one,${INTERNAL_CUSTOM_KEYS_METADATA_KEY}`, + "custom.one": "value-1", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + const attrs = spans[0].attributes; + expect(attrs["custom.one"]).toBe("value-1"); + expect(attrs[INTERNAL_CUSTOM_KEYS_METADATA_KEY]).toBeUndefined(); + }); + + it("should still copy invoke-agent-only baggage on invoke_agent spans when the key is registered as custom", () => { + const testSpan = startGenAiSpan( + provider, + OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, + { + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]: "caller-123", + }, + ); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0].attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY]).toBe( + "caller-123", + ); + }); + + it("should not allow registered custom baggage to overwrite telemetry SDK attributes", () => { + const testSpan = startGenAiSpan(provider, "chat", { + [INTERNAL_CUSTOM_KEYS_METADATA_KEY]: + "telemetry.sdk.name,telemetry.sdk.language,telemetry.sdk.version", + [OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]: "spoofed-sdk", + [OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY]: "spoofed-language", + [OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY]: "0.0.0", + }); + testSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + const attrs = spans[0].attributes; + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBe( + OpenTelemetryConstants.TELEMETRY_SDK_NAME_VALUE, + ); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY]).toBe( + OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_VALUE, + ); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY]).toBe( + OpenTelemetryConstants.TELEMETRY_SDK_VERSION_VALUE, + ); + }); + }); + describe("attribute registry application", () => { it("should apply all generic attributes", () => { expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.TENANT_ID_KEY); @@ -384,6 +769,8 @@ describe("A365SpanProcessor", () => { expect(INVOKE_AGENT_ATTRIBUTES).toContain( OpenTelemetryConstants.GEN_AI_CALLER_AGENT_VERSION_KEY, ); + expect(INVOKE_AGENT_ATTRIBUTES).toContain(OpenTelemetryConstants.SERVER_ADDRESS_KEY); + expect(INVOKE_AGENT_ATTRIBUTES).toContain(OpenTelemetryConstants.SERVER_PORT_KEY); }); it("should include blueprint ID and agent version in generic attributes", () => { diff --git a/test/internal/unit/a365/baggageBuilder.test.ts b/test/internal/unit/a365/baggageBuilder.test.ts index 53bb25da..24b04bc1 100644 --- a/test/internal/unit/a365/baggageBuilder.test.ts +++ b/test/internal/unit/a365/baggageBuilder.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, expectTypeOf, beforeAll, afterAll } from "vitest"; import { context, propagation } from "@opentelemetry/api"; import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; @@ -11,6 +11,26 @@ import { OpenTelemetryConstants, } from "../../../../src/a365/index.js"; +const INTERNAL_CUSTOM_KEYS_METADATA_KEY = "_internal.custom_keys"; + +interface InterfaceTypedBaggagePairs { + "microsoft.tenant.id": string; + "gen_ai.agent.id": string; +} + +interface InterfaceTypedCustomAttributes { + alpha: number; + beta: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- match the source-compatible public API surface +type PublicPairsArg = Record | Iterable<[string, any]> | null | undefined; + +function getScopeBaggage(scope: BaggageScope) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return propagation.getBaggage((scope as any).contextWithBaggage); +} + describe("BaggageBuilder", () => { let contextManager: AsyncLocalStorageContextManager; @@ -111,6 +131,12 @@ describe("BaggageBuilder", () => { }); describe("setPairs", () => { + it("should preserve the public setPairs parameter compatibility", () => { + type SetPairsArg = Parameters[0]; + + expectTypeOf().toEqualTypeOf(); + }); + it("should accept dictionary of pairs", () => { const builder = new BaggageBuilder(); builder.setPairs({ @@ -122,6 +148,21 @@ describe("BaggageBuilder", () => { expect(scope).toBeInstanceOf(BaggageScope); }); + it("should accept interface-typed object inputs", () => { + const builder = new BaggageBuilder(); + const pairs: InterfaceTypedBaggagePairs = { + "microsoft.tenant.id": "tenant-123", + "gen_ai.agent.id": "agent-456", + }; + + const result: BaggageBuilder = builder.setPairs(pairs); + expect(result).toBe(builder); + + const bag = getScopeBaggage(result.build()); + expect(bag?.getEntry(OpenTelemetryConstants.TENANT_ID_KEY)?.value).toBe("tenant-123"); + expect(bag?.getEntry(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY)?.value).toBe("agent-456"); + }); + it("should accept iterable of pairs", () => { const builder = new BaggageBuilder(); const pairs: Array<[string, string]> = [ @@ -153,6 +194,112 @@ describe("BaggageBuilder", () => { const scope = builder.build(); expect(scope).toBeInstanceOf(BaggageScope); }); + + it("should not mark setPairs entries as custom metadata", () => { + const scope = new BaggageBuilder() + .setPairs({ + custom_key: "custom-value", + }) + .build(); + + const bag = getScopeBaggage(scope); + expect(bag?.getEntry("custom_key")?.value).toBe("custom-value"); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)).toBeUndefined(); + }); + }); + + describe("custom attributes", () => { + it("should preserve the public customAttributes parameter compatibility", () => { + type CustomAttributesArg = Parameters[0]; + + expectTypeOf().toEqualTypeOf(); + }); + + it("should normalize and mark a custom attribute", () => { + const builder = new BaggageBuilder(); + expect(typeof (builder as unknown as { customAttribute?: unknown }).customAttribute).toBe( + "function", + ); + + const scope = ( + builder as unknown as { + customAttribute(key: string, value: string | null | undefined): BaggageBuilder; + } + ) + .customAttribute(" custom.key ", " custom-value ") + .build(); + + const bag = getScopeBaggage(scope); + expect(bag?.getEntry("custom.key")?.value).toBe("custom-value"); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("custom.key"); + }); + + it("should accept object inputs and stringify valid values", () => { + const scope = ( + new BaggageBuilder() as unknown as { + customAttributes( + pairs: Record | Iterable<[string, unknown]> | null | undefined, + ): BaggageBuilder; + } + ) + .customAttributes({ + " beta ": " two ", + alpha: 1, + blank: " ", + skipNull: null, + }) + .build(); + + const bag = getScopeBaggage(scope); + expect(bag?.getEntry("alpha")?.value).toBe("1"); + expect(bag?.getEntry("beta")?.value).toBe("two"); + expect(bag?.getEntry("blank")).toBeUndefined(); + expect(bag?.getEntry("skipNull")).toBeUndefined(); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("alpha,beta"); + }); + + it("should accept interface-typed object inputs and stringify valid values", () => { + const builder = new BaggageBuilder(); + const pairs: InterfaceTypedCustomAttributes = { + alpha: 1, + beta: " two ", + }; + + const result: BaggageBuilder = builder.customAttributes(pairs); + expect(result).toBe(builder); + + const bag = getScopeBaggage(result.build()); + expect(bag?.getEntry("alpha")?.value).toBe("1"); + expect(bag?.getEntry("beta")?.value).toBe("two"); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("alpha,beta"); + }); + + it("should ignore invalid iterable keys and values", () => { + const scope = ( + new BaggageBuilder() as unknown as { + customAttributes( + pairs: Record | Iterable<[string, unknown]> | null | undefined, + ): BaggageBuilder; + } + ) + .customAttributes([ + [" valid ", " kept "], + [" ", "blank-key"], + ["bad,key", "comma-key"], + [INTERNAL_CUSTOM_KEYS_METADATA_KEY, "reserved-key"], + ["blank-value", " "], + ["null-value", null], + ]) + .build(); + + const bag = getScopeBaggage(scope); + expect(bag?.getEntry("valid")?.value).toBe("kept"); + expect(bag?.getEntry("")).toBeUndefined(); + expect(bag?.getEntry("bad,key")).toBeUndefined(); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("valid"); + expect(bag?.getEntry("blank-value")).toBeUndefined(); + expect(bag?.getEntry("null-value")).toBeUndefined(); + }); }); describe("null and whitespace handling", () => { @@ -219,15 +366,15 @@ describe("BaggageBuilder", () => { }, ); - it("should clear previously set non-443 port when port is 443", () => { + it("should clear a previously set port when a later call uses the default port", () => { const builder = new BaggageBuilder(); - builder.invokeAgentServer("api.example.com", 8080); - builder.invokeAgentServer("api.example.com", 443); + builder.invokeAgentServer("old.example.com", 8080); + builder.invokeAgentServer("new.example.com", 443); const scope = builder.build(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const bag = propagation.getBaggage((scope as any).contextWithBaggage); expect(bag?.getEntry(OpenTelemetryConstants.SERVER_ADDRESS_KEY)?.value).toBe( - "api.example.com", + "new.example.com", ); expect(bag?.getEntry(OpenTelemetryConstants.SERVER_PORT_KEY)).toBeUndefined(); }); @@ -325,6 +472,67 @@ describe("BaggageScope", () => { const restoredContext = context.active(); expect(restoredContext).toBeDefined(); }); + + it("should union custom metadata across nested scopes", () => { + const outerScope = ( + new BaggageBuilder() as unknown as { + customAttributes( + pairs: Record | Iterable<[string, unknown]> | null | undefined, + ): BaggageBuilder; + } + ) + .customAttributes({ + beta: "two", + alpha: "one", + }) + .build(); + + outerScope.run(() => { + const innerScope = ( + new BaggageBuilder() as unknown as { + customAttributes( + pairs: Record | Iterable<[string, unknown]> | null | undefined, + ): BaggageBuilder; + } + ) + .customAttributes([ + ["gamma", "three"], + ["beta", "updated"], + ]) + .build(); + + const bag = getScopeBaggage(innerScope); + expect(bag?.getEntry("alpha")?.value).toBe("one"); + expect(bag?.getEntry("beta")?.value).toBe("updated"); + expect(bag?.getEntry("gamma")?.value).toBe("three"); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("alpha,beta,gamma"); + }); + }); + + it("should deduplicate ambient custom metadata deterministically", () => { + const ambientBaggage = propagation + .createBaggage({}) + .setEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY, { + value: "beta, alpha ,beta", + }) + .setEntry("alpha", { value: "one" }); + const ambientContext = propagation.setBaggage(context.active(), ambientBaggage); + + context.with(ambientContext, () => { + const scope = ( + new BaggageBuilder() as unknown as { + customAttribute(key: string, value: string | null | undefined): BaggageBuilder; + } + ) + .customAttribute(" gamma ", " three ") + .build(); + + const bag = getScopeBaggage(scope); + expect(bag?.getEntry("alpha")?.value).toBe("one"); + expect(bag?.getEntry("gamma")?.value).toBe("three"); + expect(bag?.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value).toBe("alpha,beta,gamma"); + }); + }); }); describe("disposable pattern", () => { diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts index b8b1099a..4728e6a4 100644 --- a/test/internal/unit/a365/scopes.test.ts +++ b/test/internal/unit/a365/scopes.test.ts @@ -572,6 +572,7 @@ describe("Scopes", () => { const scope = ExecuteToolScope.start( { conversationId: "conv-tool-123", + sessionId: "session-tool-123", channel: { name: "ChannelTool", description: "https://channel/tool" }, }, { toolName: "test-tool" }, @@ -586,6 +587,10 @@ describe("Scopes", () => { key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: "conv-tool-123", }), + expect.objectContaining({ + key: OpenTelemetryConstants.SESSION_ID_KEY, + val: "session-tool-123", + }), expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: "ChannelTool", @@ -831,6 +836,7 @@ describe("Scopes", () => { const scope = InferenceScope.start( { conversationId: "conv-inf-123", + sessionId: "session-inf-123", channel: { name: "ChannelInf", description: "https://channel/inf" }, }, inferenceDetails, @@ -845,6 +851,10 @@ describe("Scopes", () => { key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: "conv-inf-123", }), + expect.objectContaining({ + key: OpenTelemetryConstants.SESSION_ID_KEY, + val: "session-inf-123", + }), expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: "ChannelInf", @@ -1150,6 +1160,34 @@ describe("Request content and message serialization (span attributes)", () => { }); }); + describe("InferenceScope – session id span attribute", () => { + it("should write request.sessionId directly to the span", () => { + const scope = InferenceScope.start( + { ...testRequest, sessionId: "session-inf-123" }, + { operationName: InferenceOperationType.CHAT, model: "gpt-4o" }, + testAgentDetails, + ); + scope.dispose(); + + const attributes = getLastSpan().attributes; + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("session-inf-123"); + }); + }); + + describe("ExecuteToolScope – session id span attribute", () => { + it("should write request.sessionId directly to the span", () => { + const scope = ExecuteToolScope.start( + { ...testRequest, sessionId: "session-tool-123" }, + { toolName: "lookup", toolCallId: "tool-call-1", toolType: "function" }, + testAgentDetails, + ); + scope.dispose(); + + const attributes = getLastSpan().attributes; + expect(attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("session-tool-123"); + }); + }); + describe("InvokeAgentScope – GenAI request and response parameters", () => { it("should record all request attributes and response-at-start attributes", () => { const requestParameters: GenAiRequestParameters = { diff --git a/test/internal/unit/main.test.ts b/test/internal/unit/main.test.ts index 00be56d2..40221361 100644 --- a/test/internal/unit/main.test.ts +++ b/test/internal/unit/main.test.ts @@ -1211,6 +1211,38 @@ describe("Main functions", () => { await shutdownMicrosoftOpenTelemetry(); }); + it("does not register configured OpenAI tracer names as A365 fallback scopes", async () => { + const tracerName = "custom-openai-scope"; + useMicrosoftOpenTelemetry({ + azureMonitor: { enabled: false }, + enableConsoleExporters: false, + a365: { + enabled: true, + tokenResolver: () => "token", + }, + instrumentationOptions: { + openaiAgents: { + enabled: false, + tracerName, + }, + langchain: { enabled: false }, + }, + }); + + const internalSdk = _getSdkInstance(); + const tracerProvider = (internalSdk as any)["_tracerProvider"]; + const registeredProcessors = + tracerProvider?.["_activeSpanProcessor"]?.["_spanProcessors"] || []; + const processor = registeredProcessors.find( + (candidate: any) => candidate.constructor?.name === "A365SpanProcessor", + ); + + assert.isDefined(processor); + assert.isFalse(processor["genAiInstrumentationScopeNames"].has(tracerName)); + + await shutdownMicrosoftOpenTelemetry(); + }); + it("registers A365SpanProcessor but not Agent365Exporter when a365.enableObservabilityExporter is false (default)", async () => { useMicrosoftOpenTelemetry({ azureMonitor: { enabled: false }, @@ -1590,36 +1622,39 @@ describe("Main functions", () => { _resetA365LoggerForTest(); }); - it("initializes OpenAI Agents instrumentation when enabled", async () => { - const instrumentSpy = vi.spyOn(OpenAIAgentsTraceInstrumentor, "instrument"); + it.each(["openai-agent-auto-instrumentation", " openai-agent-auto-instrumentation ", ""])( + "initializes OpenAI Agents instrumentation with exact tracer name %j", + async (tracerName) => { + const instrumentSpy = vi.spyOn(OpenAIAgentsTraceInstrumentor, "instrument"); - useMicrosoftOpenTelemetry({ - azureMonitor: { enabled: false }, - enableConsoleExporters: false, - instrumentationOptions: { - openaiAgents: { - enabled: true, - tracerName: "openai-agent-auto-instrumentation", - tracerVersion: "1.0.0", - isContentRecordingEnabled: true, + useMicrosoftOpenTelemetry({ + azureMonitor: { enabled: false }, + enableConsoleExporters: false, + instrumentationOptions: { + openaiAgents: { + enabled: true, + tracerName, + tracerVersion: "1.0.0", + isContentRecordingEnabled: true, + }, + langchain: { enabled: false }, }, - langchain: { enabled: false }, - }, - }); + }); - await vi.waitFor(() => { - expect(instrumentSpy).toHaveBeenCalledWith( - expect.objectContaining({ - enabled: true, - tracerName: "openai-agent-auto-instrumentation", - tracerVersion: "1.0.0", - isContentRecordingEnabled: true, - }), - ); - }); + await vi.waitFor(() => { + expect(instrumentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + tracerName, + tracerVersion: "1.0.0", + isContentRecordingEnabled: true, + }), + ); + }); - await shutdownMicrosoftOpenTelemetry(); - }); + await shutdownMicrosoftOpenTelemetry(); + }, + ); it("initializes LangChain instrumentation when enabled", async () => { const instrumentSpy = vi.spyOn(LangChainTraceInstrumentor, "instrument");