diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts index 98dab6e77b86..ac40fbe94249 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts @@ -182,6 +182,7 @@ describe('OpenAI Tool Calls integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, 'gen_ai.response.model': 'gpt-4', @@ -212,6 +213,7 @@ describe('OpenAI Tool Calls integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, 'gen_ai.response.model': 'gpt-4', @@ -241,6 +243,7 @@ describe('OpenAI Tool Calls integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, 'gen_ai.response.model': 'gpt-4', @@ -270,6 +273,7 @@ describe('OpenAI Tool Calls integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, 'gen_ai.response.model': 'gpt-4', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index d56bb27f6a24..e3973d95195a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -159,6 +159,7 @@ describe('OpenAI integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.messages.original_length': 2, 'gen_ai.request.messages': '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"What is the capital of France?"}]', 'gen_ai.response.model': 'gpt-3.5-turbo', @@ -214,6 +215,7 @@ describe('OpenAI integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', }, description: 'chat error-model', @@ -231,6 +233,7 @@ describe('OpenAI integration', () => { 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.temperature': 0.8, 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 2, 'gen_ai.request.messages': '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Tell me about streaming"}]', 'gen_ai.response.text': 'Hello from OpenAI streaming!', @@ -287,6 +290,7 @@ describe('OpenAI integration', () => { 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'error-model', 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', 'gen_ai.system': 'openai', 'sentry.op': 'gen_ai.chat', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts index 23520852f070..3784fb7e4631 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts @@ -159,6 +159,7 @@ describe('OpenAI integration (V6)', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.messages.original_length': 2, 'gen_ai.request.messages': '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"What is the capital of France?"}]', 'gen_ai.response.model': 'gpt-3.5-turbo', @@ -214,6 +215,7 @@ describe('OpenAI integration (V6)', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', }, description: 'chat error-model', @@ -231,6 +233,7 @@ describe('OpenAI integration (V6)', () => { 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.temperature': 0.8, 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 2, 'gen_ai.request.messages': '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Tell me about streaming"}]', 'gen_ai.response.text': 'Hello from OpenAI streaming!', @@ -287,6 +290,7 @@ describe('OpenAI integration (V6)', () => { 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'error-model', 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', 'gen_ai.system': 'openai', 'sentry.op': 'gen_ai.chat', @@ -306,6 +310,7 @@ describe('OpenAI integration (V6)', () => { // Check that custom options are respected expect.objectContaining({ data: expect.objectContaining({ + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true }), @@ -313,6 +318,7 @@ describe('OpenAI integration (V6)', () => { // Check that custom options are respected for streaming expect.objectContaining({ data: expect.objectContaining({ + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true 'gen_ai.request.stream': true, // Should be marked as stream diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index 2ccf8a1dc212..8112bcadd5f5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -67,6 +67,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.model': 'mock-model-id', @@ -95,6 +96,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.request.messages': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.response.id': expect.any(String), @@ -205,6 +207,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.prompt': '{"prompt":"Where is the first span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the first span?"}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.model': 'mock-model-id', @@ -237,6 +240,7 @@ describe('Vercel AI integration', () => { // Second span - doGenerate for first call, should also include input/output fields when sendDefaultPii: true expect.objectContaining({ data: { + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['stop'], @@ -275,6 +279,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.model': 'mock-model-id', @@ -308,6 +313,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.request.messages': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.response.id': expect.any(String), @@ -345,6 +351,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather in San Francisco?"}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.model': 'mock-model-id', @@ -380,6 +387,7 @@ describe('Vercel AI integration', () => { data: { 'gen_ai.request.available_tools': EXPECTED_AVAILABLE_TOOLS_JSON, 'gen_ai.request.messages': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['tool-calls'], 'gen_ai.response.id': expect.any(String), diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts index 01aa715bdc77..179644bbcd73 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts @@ -75,6 +75,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.settings.maxRetries': 2, 'vercel.ai.streaming': false, 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', 'gen_ai.response.model': 'mock-model-id', 'gen_ai.usage.input_tokens': 10, @@ -107,6 +108,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.response.id': expect.any(String), 'gen_ai.response.text': expect.any(String), 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.usage.input_tokens': 10, @@ -205,6 +207,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.operationId': 'ai.generateText', 'vercel.ai.pipeline.name': 'generateText', 'vercel.ai.prompt': '{"prompt":"Where is the first span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the first span?"}]', 'vercel.ai.response.finishReason': 'stop', 'gen_ai.response.text': 'First span here!', @@ -231,6 +234,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.model.provider': 'mock-provider', 'vercel.ai.operationId': 'ai.generateText.doGenerate', 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', 'vercel.ai.response.finishReason': 'stop', 'vercel.ai.response.id': expect.any(String), @@ -263,6 +267,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.operationId': 'ai.generateText', 'vercel.ai.pipeline.name': 'generateText', 'vercel.ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', 'vercel.ai.response.finishReason': 'stop', 'gen_ai.response.text': expect.any(String), @@ -300,6 +305,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.response.id': expect.any(String), 'gen_ai.response.text': expect.any(String), 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.usage.input_tokens': 10, @@ -321,6 +327,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.operationId': 'ai.generateText', 'vercel.ai.pipeline.name': 'generateText', 'vercel.ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather in San Francisco?"}]', 'vercel.ai.response.finishReason': 'tool-calls', 'gen_ai.response.tool_calls': expect.any(String), @@ -347,6 +354,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.model.provider': 'mock-provider', 'vercel.ai.operationId': 'ai.generateText.doGenerate', 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), 'vercel.ai.prompt.toolChoice': expect.any(String), 'gen_ai.request.available_tools': EXPECTED_AVAILABLE_TOOLS_JSON, diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index e76b2945b497..6803810f97b4 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -115,6 +115,11 @@ export const GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE = 'gen_ai.usage.total_tokens'; */ export const GEN_AI_OPERATION_NAME_ATTRIBUTE = 'gen_ai.operation.name'; +/** + * Original length of messages array, used to indicate truncations had occured + */ +export const GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE = 'gen_ai.request.messages.original_length'; + /** * The prompt messages * Only recorded when recordInputs is enabled diff --git a/packages/core/src/tracing/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts index d8d06efdc9e5..49ed1c3b3354 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -12,7 +12,6 @@ import { GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, - GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_STREAM_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, @@ -24,13 +23,7 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { - buildMethodPath, - getFinalOperationName, - getSpanOperation, - getTruncatedJsonString, - setTokenUsageAttributes, -} from '../ai/utils'; +import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { AnthropicAiInstrumentedMethod, @@ -39,7 +32,7 @@ import type { AnthropicAiStreamingEvent, ContentBlock, } from './types'; -import { handleResponseError, messagesFromParams, shouldInstrument } from './utils'; +import { handleResponseError, messagesFromParams, setMessagesAttribute, shouldInstrument } from './utils'; /** * Extract request attributes from method arguments @@ -83,15 +76,7 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record): void { const messages = messagesFromParams(params); - if (messages.length) { - const truncatedMessages = getTruncatedJsonString(messages); - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); - } - - if ('input' in params) { - const truncatedInput = getTruncatedJsonString(params.input); - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); - } + setMessagesAttribute(span, messages); if ('prompt' in params) { span.setAttributes({ [GEN_AI_PROMPT_ATTRIBUTE]: JSON.stringify(params.prompt) }); diff --git a/packages/core/src/tracing/anthropic-ai/utils.ts b/packages/core/src/tracing/anthropic-ai/utils.ts index 01f86b41adfc..f10b3ebe6358 100644 --- a/packages/core/src/tracing/anthropic-ai/utils.ts +++ b/packages/core/src/tracing/anthropic-ai/utils.ts @@ -1,6 +1,11 @@ import { captureException } from '../../exports'; import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types-hoist/span'; +import { + GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { getTruncatedJsonString } from '../ai/utils'; import { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; import type { AnthropicAiInstrumentedMethod, AnthropicAiResponse } from './types'; @@ -11,6 +16,19 @@ export function shouldInstrument(methodPath: string): methodPath is AnthropicAiI return ANTHROPIC_AI_INSTRUMENTED_METHODS.includes(methodPath as AnthropicAiInstrumentedMethod); } +/** + * Set the messages and messages original length attributes. + */ +export function setMessagesAttribute(span: Span, messages: unknown): void { + const length = Array.isArray(messages) ? messages.length : undefined; + if (length !== 0) { + span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(messages), + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: length, + }); + } +} + /** * Capture error information from the response * @see https://docs.anthropic.com/en/api/errors#error-shapes @@ -32,11 +50,15 @@ export function handleResponseError(span: Span, response: AnthropicAiResponse): * Include the system prompt in the messages list, if available */ export function messagesFromParams(params: Record): unknown[] { - const { system, messages } = params; + const { system, messages, input } = params; const systemMessages = typeof system === 'string' ? [{ role: 'system', content: params.system }] : []; - const userMessages = Array.isArray(messages) ? messages : messages != null ? [messages] : []; + const inputParamMessages = Array.isArray(input) ? input : input != null ? [input] : undefined; + + const messagesParamMessages = Array.isArray(messages) ? messages : messages != null ? [messages] : []; + + const userMessages = inputParamMessages ?? messagesParamMessages; return [...systemMessages, ...userMessages]; } diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index 9c53e09fd1ca..53af7a9632cb 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -11,6 +11,7 @@ import { GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, @@ -165,8 +166,9 @@ function addPrivateRequestAttributes(span: Span, params: Record messages.push(...contentUnionToMessages(params.message as PartListUnion, 'user')); } - if (messages.length) { + if (Array.isArray(messages) && messages.length) { span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: messages.length, [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncateGenAiMessages(messages)), }); } diff --git a/packages/core/src/tracing/langchain/utils.ts b/packages/core/src/tracing/langchain/utils.ts index 9a8fa9aed26d..0a07ae8df370 100644 --- a/packages/core/src/tracing/langchain/utils.ts +++ b/packages/core/src/tracing/langchain/utils.ts @@ -5,6 +5,7 @@ import { GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_STREAM_ATTRIBUTE, @@ -253,6 +254,7 @@ export function extractLLMRequestAttributes( const attrs = baseRequestAttributes(system, modelName, 'pipeline', llm, invocationParams, langSmithMetadata); if (recordInputs && Array.isArray(prompts) && prompts.length > 0) { + setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, prompts.length); const messages = prompts.map(p => ({ role: 'user', content: p })); setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(messages)); } @@ -282,6 +284,7 @@ export function extractChatModelRequestAttributes( if (recordInputs && Array.isArray(langChainMessages) && langChainMessages.length > 0) { const normalized = normalizeLangChainMessages(langChainMessages.flat()); + setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, normalized.length); const truncated = truncateGenAiMessages(normalized); setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(truncated)); } diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index 5601cddf458b..e5b8d79f72e3 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -8,6 +8,7 @@ import { GEN_AI_PIPELINE_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { truncateGenAiMessages } from '../ai/messageTruncation'; import type { LangChainMessage } from '../langchain/types'; @@ -128,7 +129,10 @@ function instrumentCompiledGraphInvoke( if (inputMessages && recordInputs) { const normalizedMessages = normalizeLangChainMessages(inputMessages); const truncatedMessages = truncateGenAiMessages(normalizedMessages); - span.setAttribute(GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, JSON.stringify(truncatedMessages)); + span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncatedMessages), + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: normalizedMessages.length, + }); } // Call original invoke diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index c68e920daf2b..adc49b2b60bc 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -11,6 +11,7 @@ import { GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_STREAM_ATTRIBUTE, @@ -116,13 +117,15 @@ function addResponseAttributes(span: Span, result: unknown, recordOutputs?: bool // Extract and record AI request inputs, if present. This is intentionally separate from response attributes. function addRequestAttributes(span: Span, params: Record): void { - if ('messages' in params) { - const truncatedMessages = getTruncatedJsonString(params.messages); - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); - } - if ('input' in params) { - const truncatedInput = getTruncatedJsonString(params.input); - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); + const src = 'input' in params ? params.input : 'messages' in params ? params.messages : undefined; + // typically an array, but can be other types. skip if an empty array. + const length = Array.isArray(src) ? src.length : undefined; + if (src && length !== 0) { + const truncatedInput = getTruncatedJsonString(src); + span.setAttribute(GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, truncatedInput); + if (length) { + span.setAttribute(GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, length); + } } } diff --git a/packages/core/src/tracing/vercel-ai/utils.ts b/packages/core/src/tracing/vercel-ai/utils.ts index b6c5b0ad5aab..05dcc1f43817 100644 --- a/packages/core/src/tracing/vercel-ai/utils.ts +++ b/packages/core/src/tracing/vercel-ai/utils.ts @@ -8,6 +8,7 @@ import { GEN_AI_GENERATE_TEXT_DO_GENERATE_OPERATION_ATTRIBUTE, GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_STREAM_OBJECT_DO_STREAM_OPERATION_ATTRIBUTE, GEN_AI_STREAM_TEXT_DO_STREAM_OPERATION_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, @@ -142,7 +143,24 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes !attributes[AI_PROMPT_MESSAGES_ATTRIBUTE] ) { const messages = convertPromptToMessages(prompt); - if (messages.length) span.setAttribute(GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, getTruncatedJsonString(messages)); + if (messages.length) { + span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(messages), + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: messages.length, + }); + } + } else if (typeof attributes[AI_PROMPT_MESSAGES_ATTRIBUTE] === 'string') { + try { + const messages = JSON.parse(attributes[AI_PROMPT_MESSAGES_ATTRIBUTE]); + if (Array.isArray(messages)) { + span.setAttributes({ + [AI_PROMPT_MESSAGES_ATTRIBUTE]: undefined, + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(messages), + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: messages.length, + }); + } + // eslint-disable-next-line no-empty + } catch {} } } diff --git a/packages/core/test/lib/utils/anthropic-utils.test.ts b/packages/core/test/lib/utils/anthropic-utils.test.ts index 0be295b85813..74d4e6b85c17 100644 --- a/packages/core/test/lib/utils/anthropic-utils.test.ts +++ b/packages/core/test/lib/utils/anthropic-utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { messagesFromParams, shouldInstrument } from '../../../src/tracing/anthropic-ai/utils'; +import { messagesFromParams, setMessagesAttribute, shouldInstrument } from '../../../src/tracing/anthropic-ai/utils'; +import type { Span } from '../../../src/types-hoist/span'; describe('anthropic-ai-utils', () => { describe('shouldInstrument', () => { @@ -25,6 +26,19 @@ describe('anthropic-ai-utils', () => { ]); }); + it('looks to params.input ahead of params.messages', () => { + expect( + messagesFromParams({ + input: [{ role: 'user', content: 'input' }], + messages: [{ role: 'user', content: 'hello' }], + system: 'You are a friendly robot awaiting a greeting.', + }), + ).toStrictEqual([ + { role: 'system', content: 'You are a friendly robot awaiting a greeting.' }, + { role: 'user', content: 'input' }, + ]); + }); + it('includes system message along with non-array messages', () => { expect( messagesFromParams({ @@ -53,4 +67,42 @@ describe('anthropic-ai-utils', () => { ).toStrictEqual([{ role: 'user', content: 'hello' }]); }); }); + + describe('setMessagesAtribute', () => { + const mock = { + attributes: {} as Record, + setAttributes(kv: Record) { + for (const [key, val] of Object.entries(kv)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + if (val === undefined) delete this.attributes[key]; + else this.attributes[key] = val; + } + }, + }; + const span = mock as unknown as Span; + + it('sets length along with truncated value', () => { + const content = 'A'.repeat(200_000); + setMessagesAttribute(span, [{ role: 'user', content }]); + const result = [{ role: 'user', content: 'A'.repeat(19972) }]; + expect(mock.attributes).toStrictEqual({ + 'gen_ai.request.messages.original_length': 1, + 'gen_ai.request.messages': JSON.stringify(result), + }); + }); + + it('removes length when setting new value ', () => { + setMessagesAttribute(span, { content: 'hello, world' }); + expect(mock.attributes).toStrictEqual({ + 'gen_ai.request.messages': '{"content":"hello, world"}', + }); + }); + + it('ignores empty array', () => { + setMessagesAttribute(span, []); + expect(mock.attributes).toStrictEqual({ + 'gen_ai.request.messages': '{"content":"hello, world"}', + }); + }); + }); });