UNPKG

@sentry/core

Version:
421 lines (364 loc) 15.3 kB
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes.js'; import { GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_TOP_P_ATTRIBUTE, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_STREAM_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, GEN_AI_RESPONSE_ID_ATTRIBUTE } from '../ai/gen-ai-attributes.js'; import { truncateGenAiMessages } from '../ai/messageTruncation.js'; import { LANGCHAIN_ORIGIN, ROLE_MAP } from './constants.js'; /** * Assigns an attribute only when the value is neither `undefined` nor `null`. * * We keep this tiny helper because call sites are repetitive and easy to miswrite. * It also preserves falsy-but-valid values like `0` and `""`. */ const setIfDefined = (target, key, value) => { if (value != null) target[key] = value ; }; /** * Like `setIfDefined`, but converts the value with `Number()` and skips only when the * result is `NaN`. This ensures numeric 0 makes it through (unlike truthy checks). */ const setNumberIfDefined = (target, key, value) => { const n = Number(value); if (!Number.isNaN(n)) target[key] = n; }; /** * Converts a value to a string. Avoids double-quoted JSON strings where a plain * string is desired, but still handles objects/arrays safely. */ function asString(v) { if (typeof v === 'string') return v; try { return JSON.stringify(v); } catch { return String(v); } } /** * Normalizes a single role token to our canonical set. * * @param role Incoming role value (free-form, any casing) * @returns Canonical role: 'user' | 'assistant' | 'system' | 'function' | 'tool' | <passthrough> */ function normalizeMessageRole(role) { const normalized = role.toLowerCase(); return ROLE_MAP[normalized] ?? normalized; } /** * Infers a role from a LangChain message constructor name. * * Checks for substrings like "System", "Human", "AI", etc. */ function normalizeRoleNameFromCtor(name) { if (name.includes('System')) return 'system'; if (name.includes('Human')) return 'user'; if (name.includes('AI') || name.includes('Assistant')) return 'assistant'; if (name.includes('Function')) return 'function'; if (name.includes('Tool')) return 'tool'; return 'user'; } /** * Returns invocation params from a LangChain `tags` object. * * LangChain often passes runtime parameters (model, temperature, etc.) via the * `tags.invocation_params` bag. If `tags` is an array (LangChain sometimes uses * string tags), we return `undefined`. * * @param tags LangChain tags (string[] or record) * @returns The `invocation_params` object, if present */ function getInvocationParams(tags) { if (!tags || Array.isArray(tags)) return undefined; return tags.invocation_params ; } /** * Normalizes a heterogeneous set of LangChain messages to `{ role, content }`. * * Why so many branches? LangChain messages can arrive in several shapes: * - Message classes with `_getType()` (most reliable) * - Classes with meaningful constructor names (e.g. `SystemMessage`) * - Plain objects with `type`, or `{ role, content }` * - Serialized format with `{ lc: 1, id: [...], kwargs: { content } }` * We preserve the prioritization to minimize behavioral drift. * * @param messages Mixed LangChain messages * @returns Array of normalized `{ role, content }` */ function normalizeLangChainMessages(messages) { return messages.map(message => { // 1) Prefer _getType() when present const maybeGetType = (message )._getType; if (typeof maybeGetType === 'function') { const messageType = maybeGetType.call(message); return { role: normalizeMessageRole(messageType), content: asString(message.content), }; } // 2) Then try constructor name (SystemMessage / HumanMessage / ...) const ctor = (message ).constructor?.name; if (ctor) { return { role: normalizeMessageRole(normalizeRoleNameFromCtor(ctor)), content: asString(message.content), }; } // 3) Then objects with `type` if (message.type) { const role = String(message.type).toLowerCase(); return { role: normalizeMessageRole(role), content: asString(message.content), }; } // 4) Then objects with `{ role, content }` if (message.role) { return { role: normalizeMessageRole(String(message.role)), content: asString(message.content), }; } // 5) Serialized LangChain format (lc: 1) if (message.lc === 1 && message.kwargs) { const id = message.id; const messageType = Array.isArray(id) && id.length > 0 ? id[id.length - 1] : ''; const role = typeof messageType === 'string' ? normalizeRoleNameFromCtor(messageType) : 'user'; return { role: normalizeMessageRole(role), content: asString(message.kwargs?.content), }; } // 6) Fallback: treat as user text return { role: 'user', content: asString(message.content), }; }); } /** * Extracts request attributes common to both LLM and ChatModel invocations. * * Source precedence: * 1) `invocationParams` (highest) * 2) `langSmithMetadata` * * Numeric values are set even when 0 (e.g. `temperature: 0`), but skipped if `NaN`. */ function extractCommonRequestAttributes( serialized, invocationParams, langSmithMetadata, ) { const attrs = {}; // Get kwargs if available (from constructor type) const kwargs = 'kwargs' in serialized ? serialized.kwargs : undefined; const temperature = invocationParams?.temperature ?? langSmithMetadata?.ls_temperature ?? kwargs?.temperature; setNumberIfDefined(attrs, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, temperature); const maxTokens = invocationParams?.max_tokens ?? langSmithMetadata?.ls_max_tokens ?? kwargs?.max_tokens; setNumberIfDefined(attrs, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, maxTokens); const topP = invocationParams?.top_p ?? kwargs?.top_p; setNumberIfDefined(attrs, GEN_AI_REQUEST_TOP_P_ATTRIBUTE, topP); const frequencyPenalty = invocationParams?.frequency_penalty; setNumberIfDefined(attrs, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, frequencyPenalty); const presencePenalty = invocationParams?.presence_penalty; setNumberIfDefined(attrs, GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, presencePenalty); // LangChain uses `stream`. We only set the attribute if the key actually exists // (some callbacks report `false` even on streamed requests, this stems from LangChain's callback handler). if (invocationParams && 'stream' in invocationParams) { setIfDefined(attrs, GEN_AI_REQUEST_STREAM_ATTRIBUTE, Boolean(invocationParams.stream)); } return attrs; } /** * Small helper to assemble boilerplate attributes shared by both request extractors. */ function baseRequestAttributes( system, modelName, operation, serialized, invocationParams, langSmithMetadata, ) { return { [GEN_AI_SYSTEM_ATTRIBUTE]: asString(system ?? 'langchain'), [GEN_AI_OPERATION_NAME_ATTRIBUTE]: operation, [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: asString(modelName), [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGCHAIN_ORIGIN, ...extractCommonRequestAttributes(serialized, invocationParams, langSmithMetadata), }; } /** * Extracts attributes for plain LLM invocations (string prompts). * * - Operation is tagged as `pipeline` to distinguish from chat-style invocations. * - When `recordInputs` is true, string prompts are wrapped into `{role:"user"}` * messages to align with the chat schema used elsewhere. */ function extractLLMRequestAttributes( llm, prompts, recordInputs, invocationParams, langSmithMetadata, ) { const system = langSmithMetadata?.ls_provider; const modelName = invocationParams?.model ?? langSmithMetadata?.ls_model_name ?? 'unknown'; const attrs = baseRequestAttributes(system, modelName, 'pipeline', llm, invocationParams, langSmithMetadata); if (recordInputs && Array.isArray(prompts) && prompts.length > 0) { const messages = prompts.map(p => ({ role: 'user', content: p })); setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(messages)); } return attrs; } /** * Extracts attributes for ChatModel invocations (array-of-arrays of messages). * * - Operation is tagged as `chat`. * - We flatten LangChain's `LangChainMessage[][]` and normalize shapes into a * consistent `{ role, content }` array when `recordInputs` is true. * - Provider system value falls back to `serialized.id?.[2]`. */ function extractChatModelRequestAttributes( llm, langChainMessages, recordInputs, invocationParams, langSmithMetadata, ) { const system = langSmithMetadata?.ls_provider ?? llm.id?.[2]; const modelName = invocationParams?.model ?? langSmithMetadata?.ls_model_name ?? 'unknown'; const attrs = baseRequestAttributes(system, modelName, 'chat', llm, invocationParams, langSmithMetadata); if (recordInputs && Array.isArray(langChainMessages) && langChainMessages.length > 0) { const normalized = normalizeLangChainMessages(langChainMessages.flat()); const truncated = truncateGenAiMessages(normalized); setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(truncated)); } return attrs; } /** * Scans generations for Anthropic-style `tool_use` items and records them. * * LangChain represents some provider messages (e.g., Anthropic) with a `message.content` * array that may include objects `{ type: 'tool_use', ... }`. We collect and attach * them as a JSON array on `gen_ai.response.tool_calls` for downstream consumers. */ function addToolCallsAttributes(generations, attrs) { const toolCalls = []; const flatGenerations = generations.flat(); for (const gen of flatGenerations) { const content = gen.message?.content; if (Array.isArray(content)) { for (const item of content) { const t = item ; if (t.type === 'tool_use') toolCalls.push(t); } } } if (toolCalls.length > 0) { setIfDefined(attrs, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, asString(toolCalls)); } } /** * Adds token usage attributes, supporting both OpenAI (`tokenUsage`) and Anthropic (`usage`) formats. * - Preserve zero values (0 tokens) by avoiding truthy checks. * - Compute a total for Anthropic when not explicitly provided. * - Include cache token metrics when present. */ function addTokenUsageAttributes( llmOutput, attrs, ) { if (!llmOutput) return; const tokenUsage = llmOutput.tokenUsage ; const anthropicUsage = llmOutput.usage ; if (tokenUsage) { setNumberIfDefined(attrs, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, tokenUsage.promptTokens); setNumberIfDefined(attrs, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, tokenUsage.completionTokens); setNumberIfDefined(attrs, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, tokenUsage.totalTokens); } else if (anthropicUsage) { setNumberIfDefined(attrs, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, anthropicUsage.input_tokens); setNumberIfDefined(attrs, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, anthropicUsage.output_tokens); // Compute total when not provided by the provider. const input = Number(anthropicUsage.input_tokens); const output = Number(anthropicUsage.output_tokens); const total = (Number.isNaN(input) ? 0 : input) + (Number.isNaN(output) ? 0 : output); if (total > 0) setNumberIfDefined(attrs, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, total); // Extra Anthropic cache metrics (present only when caching is enabled) if (anthropicUsage.cache_creation_input_tokens !== undefined) setNumberIfDefined( attrs, GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS_ATTRIBUTE, anthropicUsage.cache_creation_input_tokens, ); if (anthropicUsage.cache_read_input_tokens !== undefined) setNumberIfDefined(attrs, GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_ATTRIBUTE, anthropicUsage.cache_read_input_tokens); } } /** * Extracts response-related attributes based on a `LangChainLLMResult`. * * - Records finish reasons when present on generations (e.g., OpenAI) * - When `recordOutputs` is true, captures textual response content and any * tool calls. * - Also propagates model name (`model_name` or `model`), response `id`, and * `stop_reason` (for providers that use it). */ function extractLlmResponseAttributes( llmResult, recordOutputs, ) { if (!llmResult) return; const attrs = {}; if (Array.isArray(llmResult.generations)) { const finishReasons = llmResult.generations .flat() .map(g => { // v1 uses generationInfo.finish_reason if (g.generationInfo?.finish_reason) { return g.generationInfo.finish_reason; } // v0.3+ uses generation_info.finish_reason if (g.generation_info?.finish_reason) { return g.generation_info.finish_reason; } return null; }) .filter((r) => typeof r === 'string'); if (finishReasons.length > 0) { setIfDefined(attrs, GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, asString(finishReasons)); } // Tool calls metadata (names, IDs) are not PII, so capture them regardless of recordOutputs addToolCallsAttributes(llmResult.generations , attrs); if (recordOutputs) { const texts = llmResult.generations .flat() .map(gen => gen.text ?? gen.message?.content) .filter(t => typeof t === 'string'); if (texts.length > 0) { setIfDefined(attrs, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, asString(texts)); } } } addTokenUsageAttributes(llmResult.llmOutput, attrs); const llmOutput = llmResult.llmOutput; // Extract from v1 generations structure if available const firstGeneration = llmResult.generations?.[0]?.[0]; const v1Message = firstGeneration?.message; // Provider model identifier: `model_name` (OpenAI-style) or `model` (others) // v1 stores this in message.response_metadata.model_name const modelName = llmOutput?.model_name ?? llmOutput?.model ?? v1Message?.response_metadata?.model_name; if (modelName) setIfDefined(attrs, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, modelName); // Response ID: v1 stores this in message.id const responseId = llmOutput?.id ?? v1Message?.id; if (responseId) { setIfDefined(attrs, GEN_AI_RESPONSE_ID_ATTRIBUTE, responseId); } // Stop reason: v1 stores this in message.response_metadata.finish_reason const stopReason = llmOutput?.stop_reason ?? v1Message?.response_metadata?.finish_reason; if (stopReason) { setIfDefined(attrs, GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, asString(stopReason)); } return attrs; } export { extractChatModelRequestAttributes, extractLLMRequestAttributes, extractLlmResponseAttributes, getInvocationParams, normalizeLangChainMessages }; //# sourceMappingURL=utils.js.map