Skip to content
Draft
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
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
- Emit `gen_ai.output.type` and available GenAI request parameters on LangChain chat spans. [#213](https://github.com/microsoft/opentelemetry-distro-javascript/pull/213)

### Bugs Fixed
- Fix duplicate Bunyan logs, missing HTTP duration metrics, duplicate request filtering, and incorrect performance-counter values. [#212](https://github.com/microsoft/opentelemetry-distro-javascript/pull/212)

Expand Down
1 change: 1 addition & 0 deletions src/genai/instrumentations/langchain/tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ export class LangChainTracer extends BaseTracer {
}
Utils.setModelAttribute(run, span);
Utils.setChoiceCountAttribute(run, span);
Utils.setRequestAttributes(run, span);
Utils.setResponseIdAttribute(run, span);
Utils.setFinishReasonsAttribute(run, span);
Utils.setProviderNameAttribute(run, span);
Expand Down
129 changes: 129 additions & 0 deletions src/genai/instrumentations/langchain/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,20 @@ import {
ATTR_GEN_AI_CONVERSATION_ID,
ATTR_GEN_AI_INPUT_MESSAGES,
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_OUTPUT_TYPE,
ATTR_GEN_AI_OUTPUT_MESSAGES,
ATTR_GEN_AI_PROVIDER_NAME,
ATTR_GEN_AI_REQUEST_CHOICE_COUNT,
ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY,
ATTR_GEN_AI_REQUEST_MAX_TOKENS,
ATTR_GEN_AI_REQUEST_MODEL,
ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY,
ATTR_GEN_AI_REQUEST_SEED,
ATTR_GEN_AI_REQUEST_STOP_SEQUENCES,
ATTR_GEN_AI_REQUEST_STREAM,
ATTR_GEN_AI_REQUEST_TEMPERATURE,
ATTR_GEN_AI_REQUEST_TOP_K,
ATTR_GEN_AI_REQUEST_TOP_P,
ATTR_GEN_AI_RESPONSE_ID,
ATTR_GEN_AI_RESPONSE_FINISH_REASONS,
ATTR_GEN_AI_RESPONSE_MODEL,
Expand Down Expand Up @@ -566,6 +576,125 @@ export function setChoiceCountAttribute(run: Run, span: Span) {
}
}

// LangChain exposes provider-specific invocation params as unknown values. Match the upstream
// OpenTelemetry GenAI instrumentations by accepting typed SDK values without coercing strings.
function firstDefined(params: Record<string, unknown>, keys: string[]): unknown {
for (const key of keys) {
if (params[key] !== undefined && params[key] !== null) {
return params[key];
}
}
return undefined;
}

function integer(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value)
? value
: undefined;
}

function stringArray(value: unknown): string[] | undefined {
if (isString(value) && value.length > 0) return [value];
if (!Array.isArray(value)) return undefined;
const strings = value.filter((item): item is string => isString(item) && item.length > 0);
return strings.length === value.length && strings.length > 0 ? strings : undefined;
}
Comment thread
JacksonWeber marked this conversation as resolved.

const OUTPUT_TYPES: readonly string[] = ["text", "json", "image", "speech"];

const OUTPUT_TYPE_ALIASES: Readonly<Record<string, string>> = {
json_object: "json",
json_schema: "json",
b64_json: "image",
url: "image",
audio: "speech",
};

function normalizeOutputType(value: unknown): string | undefined {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are none of these helper functions available upstream which we could reuse here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LangChain doesn’t provide public helpers for normalizing these provider-specific invocation parameters and OpenTelemetry only validates values after they’ve been normalized, so we still need the small local coercion helpers here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So how does opentelemetry (genai js) normalize the values? When I mentioned upstream in my original comment, I was referring to the upstream langchain instrumentation, sorry for the confusion.

@JacksonWeber Jackson Weber (JacksonWeber) Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. The upstream otel langchain instrumentation doesn't currently emit these attributes, so it doesn't have any normalization logic for them.

Makes sense that we can try to implement these upstream there instead of only in our vendored implementation. But we don't consume that package anywhere in this project, so is the expectation that customers just pull the upstream OTel JS langchain instrumentation to use alongside this distro?

if (!isString(value)) return undefined;
const normalized = value.trim().toLowerCase();
return OUTPUT_TYPES.includes(normalized) ? normalized : OUTPUT_TYPE_ALIASES[normalized];
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Comment thread
JacksonWeber marked this conversation as resolved.

function getOutputType(params: Record<string, unknown>): string | undefined {
const explicit = firstDefined(params, ["output_type", "outputType"]);
const explicitType = normalizeOutputType(explicit);
if (explicitType) return explicitType;

const responseFormat = firstDefined(params, ["response_format", "responseFormat"]);
if (isRecord(responseFormat)) {
const formatType = normalizeOutputType(responseFormat.type);
if (formatType) return formatType;
}
const responseFormatType = normalizeOutputType(responseFormat);
if (responseFormatType) return responseFormatType;

const text = params.text;
if (isRecord(text)) {
const format = text.format;
if (isRecord(format)) {
return normalizeOutputType(format.type);
}
}
return undefined;
}

// Request attributes surfaced by LangChain under extra.invocation_params.
// Both OpenAI-style snake_case and LangChain/provider camelCase aliases are
// accepted because callback payloads vary by model integration and API path.
export function setRequestAttributes(run: Run, span: Span): void {
const params = run.extra?.invocation_params;
if (!isRecord(params)) return;

const outputType = getOutputType(params);
if (outputType) span.setAttribute(ATTR_GEN_AI_OUTPUT_TYPE, outputType);

const numberAttributes: Array<[string, string[]]> = [
[ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY, ["frequency_penalty", "frequencyPenalty"]],
[ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY, ["presence_penalty", "presencePenalty"]],
[ATTR_GEN_AI_REQUEST_TEMPERATURE, ["temperature"]],
[ATTR_GEN_AI_REQUEST_TOP_P, ["top_p", "topP"]],
];
for (const [attribute, keys] of numberAttributes) {
const value = firstDefined(params, keys);
if (typeof value === "number" && Number.isFinite(value)) {
span.setAttribute(attribute, value);
}
}

const maxTokens = integer(
firstDefined(params, [
"max_tokens",
"maxTokens",
"max_completion_tokens",
"maxCompletionTokens",
"max_output_tokens",
"maxOutputTokens",
]),
);
if (maxTokens !== undefined) {
span.setAttribute(ATTR_GEN_AI_REQUEST_MAX_TOKENS, maxTokens);
}

const seed = integer(firstDefined(params, ["seed"]));
if (seed !== undefined) span.setAttribute(ATTR_GEN_AI_REQUEST_SEED, seed);

const topK = integer(firstDefined(params, ["top_k", "topK"]));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A little confused why are we supporting the camelCase, shouldn't the attributes satisfy the _ convention?

if (topK !== undefined) span.setAttribute(ATTR_GEN_AI_REQUEST_TOP_K, topK);

const stopSequences = stringArray(
firstDefined(params, ["stop", "stop_sequences", "stopSequences"]),
);
if (stopSequences) span.setAttribute(ATTR_GEN_AI_REQUEST_STOP_SEQUENCES, stopSequences);

const stream = firstDefined(params, ["stream", "streaming"]);
if (typeof stream === "boolean") span.setAttribute(ATTR_GEN_AI_REQUEST_STREAM, stream);
}

// Response identifier - Helper to extract the unique response id returned by
// the underlying provider (e.g. OpenAI chat completion id). LangChain.js
// typically surfaces this as the AIMessage id (top-level for v1, nested
Expand Down
10 changes: 10 additions & 0 deletions src/genai/semconv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,18 @@ export const ATTR_ERROR_MESSAGE = "error.message" as const;

// GenAI core
export const ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name" as const;
export const ATTR_GEN_AI_OUTPUT_TYPE = "gen_ai.output.type" as const;
export const ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model" as const;
export const ATTR_GEN_AI_REQUEST_CHOICE_COUNT = "gen_ai.request.choice.count" as const;
export const ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty" as const;
export const ATTR_GEN_AI_REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens" as const;
export const ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY = "gen_ai.request.presence_penalty" as const;
export const ATTR_GEN_AI_REQUEST_SEED = "gen_ai.request.seed" as const;
export const ATTR_GEN_AI_REQUEST_STOP_SEQUENCES = "gen_ai.request.stop_sequences" as const;
export const ATTR_GEN_AI_REQUEST_STREAM = "gen_ai.request.stream" as const;
export const ATTR_GEN_AI_REQUEST_TEMPERATURE = "gen_ai.request.temperature" as const;
export const ATTR_GEN_AI_REQUEST_TOP_K = "gen_ai.request.top_k" as const;
export const ATTR_GEN_AI_REQUEST_TOP_P = "gen_ai.request.top_p" as const;
export const ATTR_GEN_AI_RESPONSE_MODEL = "gen_ai.response.model" as const;
export const ATTR_GEN_AI_RESPONSE_ID = "gen_ai.response.id" as const;
export const ATTR_GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons" as const;
Expand Down
29 changes: 29 additions & 0 deletions test/internal/unit/a365/agent365Exporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,35 @@ describe("Agent365Exporter", () => {
assert.deepStrictEqual(exportedSpan.attributes["number_array_attr"], [1, 2, 3]);
});

it("should preserve GenAI request attributes in the A365 payload", async () => {
const { exportedSpan } = await exportAndGetPayload(fetchSpy, {
"gen_ai.output.type": "json",
"gen_ai.request.frequency_penalty": 0.1,
"gen_ai.request.max_tokens": 512,
"gen_ai.request.presence_penalty": -0.2,
"gen_ai.request.seed": 42,
"gen_ai.request.stop_sequences": ["DONE", "STOP"],
"gen_ai.request.stream": false,
"gen_ai.request.temperature": 0.2,
"gen_ai.request.top_k": 40,
"gen_ai.request.top_p": 0.8,
});

assert.strictEqual(exportedSpan.attributes["gen_ai.output.type"], "json");
assert.strictEqual(exportedSpan.attributes["gen_ai.request.frequency_penalty"], 0.1);
assert.strictEqual(exportedSpan.attributes["gen_ai.request.max_tokens"], 512);
assert.strictEqual(exportedSpan.attributes["gen_ai.request.presence_penalty"], -0.2);
assert.strictEqual(exportedSpan.attributes["gen_ai.request.seed"], 42);
assert.deepStrictEqual(exportedSpan.attributes["gen_ai.request.stop_sequences"], [
"DONE",
"STOP",
]);
assert.strictEqual(exportedSpan.attributes["gen_ai.request.stream"], false);
assert.strictEqual(exportedSpan.attributes["gen_ai.request.temperature"], 0.2);
assert.strictEqual(exportedSpan.attributes["gen_ai.request.top_k"], 40);
assert.strictEqual(exportedSpan.attributes["gen_ai.request.top_p"], 0.8);
});

it("should partition spans by identity and export separately", async () => {
const exporter = new Agent365Exporter({
tokenResolver: () => "test-token",
Expand Down
14 changes: 13 additions & 1 deletion test/internal/unit/genai/langchain/tracer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ import {
ATTR_ERROR_MESSAGE,
ATTR_GEN_AI_INPUT_MESSAGES,
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_OUTPUT_TYPE,
ATTR_GEN_AI_OUTPUT_MESSAGES,
ATTR_GEN_AI_PROVIDER_NAME,
ATTR_GEN_AI_REQUEST_CHOICE_COUNT,
ATTR_GEN_AI_REQUEST_MODEL,
ATTR_GEN_AI_REQUEST_TEMPERATURE,
ATTR_GEN_AI_REQUEST_TOP_P,
ATTR_GEN_AI_RESPONSE_ID,
ATTR_GEN_AI_RESPONSE_MODEL,
ATTR_GEN_AI_TOOL_CALL_ARGUMENTS,
Expand Down Expand Up @@ -426,7 +429,13 @@ describe("LangChainTracer", () => {
},
extra: {
metadata: { ls_model_name: "deployment-o4-mini", ls_provider: "openai" },
invocation_params: { model: "deployment-o4-mini", n: 3 },
invocation_params: {
model: "deployment-o4-mini",
n: 3,
temperature: 0.2,
top_p: 0.8,
response_format: { type: "json_object" },
},
},
inputs: {
messages: [[{ role: "user", content: "hello" }]],
Expand Down Expand Up @@ -481,6 +490,9 @@ describe("LangChainTracer", () => {
3,
"request choice count should come from invocation_params.n when >1",
);
assert.strictEqual(got(ATTR_GEN_AI_REQUEST_TEMPERATURE), 0.2, "request temperature");
assert.strictEqual(got(ATTR_GEN_AI_REQUEST_TOP_P), 0.8, "request top_p");
assert.strictEqual(got(ATTR_GEN_AI_OUTPUT_TYPE), "json", "requested output type");
assert.strictEqual(
got(ATTR_GEN_AI_RESPONSE_MODEL),
"o4-mini-2025-04-16",
Expand Down
Loading
Loading