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
1 change: 1 addition & 0 deletions src/genai/instrumentations/langchain/tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export class LangChainTracer extends BaseTracer {
}
}
Utils.setModelAttribute(run, span);
Utils.setChoiceCountAttribute(run, span);
Utils.setResponseIdAttribute(run, span);
Utils.setProviderNameAttribute(run, span);
Utils.setSessionIdAttribute(run, span);
Expand Down
48 changes: 43 additions & 5 deletions src/genai/instrumentations/langchain/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_OUTPUT_MESSAGES,
ATTR_GEN_AI_PROVIDER_NAME,
ATTR_GEN_AI_REQUEST_CHOICE_COUNT,
ATTR_GEN_AI_REQUEST_MODEL,
ATTR_GEN_AI_RESPONSE_ID,
ATTR_GEN_AI_RESPONSE_MODEL,
Expand Down Expand Up @@ -461,12 +462,24 @@ export function getRequestModel(run: Run): string | undefined {
// served the request).
export function getResponseModel(run: Run): string | undefined {
const llmOutput = run.outputs?.llmOutput as Record<string, unknown> | undefined;
const v1Metadata = run.outputs?.generations?.[0]?.[0]?.message?.response_metadata as
| Record<string, unknown>
| undefined;
const v0Metadata = run.outputs?.generations?.[0]?.[0]?.message?.kwargs?.response_metadata as
| Record<string, unknown>
| undefined;

return [
// v1: response_metadata directly on message
run.outputs?.generations?.[0]?.[0]?.message?.response_metadata?.model_name,
// v0: response_metadata nested under kwargs
run.outputs?.generations?.[0]?.[0]?.message?.kwargs?.response_metadata?.model_name,
// LLMResult.llmOutput.model_name (common for Chat models)
// v1: response_metadata directly on message. Prefer the canonical OpenAI
// Responses-API field (`model`) and fall back to the `model_name` alias
// LangChain keeps "for backwards compat with chat completion calls" (see
// langchain-ai/langchainjs libs/providers/langchain-openai/src/converters/responses.ts).
v1Metadata?.model,
v1Metadata?.model_name,
// v0: response_metadata nested under kwargs.
v0Metadata?.model,
v0Metadata?.model_name,
// LLMResult.llmOutput.* (common for Chat Completions API).
llmOutput?.model_name,
llmOutput?.model,
]
Expand Down Expand Up @@ -528,6 +541,31 @@ export function setModelAttribute(run: Run, span: Span) {
}
}

// Choice count - Helper to extract the requested number of candidate completions
// (`n` in OpenAI / LangChain `invocation_params`). Per OTel GenAI semconv, this
// attribute is conditionally required when available in the request and not
// equal to 1, so we omit it for the common single-completion case to avoid
// emitting redundant data.
export function getChoiceCount(run: Run): number | undefined {
const invocationParams = run.extra?.invocation_params as Record<string, unknown> | undefined;
const raw = invocationParams?.n;
if (typeof raw === "number" && Number.isFinite(raw) && Number.isInteger(raw) && raw >= 1) {
return raw;
}
if (typeof raw === "string") {
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed >= 1) return parsed;
}
return undefined;
}

export function setChoiceCountAttribute(run: Run, span: Span) {
const n = getChoiceCount(run);
if (n !== undefined && n !== 1) {
span.setAttribute(ATTR_GEN_AI_REQUEST_CHOICE_COUNT, n);
}
}

// 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
1 change: 1 addition & 0 deletions src/genai/semconv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ 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_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_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_PROVIDER_NAME = "gen_ai.provider.name" as const;
Expand Down
96 changes: 96 additions & 0 deletions test/internal/unit/genai/langchain/tracer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import {
ATTR_ERROR_MESSAGE,
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_PROVIDER_NAME,
ATTR_GEN_AI_REQUEST_CHOICE_COUNT,
ATTR_GEN_AI_REQUEST_MODEL,
Comment thread
JacksonWeber marked this conversation as resolved.
ATTR_GEN_AI_RESPONSE_ID,
ATTR_GEN_AI_RESPONSE_MODEL,
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
} from "../../../../../src/genai/index.js";

function makeRun(overrides: Partial<Run> = {}): Run {
Expand Down Expand Up @@ -318,4 +324,94 @@ describe("LangChainTracer", () => {
assert.strictEqual(startSpanCalls.length, 0, "should not create span when MAX_RUNS exceeded");
});
});

// End-to-end shape test for #128 scenario 3 / #150. Mirrors the Run object
// LangChain's `langchain-openai/converters/responses.ts` produces for a single
// ChatOpenAI invocation with `useResponsesApi: true`, and asserts the full
// bundle of GenAI semconv attributes lands on the span.
describe("Responses API (useResponsesApi: true) end-to-end", () => {
it("populates request/response model, response id, and token attrs on a RAPI run", async () => {
const tracer = createMockTracer();
const lct = new LangChainTracer(tracer);

const runId = "rapi-run-1";
const startedRun = makeRun({
id: runId,
run_type: "llm",
name: "ChatOpenAI",
serialized: {
id: ["langchain", "chat_models", "openai", "ChatOpenAI"],
},
extra: {
metadata: { ls_model_name: "deployment-o4-mini", ls_provider: "openai" },
invocation_params: { model: "deployment-o4-mini", n: 3 },
},
inputs: {
messages: [[{ role: "user", content: "hello" }]],
},
});
await lct.onRunCreate(startedRun);

const completedRun = {
...startedRun,
outputs: {
generations: [
[
{
message: {
// Shape produced by libs/providers/langchain-openai/src/converters/responses.ts.
// We pin distinct sentinels on the fields the test is supposed
// to ignore so the assertions actually prove that `model`
// (canonical) and `response_metadata.id` (provider-supplied)
// win over the `model_name` alias and `message.id`.
response_metadata: {
model_provider: "openai",
model: "o4-mini-2025-04-16",
model_name: "model_name-alias-should-be-ignored",
id: "resp_rapi_abc",
created_at: 1_700_000_000,
},
usage_metadata: {
input_tokens: 4,
output_tokens: 7,
},
id: "message-id-should-be-ignored",
},
},
],
],
},
} as unknown as Run;
await (lct as unknown as { _endTrace(run: Run): Promise<void> })._endTrace(completedRun);

const span = tracer.lastSpan!;
const calls = (span.setAttribute as ReturnType<typeof vi.fn>).mock.calls;
const got = (key: string) => calls.find((c: unknown[]) => c[0] === key)?.[1];

assert.strictEqual(got(ATTR_GEN_AI_OPERATION_NAME), "chat", "operation name");
assert.strictEqual(
got(ATTR_GEN_AI_REQUEST_MODEL),
"deployment-o4-mini",
"request model should be the deployment alias",
);
assert.strictEqual(
got(ATTR_GEN_AI_REQUEST_CHOICE_COUNT),
3,
"request choice count should come from invocation_params.n when >1",
);
assert.strictEqual(
got(ATTR_GEN_AI_RESPONSE_MODEL),
"o4-mini-2025-04-16",
"response model should come from response_metadata.model (not the model_name alias)",
);
assert.strictEqual(
got(ATTR_GEN_AI_RESPONSE_ID),
"resp_rapi_abc",
"response id should come from response_metadata.id (not message.id)",
);
assert.strictEqual(got(ATTR_GEN_AI_USAGE_INPUT_TOKENS), 4, "input tokens");
assert.strictEqual(got(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS), 7, "output tokens");
assert.strictEqual(span.statusObj?.code, SpanStatusCode.OK);
});
});
});
Loading
Loading