diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index f90ed5aab..1ca1e6fbf 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -139,6 +139,11 @@ When you provide it, those scopes write `microsoft.session.id` directly on the c span instead of relying on later baggage enrichment. `OutputScope` does not currently propagate `request.sessionId` directly. +`InferenceScope` accepts `InferenceOperationType` enum values or the canonical strings +`chat`, `text_completion`, and `generate_content` in `details.operationName`. Both +`gen_ai.operation.name` and the span name use the canonical operation; for example, +`InferenceOperationType.TEXT_COMPLETION` with model `gpt-4` produces `text_completion gpt-4`. + `InvokeAgentScope.start()` captures request parameters immediately, while `recordResponseParameters()` captures response and usage values after the agent completes. @@ -203,7 +208,8 @@ baggageScope.run(() => { 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`. + `apply_guardrail`, `chat`, `text_completion`, `generate_content`, `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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 93656d652..55d79c752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Features Added +- Add privacy-safe Model Observation attributes to `InferenceScope`, including the canonical served response model and cache read/write input-token counts. Attributes can be supplied when the scope starts or recorded after the response is available; inference operations accept enum values or canonical strings and use canonical OpenTelemetry names for both operation attributes and span names. Cache-token semantic-convention constants are exported. [#261](https://github.com/microsoft/opentelemetry-distro-javascript/pull/261) + ## [1.5.0] - 2026-10-06 ### Bugs Fixed diff --git a/src/a365/contracts.ts b/src/a365/contracts.ts index d2382a1d7..5058d3252 100644 --- a/src/a365/contracts.ts +++ b/src/a365/contracts.ts @@ -459,8 +459,8 @@ export interface ToolCallDetails { /** Details for an inference call. */ export interface InferenceDetails { - /** The inference operation type (e.g. chat, text completion). */ - operationName: InferenceOperationType; + /** The inference operation enum value or canonical OpenTelemetry operation name. */ + operationName: InferenceOperationType | "chat" | "text_completion" | "generate_content"; /** Name of the model used for inference. */ model: string; /** Name of the provider serving the model. */ @@ -469,6 +469,12 @@ export interface InferenceDetails { inputTokens?: number; /** Number of output (completion) tokens. */ outputTokens?: number; + /** Canonical model name returned by the provider. */ + responseModel?: string; + /** Number of input tokens read from cache. */ + cacheReadInputTokens?: number; + /** Number of input tokens written to cache. */ + cacheWriteInputTokens?: number; /** Finish reasons returned by the model. */ finishReasons?: string[]; /** The model's thought process / reasoning trace. */ diff --git a/src/a365/exporter/utils.ts b/src/a365/exporter/utils.ts index 399f42dfd..0e3bf65df 100644 --- a/src/a365/exporter/utils.ts +++ b/src/a365/exporter/utils.ts @@ -14,9 +14,11 @@ import { ATTR_MICROSOFT_TENANT_ID, GEN_AI_OPERATION_APPLY_GUARDRAIL, GEN_AI_OPERATION_CHAT, + GEN_AI_OPERATION_GENERATE_CONTENT, GEN_AI_OPERATION_EXECUTE_TOOL, GEN_AI_OPERATION_INVOKE_AGENT, GEN_AI_OPERATION_OUTPUT_MESSAGES, + GEN_AI_OPERATION_TEXT_COMPLETION, } from "../../genai/semconv.js"; // Message attribute keys that receive special truncation handling const MESSAGE_ATTR_KEYS: Set = new Set([ @@ -36,9 +38,11 @@ export const GEN_AI_OPERATION_NAMES: ReadonlySet = new Set([ GEN_AI_OPERATION_OUTPUT_MESSAGES, // 'output_messages' GEN_AI_OPERATION_APPLY_GUARDRAIL, // 'apply_guardrail' GEN_AI_OPERATION_CHAT, // 'chat' - "Chat", // InferenceOperationType.CHAT - "TextCompletion", // InferenceOperationType.TEXT_COMPLETION - "GenerateContent", // InferenceOperationType.GENERATE_CONTENT + GEN_AI_OPERATION_TEXT_COMPLETION, // 'text_completion' + GEN_AI_OPERATION_GENERATE_CONTENT, // 'generate_content' + "Chat", // Legacy InferenceOperationType.CHAT value + "TextCompletion", // Legacy InferenceOperationType.TEXT_COMPLETION value + "GenerateContent", // Legacy InferenceOperationType.GENERATE_CONTENT value ]); /** diff --git a/src/a365/scopes/InferenceScope.ts b/src/a365/scopes/InferenceScope.ts index b75ec5e28..9be85a2a7 100644 --- a/src/a365/scopes/InferenceScope.ts +++ b/src/a365/scopes/InferenceScope.ts @@ -4,6 +4,7 @@ import { SpanKind } from "@opentelemetry/api"; import { OpenTelemetryScope } from "./OpenTelemetryScope.js"; import { OpenTelemetryConstants } from "../constants.js"; +import { InferenceOperationType } from "../contracts.js"; import type { InferenceDetails, AgentDetails, @@ -50,10 +51,11 @@ export class InferenceScope extends OpenTelemetryScope { // spanKind for InferenceScope is always CLIENT const resolvedSpanDetails: SpanDetails = { ...spanDetails, spanKind: SpanKind.CLIENT }; + const operationName = getOperationName(details.operationName); super( - details.operationName.toString(), - `${details.operationName} ${details.model}`, + operationName, + `${operationName} ${details.model}`, agentDetails, resolvedSpanDetails, userDetails, @@ -62,9 +64,18 @@ export class InferenceScope extends OpenTelemetryScope { // Core inference information this.setTagMaybe(OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY, details.model); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY, details.responseModel); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, details.providerName); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY, details.inputTokens); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY, details.outputTokens); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, + details.cacheReadInputTokens, + ); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, + details.cacheWriteInputTokens, + ); this.setTagMaybe( OpenTelemetryConstants.GEN_AI_RESPONSE_FINISH_REASONS_KEY, details.finishReasons, @@ -81,6 +92,19 @@ export class InferenceScope extends OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.SERVER_PORT_KEY, details.endpoint.port); } } + + function getOperationName(operationName: InferenceDetails["operationName"]): string { + switch (operationName) { + case InferenceOperationType.CHAT: + return "chat"; + case InferenceOperationType.TEXT_COMPLETION: + return "text_completion"; + case InferenceOperationType.GENERATE_CONTENT: + return "generate_content"; + default: + return operationName; + } + } } /** Records the number of input tokens. */ @@ -93,6 +117,27 @@ export class InferenceScope extends OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY, outputTokens); } + /** Records the canonical model name returned by the provider. */ + public recordResponseModel(responseModel: string): void { + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY, responseModel); + } + + /** Records the number of input tokens read from cache. */ + public recordCacheReadInputTokens(cacheReadInputTokens: number): void { + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, + cacheReadInputTokens, + ); + } + + /** Records the number of input tokens written to cache. */ + public recordCacheWriteInputTokens(cacheWriteInputTokens: number): void { + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, + cacheWriteInputTokens, + ); + } + /** Records the finish reasons. */ public recordFinishReasons(finishReasons: string[]): void { if (finishReasons && finishReasons.length > 0) { diff --git a/src/genai/semconv.ts b/src/genai/semconv.ts index 0936a3469..e551d65dc 100644 --- a/src/genai/semconv.ts +++ b/src/genai/semconv.ts @@ -17,6 +17,8 @@ export const GEN_AI_OPERATION_EXECUTE_TOOL = "execute_tool" as const; export const GEN_AI_OPERATION_OUTPUT_MESSAGES = "output_messages" as const; export const GEN_AI_OPERATION_APPLY_GUARDRAIL = "apply_guardrail" as const; export const GEN_AI_OPERATION_CHAT = "chat" as const; +export const GEN_AI_OPERATION_TEXT_COMPLETION = "text_completion" as const; +export const GEN_AI_OPERATION_GENERATE_CONTENT = "generate_content" as const; // --- Attributes (ATTR_ prefix, following OTel convention) --- @@ -49,6 +51,10 @@ export const ATTR_GEN_AI_OUTPUT_MESSAGES = "gen_ai.output.messages" as const; // GenAI usage export const ATTR_GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens" as const; export const ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens" as const; +export const ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS = + "gen_ai.usage.cache_read.input_tokens" as const; +export const ATTR_GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS = + "gen_ai.usage.cache_write.input_tokens" as const; // GenAI agent export const ATTR_GEN_AI_AGENT_ID = "gen_ai.agent.id" as const; diff --git a/test/internal/unit/a365/agent365Exporter.test.ts b/test/internal/unit/a365/agent365Exporter.test.ts index 1712e74bb..e3c5563ac 100644 --- a/test/internal/unit/a365/agent365Exporter.test.ts +++ b/test/internal/unit/a365/agent365Exporter.test.ts @@ -1806,6 +1806,22 @@ describe("Exporter utils", () => { assert.strictEqual(groups.get("t2:a2")?.length, 1); }); + it("should retain inference spans with canonical multiword operation names", () => { + const spans = ["text_completion", "generate_content"].map((operationName) => + makeSpan({ + attributes: { + "microsoft.tenant.id": "t1", + "gen_ai.agent.id": "a1", + "gen_ai.operation.name": operationName, + }, + }), + ); + + const groups = partitionByIdentity(spans); + + assert.strictEqual(groups.get("t1:a1")?.length, 2); + }); + it("should skip spans without identity attributes", () => { const spans = [ makeSpan({ attributes: { "gen_ai.operation.name": "invoke_agent" } }), diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts index 9b0ccd4da..a68392b26 100644 --- a/test/internal/unit/a365/scopes.test.ts +++ b/test/internal/unit/a365/scopes.test.ts @@ -36,6 +36,7 @@ import type { } from "../../../../src/a365/index.js"; import { InferenceOperationType, MessageRole } from "../../../../src/a365/index.js"; import { safeSerializeToJson } from "../../../../src/a365/message-utils.js"; +import { partitionByIdentity } from "../../../../src/a365/exporter/utils.js"; let sharedExporter: InMemorySpanExporter; let contextManager: AsyncLocalStorageContextManager; @@ -751,6 +752,9 @@ describe("Scopes", () => { providerName: "openai", inputTokens: 100, outputTokens: 150, + responseModel: "gpt-4-2024-11-20", + cacheReadInputTokens: 12, + cacheWriteInputTokens: 7, finishReasons: ["stop"], }; @@ -783,6 +787,22 @@ describe("Scopes", () => { expect(keySet).toContain("user.name"); expect(keySet).toContain("user.email"); expect(keySet).toContain("client.address"); + expect(calls).toEqual( + expect.arrayContaining([ + { + key: OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY, + val: "gpt-4-2024-11-20", + }, + { + key: OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, + val: 12, + }, + { + key: OpenTelemetryConstants.GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, + val: 7, + }, + ]), + ); scope?.dispose(); spy.mockRestore(); }); @@ -834,9 +854,49 @@ describe("Scopes", () => { expect(() => scope?.recordOutputMessages(["Generated response"])).not.toThrow(); expect(() => scope?.recordInputTokens(50)).not.toThrow(); expect(() => scope?.recordOutputTokens(100)).not.toThrow(); + expect(() => scope?.recordResponseModel("gpt-4-2024-11-20")).not.toThrow(); + expect(() => scope?.recordCacheReadInputTokens(12)).not.toThrow(); + expect(() => scope?.recordCacheWriteInputTokens(7)).not.toThrow(); expect(() => scope?.recordFinishReasons(["stop", "length"])).not.toThrow(); scope?.dispose(); - }); + + const spans = sharedExporter.getFinishedSpans(); + const attributes = spans[spans.length - 1].attributes; + expect(attributes[OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY]).toBe("gpt-4-2024-11-20"); + expect(attributes[OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY]).toBe(12); + expect(attributes[OpenTelemetryConstants.GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY]).toBe(7); + }); + + it.each<[InferenceDetails["operationName"], string]>([ + [InferenceOperationType.CHAT, "chat"], + [InferenceOperationType.TEXT_COMPLETION, "text_completion"], + [InferenceOperationType.GENERATE_CONTENT, "generate_content"], + ["chat", "chat"], + ["text_completion", "text_completion"], + ["generate_content", "generate_content"], + ])( + "should normalize %s to %s and retain the inference span for export", + (operationName, expected) => { + const spanCountBefore = sharedExporter.getFinishedSpans().length; + const scope = InferenceScope.start( + testRequest, + { operationName, model: "gpt-4" }, + testAgentDetails, + ); + scope.dispose(); + + const spans = sharedExporter.getFinishedSpans(); + expect(spans).toHaveLength(spanCountBefore + 1); + const span = spans[spans.length - 1]; + expect(span.attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe(expected); + expect(span.name).toBe(`${expected} gpt-4`); + const groups = partitionByIdentity([span]); + expect(groups.size).toBe(1); + expect(groups.get(`${testAgentDetails.tenantId}:${testAgentDetails.agentId}`)).toEqual([ + span, + ]); + }, + ); it("should set conversationId and channel tags when provided", () => { const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe");