-
Notifications
You must be signed in to change notification settings - Fork 5
feat(langchain): emit GenAI request attributes #213
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
02f1d14
101be16
14af86a
808ab34
092538e
0110be1
a52fd9b
ae24f29
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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; | ||
| } | ||
|
|
||
| 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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
|
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"])); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.