Skip to content
Merged
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
8 changes: 7 additions & 1 deletion A365_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/a365/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
Comment thread
biswapm marked this conversation as resolved.
Resolved
/** Finish reasons returned by the model. */
finishReasons?: string[];
/** The model's thought process / reasoning trace. */
Expand Down
10 changes: 7 additions & 3 deletions src/a365/exporter/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set([
Expand All @@ -36,9 +38,11 @@ export const GEN_AI_OPERATION_NAMES: ReadonlySet<string> = 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'
Comment thread
biswapm marked this conversation as resolved.
Resolved
"Chat", // Legacy InferenceOperationType.CHAT value
"TextCompletion", // Legacy InferenceOperationType.TEXT_COMPLETION value
"GenerateContent", // Legacy InferenceOperationType.GENERATE_CONTENT value
]);

/**
Expand Down
49 changes: 47 additions & 2 deletions src/a365/scopes/InferenceScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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. */
Expand All @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions src/genai/semconv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---

Expand Down Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions test/internal/unit/a365/agent365Exporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }),
Expand Down
62 changes: 61 additions & 1 deletion test/internal/unit/a365/scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -751,6 +752,9 @@ describe("Scopes", () => {
providerName: "openai",
inputTokens: 100,
outputTokens: 150,
responseModel: "gpt-4-2024-11-20",
cacheReadInputTokens: 12,
cacheWriteInputTokens: 7,
finishReasons: ["stop"],
};

Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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");
Expand Down
Loading