From 02f1d1483a3e28434cfd50af35cca214356068fb Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 6 Aug 2026 10:38:07 -0700 Subject: [PATCH 1/9] feat(langchain): emit GenAI request attributes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 + .../instrumentations/langchain/tracer.ts | 1 + src/genai/instrumentations/langchain/utils.ts | 139 ++++++++++++++++++ src/genai/semconv.ts | 10 ++ .../unit/genai/langchain/tracer.test.ts | 14 +- .../unit/genai/langchain/utils.test.ts | 114 ++++++++++++++ 6 files changed, 280 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 938a017..d3c26e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Features Added +- Emit `gen_ai.output.type` and available GenAI request parameters on LangChain chat spans. + ### 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) diff --git a/src/genai/instrumentations/langchain/tracer.ts b/src/genai/instrumentations/langchain/tracer.ts index 421eab6..68ee8c1 100644 --- a/src/genai/instrumentations/langchain/tracer.ts +++ b/src/genai/instrumentations/langchain/tracer.ts @@ -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); diff --git a/src/genai/instrumentations/langchain/utils.ts b/src/genai/instrumentations/langchain/utils.ts index 79fa804..8f1702e 100644 --- a/src/genai/instrumentations/langchain/utils.ts +++ b/src/genai/instrumentations/langchain/utils.ts @@ -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, @@ -566,6 +576,135 @@ export function setChoiceCountAttribute(run: Run, span: Span) { } } +function firstDefined(params: Record, keys: string[]): unknown { + for (const key of keys) { + if (params[key] !== undefined && params[key] !== null) { + return params[key]; + } + } + return undefined; +} + +function finiteNumber(value: unknown): number | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? value : undefined; + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function integer(value: unknown): number | undefined { + const parsed = finiteNumber(value); + return parsed !== undefined && Number.isInteger(parsed) ? parsed : undefined; +} + +function parseBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (value === "true") return true; + if (value === "false") return false; + return 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; +} + +function normalizeOutputType(value: unknown): string | undefined { + if (!isString(value)) return undefined; + const normalized = value.trim().toLowerCase(); + const outputTypes: Record = { + text: "text", + json: "json", + json_object: "json", + json_schema: "json", + image: "image", + b64_json: "image", + url: "image", + audio: "speech", + speech: "speech", + }; + return outputTypes[normalized]; +} + +function getOutputType(params: Record): 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 (responseFormat && typeof responseFormat === "object" && !Array.isArray(responseFormat)) { + const formatType = normalizeOutputType((responseFormat as Record).type); + if (formatType) return formatType; + } + const responseFormatType = normalizeOutputType(responseFormat); + if (responseFormatType) return responseFormatType; + + const text = params.text; + if (text && typeof text === "object" && !Array.isArray(text)) { + const format = (text as Record).format; + if (format && typeof format === "object" && !Array.isArray(format)) { + return normalizeOutputType((format as Record).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 as Record | undefined; + if (!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 = finiteNumber(firstDefined(params, keys)); + if (value !== undefined) 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"])); + 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 = parseBoolean(firstDefined(params, ["stream", "streaming"])); + if (stream !== undefined) 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 diff --git a/src/genai/semconv.ts b/src/genai/semconv.ts index dc7c4d0..0936a34 100644 --- a/src/genai/semconv.ts +++ b/src/genai/semconv.ts @@ -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; diff --git a/test/internal/unit/genai/langchain/tracer.test.ts b/test/internal/unit/genai/langchain/tracer.test.ts index 17065f1..754ba09 100644 --- a/test/internal/unit/genai/langchain/tracer.test.ts +++ b/test/internal/unit/genai/langchain/tracer.test.ts @@ -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, @@ -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" }]], @@ -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", diff --git a/test/internal/unit/genai/langchain/utils.test.ts b/test/internal/unit/genai/langchain/utils.test.ts index a5909a6..65ae2ef 100644 --- a/test/internal/unit/genai/langchain/utils.test.ts +++ b/test/internal/unit/genai/langchain/utils.test.ts @@ -13,6 +13,7 @@ import { setOutputMessagesAttribute, setModelAttribute, setChoiceCountAttribute, + setRequestAttributes, setProviderNameAttribute, setResponseIdAttribute, setFinishReasonsAttribute, @@ -25,10 +26,20 @@ import { ATTR_GEN_AI_AGENT_NAME, 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, @@ -801,6 +812,109 @@ describe("setChoiceCountAttribute", () => { }); }); +describe("setRequestAttributes", () => { + it("sets GenAI request parameters from OpenAI-style invocation params", () => { + const span = makeSpan(); + const run = makeRun({ + extra: { + invocation_params: { + temperature: 0.2, + top_p: 0.8, + top_k: 40, + max_completion_tokens: 512, + frequency_penalty: 0.1, + presence_penalty: -0.2, + seed: 42, + stop: ["DONE", "STOP"], + stream: true, + response_format: { type: "json_schema" }, + }, + }, + }); + + setRequestAttributes(run, span); + + assert.deepStrictEqual(span.attrs, { + [ATTR_GEN_AI_OUTPUT_TYPE]: "json", + [ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY]: 0.1, + [ATTR_GEN_AI_REQUEST_MAX_TOKENS]: 512, + [ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY]: -0.2, + [ATTR_GEN_AI_REQUEST_TEMPERATURE]: 0.2, + [ATTR_GEN_AI_REQUEST_TOP_K]: 40, + [ATTR_GEN_AI_REQUEST_TOP_P]: 0.8, + [ATTR_GEN_AI_REQUEST_SEED]: 42, + [ATTR_GEN_AI_REQUEST_STOP_SEQUENCES]: ["DONE", "STOP"], + [ATTR_GEN_AI_REQUEST_STREAM]: true, + }); + }); + + it("supports camelCase aliases and normalizes a single stop sequence", () => { + const span = makeSpan(); + const run = makeRun({ + extra: { + invocation_params: { + maxOutputTokens: "256", + frequencyPenalty: "0.25", + presencePenalty: 0, + topP: "0.9", + topK: 10, + stopSequences: "END", + streaming: false, + responseFormat: "text", + }, + }, + }); + + setRequestAttributes(run, span); + + assert.strictEqual(span.attrs[ATTR_GEN_AI_OUTPUT_TYPE], "text"); + assert.strictEqual(span.attrs[ATTR_GEN_AI_REQUEST_MAX_TOKENS], 256); + assert.strictEqual(span.attrs[ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY], 0.25); + assert.strictEqual(span.attrs[ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY], 0); + assert.strictEqual(span.attrs[ATTR_GEN_AI_REQUEST_TOP_P], 0.9); + assert.strictEqual(span.attrs[ATTR_GEN_AI_REQUEST_TOP_K], 10); + assert.deepStrictEqual(span.attrs[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES], ["END"]); + assert.strictEqual(span.attrs[ATTR_GEN_AI_REQUEST_STREAM], false); + }); + + it("reads Responses API output type from text.format", () => { + const span = makeSpan(); + const run = makeRun({ + extra: { + invocation_params: { + text: { format: { type: "json_schema" } }, + }, + }, + }); + + setRequestAttributes(run, span); + + assert.strictEqual(span.attrs[ATTR_GEN_AI_OUTPUT_TYPE], "json"); + }); + + it("ignores absent or invalid request parameters", () => { + const span = makeSpan(); + const run = makeRun({ + extra: { + invocation_params: { + temperature: Number.NaN, + top_p: "not-a-number", + top_k: 1.5, + max_tokens: 1.5, + seed: {}, + stop: ["valid", 1], + stream: "yes", + response_format: { type: "unsupported" }, + }, + }, + }); + + setRequestAttributes(run, span); + + assert.deepStrictEqual(span.attrs, {}); + }); +}); + describe("setResponseIdAttribute", () => { it("extracts response id from AIMessage.id (v1)", () => { const span = makeSpan(); From 101be168ccf56cc9fd1ee2e332e71f0b5ce1bcd8 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 6 Aug 2026 10:56:57 -0700 Subject: [PATCH 2/9] test(a365): cover GenAI request attributes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../unit/a365/agent365Exporter.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/internal/unit/a365/agent365Exporter.test.ts b/test/internal/unit/a365/agent365Exporter.test.ts index 5c92c0d..dbbe0a6 100644 --- a/test/internal/unit/a365/agent365Exporter.test.ts +++ b/test/internal/unit/a365/agent365Exporter.test.ts @@ -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", From 14af86ad2b48a48a69492769a539699dea5a6844 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 6 Aug 2026 14:14:43 -0700 Subject: [PATCH 3/9] fix(langchain): harden request attribute parsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/genai/instrumentations/langchain/utils.ts | 55 +++++++++++-------- .../unit/genai/langchain/utils.test.ts | 24 +++++++- 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/src/genai/instrumentations/langchain/utils.ts b/src/genai/instrumentations/langchain/utils.ts index 8f1702e..3e6a212 100644 --- a/src/genai/instrumentations/langchain/utils.ts +++ b/src/genai/instrumentations/langchain/utils.ts @@ -603,33 +603,40 @@ function integer(value: unknown): number | undefined { function parseBoolean(value: unknown): boolean | undefined { if (typeof value === "boolean") return value; - if (value === "true") return true; - if (value === "false") return false; + if (!isString(value)) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; return undefined; } function stringArray(value: unknown): string[] | undefined { - if (isString(value) && value.length > 0) return [value]; + if (isString(value) && value.trim().length > 0) return [value]; if (!Array.isArray(value)) return undefined; - const strings = value.filter((item): item is string => isString(item) && item.length > 0); + const strings = value.filter((item): item is string => isString(item) && item.trim().length > 0); return strings.length === value.length && strings.length > 0 ? strings : undefined; } +const OUTPUT_TYPES: Readonly> = { + text: "text", + json: "json", + json_object: "json", + json_schema: "json", + image: "image", + b64_json: "image", + url: "image", + audio: "speech", + speech: "speech", +}; + function normalizeOutputType(value: unknown): string | undefined { if (!isString(value)) return undefined; const normalized = value.trim().toLowerCase(); - const outputTypes: Record = { - text: "text", - json: "json", - json_object: "json", - json_schema: "json", - image: "image", - b64_json: "image", - url: "image", - audio: "speech", - speech: "speech", - }; - return outputTypes[normalized]; + return OUTPUT_TYPES[normalized]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } function getOutputType(params: Record): string | undefined { @@ -638,18 +645,18 @@ function getOutputType(params: Record): string | undefined { if (explicitType) return explicitType; const responseFormat = firstDefined(params, ["response_format", "responseFormat"]); - if (responseFormat && typeof responseFormat === "object" && !Array.isArray(responseFormat)) { - const formatType = normalizeOutputType((responseFormat as Record).type); + 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 (text && typeof text === "object" && !Array.isArray(text)) { - const format = (text as Record).format; - if (format && typeof format === "object" && !Array.isArray(format)) { - return normalizeOutputType((format as Record).type); + if (isRecord(text)) { + const format = text.format; + if (isRecord(format)) { + return normalizeOutputType(format.type); } } return undefined; @@ -659,8 +666,8 @@ function getOutputType(params: Record): string | undefined { // 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 as Record | undefined; - if (!params) return; + const params = run.extra?.invocation_params; + if (!isRecord(params)) return; const outputType = getOutputType(params); if (outputType) span.setAttribute(ATTR_GEN_AI_OUTPUT_TYPE, outputType); diff --git a/test/internal/unit/genai/langchain/utils.test.ts b/test/internal/unit/genai/langchain/utils.test.ts index 65ae2ef..bd3de27 100644 --- a/test/internal/unit/genai/langchain/utils.test.ts +++ b/test/internal/unit/genai/langchain/utils.test.ts @@ -859,7 +859,7 @@ describe("setRequestAttributes", () => { topP: "0.9", topK: 10, stopSequences: "END", - streaming: false, + streaming: " FALSE ", responseFormat: "text", }, }, @@ -877,6 +877,28 @@ describe("setRequestAttributes", () => { assert.strictEqual(span.attrs[ATTR_GEN_AI_REQUEST_STREAM], false); }); + it("rejects whitespace-only stop sequences", () => { + for (const stop of [" ", ["DONE", "\t"]]) { + const span = makeSpan(); + const run = makeRun({ extra: { invocation_params: { stop } } }); + + setRequestAttributes(run, span); + + assert.deepStrictEqual(span.attrs, {}); + } + }); + + it("ignores invocation params that are not objects", () => { + for (const invocation_params of [null, "invalid", 1, true, []]) { + const span = makeSpan(); + const run = makeRun({ extra: { invocation_params } }); + + setRequestAttributes(run, span); + + assert.deepStrictEqual(span.attrs, {}); + } + }); + it("reads Responses API output type from text.format", () => { const span = makeSpan(); const run = makeRun({ From 808ab3440918fe52b18aca8140d6bf0d6997341e Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Fri, 7 Aug 2026 10:39:32 -0700 Subject: [PATCH 4/9] Add PR link to changelog Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c26e6..a31d132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Features Added -- Emit `gen_ai.output.type` and available GenAI request parameters on LangChain chat spans. +- 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) From 092538e36dcbb1264857a297c07042eb5bc0645b Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Fri, 7 Aug 2026 12:58:17 -0700 Subject: [PATCH 5/9] fix(langchain): address request attribute review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/genai/instrumentations/langchain/utils.ts | 16 +++---- .../unit/genai/langchain/utils.test.ts | 43 ++++++++++++++++++- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/genai/instrumentations/langchain/utils.ts b/src/genai/instrumentations/langchain/utils.ts index 3e6a212..586d2ef 100644 --- a/src/genai/instrumentations/langchain/utils.ts +++ b/src/genai/instrumentations/langchain/utils.ts @@ -576,6 +576,8 @@ export function setChoiceCountAttribute(run: Run, span: Span) { } } +// LangChain exposes provider-specific invocation params as unknown values. OpenTelemetry only +// validates already-normalized attribute values, so aliases and coercion are handled at this boundary. function firstDefined(params: Record, keys: string[]): unknown { for (const key of keys) { if (params[key] !== undefined && params[key] !== null) { @@ -611,28 +613,26 @@ function parseBoolean(value: unknown): boolean | undefined { } function stringArray(value: unknown): string[] | undefined { - if (isString(value) && value.trim().length > 0) return [value]; + 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.trim().length > 0); + const strings = value.filter((item): item is string => isString(item) && item.length > 0); return strings.length === value.length && strings.length > 0 ? strings : undefined; } -const OUTPUT_TYPES: Readonly> = { - text: "text", - json: "json", +const OUTPUT_TYPES: readonly string[] = ["text", "json", "image", "speech"]; + +const OUTPUT_TYPE_ALIASES: Readonly> = { json_object: "json", json_schema: "json", - image: "image", b64_json: "image", url: "image", audio: "speech", - speech: "speech", }; function normalizeOutputType(value: unknown): string | undefined { if (!isString(value)) return undefined; const normalized = value.trim().toLowerCase(); - return OUTPUT_TYPES[normalized]; + return OUTPUT_TYPES.includes(normalized) ? normalized : OUTPUT_TYPE_ALIASES[normalized]; } function isRecord(value: unknown): value is Record { diff --git a/test/internal/unit/genai/langchain/utils.test.ts b/test/internal/unit/genai/langchain/utils.test.ts index bd3de27..2cba711 100644 --- a/test/internal/unit/genai/langchain/utils.test.ts +++ b/test/internal/unit/genai/langchain/utils.test.ts @@ -877,8 +877,22 @@ describe("setRequestAttributes", () => { assert.strictEqual(span.attrs[ATTR_GEN_AI_REQUEST_STREAM], false); }); - it("rejects whitespace-only stop sequences", () => { - for (const stop of [" ", ["DONE", "\t"]]) { + it("preserves whitespace stop sequences", () => { + for (const stop of ["\n", ["STOP", "\n\n"]]) { + const span = makeSpan(); + const run = makeRun({ extra: { invocation_params: { stop } } }); + + setRequestAttributes(run, span); + + assert.deepStrictEqual( + span.attrs[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES], + Array.isArray(stop) ? stop : [stop], + ); + } + }); + + it("rejects empty stop sequences", () => { + for (const stop of ["", ["DONE", ""]]) { const span = makeSpan(); const run = makeRun({ extra: { invocation_params: { stop } } }); @@ -914,6 +928,31 @@ describe("setRequestAttributes", () => { assert.strictEqual(span.attrs[ATTR_GEN_AI_OUTPUT_TYPE], "json"); }); + it("normalizes canonical and provider-specific output types", () => { + const outputTypes = [ + ["text", "text"], + ["json", "json"], + ["image", "image"], + ["speech", "speech"], + ["json_object", "json"], + ["json_schema", "json"], + ["b64_json", "image"], + ["url", "image"], + ["audio", "speech"], + ]; + + for (const [outputType, expected] of outputTypes) { + const span = makeSpan(); + const run = makeRun({ + extra: { invocation_params: { output_type: outputType } }, + }); + + setRequestAttributes(run, span); + + assert.strictEqual(span.attrs[ATTR_GEN_AI_OUTPUT_TYPE], expected); + } + }); + it("ignores absent or invalid request parameters", () => { const span = makeSpan(); const run = makeRun({ From 0110be12a08df7e8d7180016a7d74c4a2ade8a7c Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Sat, 8 Aug 2026 11:01:40 -0700 Subject: [PATCH 6/9] ci: test Node.js 26 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-validation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 8a7a5d3..1e7433e 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -42,7 +42,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: ["22", "24"] + node-version: ["22", "24", "26"] steps: - name: Check out repository From a52fd9b6558d2c99f9fc149205c3fe49dd2818bc Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Sat, 8 Aug 2026 11:12:42 -0700 Subject: [PATCH 7/9] Revert "ci: test Node.js 26" This reverts commit 0110be12a08df7e8d7180016a7d74c4a2ade8a7c. --- .github/workflows/pr-validation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 1e7433e..8a7a5d3 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -42,7 +42,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: ["22", "24", "26"] + node-version: ["22", "24"] steps: - name: Check out repository From ae24f29cd47c0ac26891bbfd1ffc1ff91fe7a521 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Sat, 8 Aug 2026 11:19:34 -0700 Subject: [PATCH 8/9] fix(langchain): preserve typed request values Match upstream OpenTelemetry GenAI instrumentations by accepting typed provider parameters without coercing numeric or boolean strings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/genai/instrumentations/langchain/utils.ts | 39 ++++++------------- .../unit/genai/langchain/utils.test.ts | 14 ++++--- 2 files changed, 19 insertions(+), 34 deletions(-) diff --git a/src/genai/instrumentations/langchain/utils.ts b/src/genai/instrumentations/langchain/utils.ts index 586d2ef..dc235cb 100644 --- a/src/genai/instrumentations/langchain/utils.ts +++ b/src/genai/instrumentations/langchain/utils.ts @@ -576,8 +576,8 @@ export function setChoiceCountAttribute(run: Run, span: Span) { } } -// LangChain exposes provider-specific invocation params as unknown values. OpenTelemetry only -// validates already-normalized attribute values, so aliases and coercion are handled at this boundary. +// 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, keys: string[]): unknown { for (const key of keys) { if (params[key] !== undefined && params[key] !== null) { @@ -587,29 +587,10 @@ function firstDefined(params: Record, keys: string[]): unknown return undefined; } -function finiteNumber(value: unknown): number | undefined { - if (typeof value === "number") { - return Number.isFinite(value) ? value : undefined; - } - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; - } - return undefined; -} - function integer(value: unknown): number | undefined { - const parsed = finiteNumber(value); - return parsed !== undefined && Number.isInteger(parsed) ? parsed : undefined; -} - -function parseBoolean(value: unknown): boolean | undefined { - if (typeof value === "boolean") return value; - if (!isString(value)) return undefined; - const normalized = value.trim().toLowerCase(); - if (normalized === "true") return true; - if (normalized === "false") return false; - return undefined; + return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) + ? value + : undefined; } function stringArray(value: unknown): string[] | undefined { @@ -679,8 +660,10 @@ export function setRequestAttributes(run: Run, span: Span): void { [ATTR_GEN_AI_REQUEST_TOP_P, ["top_p", "topP"]], ]; for (const [attribute, keys] of numberAttributes) { - const value = finiteNumber(firstDefined(params, keys)); - if (value !== undefined) span.setAttribute(attribute, value); + const value = firstDefined(params, keys); + if (typeof value === "number" && Number.isFinite(value)) { + span.setAttribute(attribute, value); + } } const maxTokens = integer( @@ -708,8 +691,8 @@ export function setRequestAttributes(run: Run, span: Span): void { ); if (stopSequences) span.setAttribute(ATTR_GEN_AI_REQUEST_STOP_SEQUENCES, stopSequences); - const stream = parseBoolean(firstDefined(params, ["stream", "streaming"])); - if (stream !== undefined) span.setAttribute(ATTR_GEN_AI_REQUEST_STREAM, stream); + 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 diff --git a/test/internal/unit/genai/langchain/utils.test.ts b/test/internal/unit/genai/langchain/utils.test.ts index 2cba711..e78bf2a 100644 --- a/test/internal/unit/genai/langchain/utils.test.ts +++ b/test/internal/unit/genai/langchain/utils.test.ts @@ -848,18 +848,18 @@ describe("setRequestAttributes", () => { }); }); - it("supports camelCase aliases and normalizes a single stop sequence", () => { + it("supports typed camelCase provider aliases and normalizes a single stop sequence", () => { const span = makeSpan(); const run = makeRun({ extra: { invocation_params: { - maxOutputTokens: "256", - frequencyPenalty: "0.25", + maxOutputTokens: 256, + frequencyPenalty: 0.25, presencePenalty: 0, - topP: "0.9", + topP: 0.9, topK: 10, stopSequences: "END", - streaming: " FALSE ", + streaming: false, responseFormat: "text", }, }, @@ -962,9 +962,11 @@ describe("setRequestAttributes", () => { top_p: "not-a-number", top_k: 1.5, max_tokens: 1.5, + maxOutputTokens: "256", + frequencyPenalty: "0.25", seed: {}, stop: ["valid", 1], - stream: "yes", + stream: "false", response_format: { type: "unsupported" }, }, }, From f99a287ad848524114a7b16b9e888d6bb927b91b Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Mon, 10 Aug 2026 15:01:42 -0700 Subject: [PATCH 9/9] fix(langchain): restrict GenAI output types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/genai/instrumentations/langchain/utils.ts | 3 +-- test/internal/unit/genai/langchain/utils.test.ts | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/genai/instrumentations/langchain/utils.ts b/src/genai/instrumentations/langchain/utils.ts index dc235cb..eec1c16 100644 --- a/src/genai/instrumentations/langchain/utils.ts +++ b/src/genai/instrumentations/langchain/utils.ts @@ -600,14 +600,13 @@ function stringArray(value: unknown): string[] | undefined { return strings.length === value.length && strings.length > 0 ? strings : undefined; } -const OUTPUT_TYPES: readonly string[] = ["text", "json", "image", "speech"]; +const OUTPUT_TYPES: readonly string[] = ["text", "json", "image"]; const OUTPUT_TYPE_ALIASES: Readonly> = { json_object: "json", json_schema: "json", b64_json: "image", url: "image", - audio: "speech", }; function normalizeOutputType(value: unknown): string | undefined { diff --git a/test/internal/unit/genai/langchain/utils.test.ts b/test/internal/unit/genai/langchain/utils.test.ts index e78bf2a..4b6da85 100644 --- a/test/internal/unit/genai/langchain/utils.test.ts +++ b/test/internal/unit/genai/langchain/utils.test.ts @@ -933,12 +933,10 @@ describe("setRequestAttributes", () => { ["text", "text"], ["json", "json"], ["image", "image"], - ["speech", "speech"], ["json_object", "json"], ["json_schema", "json"], ["b64_json", "image"], ["url", "image"], - ["audio", "speech"], ]; for (const [outputType, expected] of outputTypes) { @@ -953,6 +951,19 @@ describe("setRequestAttributes", () => { } }); + it("ignores output types not documented by the semantic conventions", () => { + for (const outputType of ["speech", "audio"]) { + const span = makeSpan(); + const run = makeRun({ + extra: { invocation_params: { output_type: outputType } }, + }); + + setRequestAttributes(run, span); + + assert.strictEqual(span.attrs[ATTR_GEN_AI_OUTPUT_TYPE], undefined); + } + }); + it("ignores absent or invalid request parameters", () => { const span = makeSpan(); const run = makeRun({